test: push coverage past ninety-two percent

This commit is contained in:
purocean
2026-05-02 17:26:54 +08:00
parent cfbbf1d72e
commit dfccc3a6cc
17 changed files with 759 additions and 10 deletions
+35
View File
@@ -487,6 +487,41 @@ describe('main app entry', () => {
expect(win.destroy).toHaveBeenCalled()
})
test('covers alternate window restore and action branches', async () => {
const electron = await import('electron')
mocks.store.set('window.state', { x: 5, y: 5, width: 900, height: 500, maximized: true })
await loadApp()
mocks.appEvents.ready()
let win = mocks.browserWindowInstances[0]
expect(win.maximize).toHaveBeenCalled()
await mocks.actions['show-open-dialog']({ properties: ['openFile'] })
expect(electron.dialog.showOpenDialog).toHaveBeenCalledWith(win, { properties: ['openFile'] })
expect(mocks.actions['get-main-widow']()).toBe(win)
expect(mocks.actions['get-url-mode']()).toBe('scheme')
mocks.actions['set-url-mode']('prod')
expect(mocks.actions['get-url-mode']()).toBe('prod')
mocks.actions['open-in-browser']()
expect(mocks.shellOpenExternal).toHaveBeenCalledWith('url:prod:4555:8066')
win.events['enter-full-screen']()
mocks.actions['toggle-fullscreen']()
expect(win.setFullScreen).toHaveBeenCalledWith(false)
win.events['leave-full-screen']()
win.events.closed()
expect(() => mocks.actions['show-main-window']()).not.toThrow()
win = mocks.browserWindowInstances[1]
expect(win).toBeTruthy()
vi.resetModules()
mocks.store.set('window.state', { x: 10, y: 10, width: -1, height: 500, maximized: false })
await loadApp()
mocks.appEvents.ready()
expect(mocks.browserWindowInstances[2].setBounds).not.toHaveBeenCalled()
})
test('handles open-file, second-instance, and open-url branches', async () => {
process.argv = ['/electron', '/app/app.js', 'argv-doc.md']
await loadApp()
+87
View File
@@ -931,4 +931,91 @@ describe('server index module', () => {
loadSpy.mockRestore()
}
})
test('handles websocket pty lifecycle, input, resize, and process cleanup', async () => {
const Module = (await import('node:module')).default as any
const originalLoad = Module._load
const ptyExitHandlers: Function[] = []
let dataHandler: Function | undefined
const ptyProcess = {
kill: vi.fn(),
onData: vi.fn((handler: Function) => {
dataHandler = handler
}),
onExit: vi.fn((handler: Function) => {
ptyExitHandlers.push(handler)
}),
resize: vi.fn(),
write: vi.fn(),
}
const pty = {
spawn: vi.fn(() => ptyProcess),
}
const processOn = vi.spyOn(process, 'on')
const processOff = vi.spyOn(process, 'off')
const loadSpy = vi.spyOn(Module, '_load').mockImplementation((request: string, parent: any, isMain: boolean) => {
if (request === 'http') {
return { createServer: mocks.httpCreateServer }
}
if (request === 'socket.io') {
return mocks.socketIo
}
if (request === 'node-pty') {
return pty
}
return originalLoad.call(Module, request, parent, isMain)
})
mocks.disableServer = false
try {
const { default: server } = await loadServer()
server(3999)
const socketHandlers: Record<string, Function> = {}
const localSocket = {
client: { conn: { remoteAddress: '127.0.0.1' } },
disconnect: vi.fn(),
emit: vi.fn(),
handshake: { query: { cwd: '/repo', env: '{"TERM":"xterm"}' } },
on: vi.fn((event: string, handler: Function) => {
socketHandlers[event] = handler
})
}
mocks.socketEmitter.emit('connection', localSocket)
expect(pty.spawn).toHaveBeenCalledWith('/bin/zsh', [], expect.objectContaining({
cols: 80,
cwd: '/repo',
env: expect.objectContaining({ TERM: 'xterm' }),
rows: 24,
}))
expect(processOn).toHaveBeenCalledWith('exit', expect.any(Function))
dataHandler?.('hello')
expect(localSocket.emit).toHaveBeenCalledWith('output', 'hello')
socketHandlers.input('\u001b]51;A/tmp\n')
expect(mocks.shellTransformCdCommand).toHaveBeenCalledWith('\u001b]51;A/tmp\n')
expect(ptyProcess.write).toHaveBeenLastCalledWith('cd:\u001b]51;A/tmp\n')
socketHandlers.input('echo ok\n')
expect(ptyProcess.write).toHaveBeenLastCalledWith('echo ok\n')
socketHandlers.resize([100, 40])
expect(ptyProcess.resize).toHaveBeenCalledWith(100, 40)
ptyExitHandlers[0]()
expect(localSocket.disconnect).toHaveBeenCalled()
expect(processOff).toHaveBeenCalledWith('exit', expect.any(Function))
const killPromise = socketHandlers.disconnect()
ptyExitHandlers.at(-1)!()
await killPromise
expect(ptyProcess.kill).toHaveBeenCalled()
} finally {
loadSpy.mockRestore()
processOn.mockRestore()
processOff.mockRestore()
}
})
})
+53
View File
@@ -0,0 +1,53 @@
const mocks = vi.hoisted(() => ({
use: vi.fn(),
mount: vi.fn(),
createApp: vi.fn(() => ({
use: mocks.use,
mount: mocks.mount,
})),
plugins: {
directives: { name: 'directives' },
toast: { name: 'toast' },
modal: { name: 'modal' },
contextmenu: { name: 'contextmenu' },
quickFilter: { name: 'quickFilter' },
fixedFloat: { name: 'fixedFloat' },
},
}))
vi.mock('vue', () => ({
createApp: mocks.createApp,
}))
vi.mock('@fe/others/demo', () => ({}))
vi.mock('@fe/Main.vue', () => ({ default: { name: 'Main' } }))
vi.mock('@fe/directives', () => ({ default: mocks.plugins.directives }))
vi.mock('@fe/support/ui/toast', () => ({ default: mocks.plugins.toast }))
vi.mock('@fe/support/ui/modal', () => ({ default: mocks.plugins.modal }))
vi.mock('@fe/support/ui/context-menu', () => ({ default: mocks.plugins.contextmenu }))
vi.mock('@fe/support/ui/quick-filter', () => ({ default: mocks.plugins.quickFilter }))
vi.mock('@fe/support/ui/fixed-float', () => ({ default: mocks.plugins.fixedFloat }))
describe('renderer entry', () => {
beforeEach(() => {
vi.resetModules()
mocks.createApp.mockClear()
mocks.use.mockClear()
mocks.mount.mockClear()
})
test('creates the Vue app, installs shared UI plugins, and mounts it', async () => {
await import('../index')
expect(mocks.createApp).toHaveBeenCalledWith({ name: 'Main' })
expect(mocks.use.mock.calls.map(([plugin]) => plugin)).toEqual([
mocks.plugins.directives,
mocks.plugins.toast,
mocks.plugins.modal,
mocks.plugins.contextmenu,
mocks.plugins.quickFilter,
mocks.plugins.fixedFloat,
])
expect(mocks.mount).toHaveBeenCalledWith('#app')
})
})
@@ -1,5 +1,5 @@
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { flushPromises, shallowMount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({
actions: new Map<string, Function>(),
@@ -12,9 +12,14 @@ const mocks = vi.hoisted(() => ({
commentHistoryVersion: vi.fn(),
deleteHistoryVersion: vi.fn(),
setValue: vi.fn(),
inputPassword: vi.fn(),
decrypt: vi.fn(),
modalInput: vi.fn(),
modalConfirm: vi.fn(),
modalAlert: vi.fn(),
toastShow: vi.fn(),
showPremium: vi.fn(),
purchased: true,
registerHook: vi.fn(),
removeHook: vi.fn(),
createEditor: vi.fn(),
@@ -63,14 +68,14 @@ vi.mock('@fe/services/document', () => ({
isSameFile: (a: any, b: any) => a?.repo === b?.repo && a?.path === b?.path,
}))
vi.mock('@fe/services/base', () => ({ inputPassword: vi.fn() }))
vi.mock('@fe/services/base', () => ({ inputPassword: mocks.inputPassword }))
vi.mock('@fe/services/i18n', () => ({
useI18n: () => ({ t: (key: string, ...args: string[]) => args.length ? `${key}:${args.join(':')}` : key }),
}))
vi.mock('@fe/support/ui/modal', () => ({
useModal: () => ({ input: mocks.modalInput, confirm: mocks.modalConfirm, alert: vi.fn() }),
useModal: () => ({ input: mocks.modalInput, confirm: mocks.modalConfirm, alert: mocks.modalAlert }),
}))
vi.mock('@fe/support/ui/toast', () => ({
@@ -81,11 +86,11 @@ vi.mock('@fe/utils', () => ({
getLogger: () => ({ debug: vi.fn() }),
}))
vi.mock('@fe/utils/crypto', () => ({ decrypt: vi.fn() }))
vi.mock('@fe/utils/crypto', () => ({ decrypt: mocks.decrypt }))
vi.mock('@fe/others/premium', () => ({
getPurchased: () => true,
showPremium: vi.fn(),
getPurchased: () => mocks.purchased,
showPremium: mocks.showPremium,
}))
vi.mock('@fe/support/store', () => ({
@@ -133,9 +138,16 @@ beforeEach(() => {
mocks.commentHistoryVersion.mockResolvedValue(undefined)
mocks.deleteHistoryVersion.mockResolvedValue(undefined)
mocks.setValue.mockReset()
mocks.inputPassword.mockReset()
mocks.inputPassword.mockResolvedValue('password')
mocks.decrypt.mockReset()
mocks.decrypt.mockReturnValue({ content: 'decrypted history' })
mocks.modalInput.mockReset()
mocks.modalConfirm.mockReset()
mocks.modalAlert.mockReset()
mocks.toastShow.mockReset()
mocks.showPremium.mockReset()
mocks.purchased = true
mocks.registerHook.mockReset()
mocks.removeHook.mockReset()
mocks.createEditor.mockReturnValue(editorMock())
@@ -211,4 +223,59 @@ describe('DocHistory', () => {
await (wrapper.vm as any).clearVersions()
expect(mocks.deleteHistoryVersion).toHaveBeenCalledWith(mocks.storeState.currentFile, '--all--')
})
test('handles premium gating, encrypted content, alerts, and cleanup hooks', async () => {
mocks.fetchHistoryList.mockResolvedValue({
size: 4096,
list: [
{ name: '2024-01-04 03-04-05.encrypted.md', comment: '' },
],
})
mocks.fetchHistoryContent.mockResolvedValue('cipher text')
const wrapper = shallowMount(DocHistory, {
global: {
mocks: { $t: (key: string) => key },
stubs: {
XMask: { props: ['show'], template: '<div v-if="show" class="mask-stub"><slot /></div>' },
GroupTabs: {
props: ['modelValue', 'tabs'],
emits: ['update:modelValue'],
template: '<div class="group-tabs-stub"><button v-for="tab in tabs" :key="tab.value" @click="$emit(\'update:modelValue\', tab.value)">{{tab.label}}</button></div>',
},
SvgIcon: { emits: ['click'], template: '<i class="svg-icon" @click="$emit(\'click\', $event)" />' },
},
},
})
mocks.storeState.currentContent = 'x'.repeat(102401)
mocks.actions.get('doc.show-history')?.()
await flushPromises()
await nextTick()
expect(mocks.modalAlert).toHaveBeenCalledWith(expect.objectContaining({
title: 'doc-history.content-too-long-alert.title',
}))
expect(mocks.inputPassword).toHaveBeenCalledWith('document.password-open', 'History Version', true)
expect(mocks.decrypt).toHaveBeenCalledWith('cipher text', 'password')
expect((wrapper.vm as any).content).toBe('decrypted history')
mocks.purchased = false
await (wrapper.vm as any).markVersion((wrapper.vm as any).versions[0])
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'premium.need-purchase:Mark')
expect(mocks.showPremium).toHaveBeenCalled()
mocks.decrypt.mockImplementationOnce(() => {
throw new Error('bad password')
})
;(wrapper.vm as any).currentVersion = undefined
await nextTick()
;(wrapper.vm as any).choose((wrapper.vm as any).versions[0])
await flushPromises()
expect((wrapper.vm as any).content).toBe('document.wrong-password')
wrapper.unmount()
expect(mocks.removeHook).toHaveBeenCalledWith('GLOBAL_RESIZE', expect.any(Function))
expect(mocks.actions.has('doc.show-history')).toBe(false)
})
})
@@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({
toggleExportPanel: vi.fn((val: boolean) => { mocks.storeState.showExport = val }),
downloadContent: vi.fn(),
sleep: vi.fn(() => Promise.resolve()),
isElectron: false,
flagDemo: false,
}))
vi.mock('@share/misc', () => ({
@@ -24,11 +26,11 @@ vi.mock('@fe/support/store', () => ({
}))
vi.mock('@fe/support/env', () => ({
isElectron: false,
get isElectron () { return mocks.isElectron },
}))
vi.mock('@fe/support/args', () => ({
FLAG_DEMO: false,
get FLAG_DEMO () { return mocks.flagDemo },
}))
vi.mock('@fe/support/ui/toast', () => ({
@@ -84,9 +86,12 @@ beforeEach(() => {
mocks.printCurrentDocument.mockReset()
mocks.printCurrentDocument.mockResolvedValue(undefined)
mocks.printCurrentDocumentToPDF.mockReset()
mocks.printCurrentDocumentToPDF.mockResolvedValue(new Blob(['pdf']))
mocks.toggleExportPanel.mockClear()
mocks.downloadContent.mockReset()
mocks.sleep.mockClear()
mocks.isElectron = false
mocks.flagDemo = false
})
describe('ExportPanel', () => {
@@ -136,4 +141,46 @@ describe('ExportPanel', () => {
await nextTick()
expect(convert.localHtmlOptions.includeStyle).toBe(false)
})
test('prints pdf through electron with clamped scale and downloads buffer', async () => {
mocks.isElectron = true
const wrapper = mountExportPanel()
const convert = (wrapper.vm as any).convert
convert.pdfOptions.scaleFactor = 500
convert.pdfOptions.landscape = 'true'
convert.pdfOptions.pageSize = 'Letter'
convert.pdfOptions.printBackground = false
await (wrapper.vm as any).ok()
await flushPromises()
expect(mocks.printCurrentDocumentToPDF).toHaveBeenCalledWith({
pageSize: 'Letter',
printBackground: false,
generateDocumentOutline: true,
landscape: true,
scale: 2,
})
expect(mocks.downloadContent).toHaveBeenCalledWith('note.pdf', expect.any(Blob), 'application/pdf')
})
test('handles demo, missing content, and conversion errors without downloading', async () => {
const wrapper = mountExportPanel()
mocks.storeState.currentFile.content = ''
await (wrapper.vm as any).ok()
expect(mocks.toggleExportPanel).not.toHaveBeenCalled()
mocks.storeState.currentFile.content = '# Note'
mocks.flagDemo = true
;(wrapper.vm as any).convert.toType = 'html'
await (wrapper.vm as any).ok()
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'demo-tips')
expect(mocks.convertCurrentDocument).not.toHaveBeenCalled()
mocks.flagDemo = false
mocks.convertCurrentDocument.mockRejectedValueOnce(new Error('convert failed'))
await expect((wrapper.vm as any).ok()).rejects.toThrow('convert failed')
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'convert failed')
})
})
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
fileTabsTapActionBtns: vi.fn(),
fileTabsRemoveActionBtnTapper: vi.fn(),
fileTabsRefreshActionBtns: vi.fn(),
lastIframe: undefined as any,
xtermInit: vi.fn(),
xtermInput: vi.fn(),
downloadContent: vi.fn(),
@@ -86,6 +87,7 @@ vi.mock('@fe/support/embed', () => ({
}),
},
} as any
mocks.lastIframe = iframe
this.onLoad(iframe)
},
template: '<iframe class="iframe-stub" />',
@@ -203,6 +205,7 @@ beforeEach(() => {
mocks.fileTabsTapActionBtns.mockReset()
mocks.fileTabsRemoveActionBtnTapper.mockReset()
mocks.fileTabsRefreshActionBtns.mockReset()
mocks.lastIframe = undefined
mocks.xtermInit.mockReset()
mocks.xtermInput.mockReset()
mocks.downloadContent.mockReset()
@@ -290,6 +293,44 @@ describe('DefaultPreviewer', () => {
expect(mocks.hooks.has('GLOBAL_RESIZE')).toBe(false)
expect(mocks.fileTabsRemoveActionBtnTapper).toHaveBeenCalledWith(expect.any(Function))
})
test('handles iframe scroll, beforeunload recovery, resize CSS variable, and empty tab action state', async () => {
vi.useFakeTimers()
const wrapper = mount(DefaultPreviewer, {
global: {
mocks: { $t: (key: string) => key },
stubs: { teleport: true },
},
})
await flushPromises()
mocks.lastIframe.onscroll({ target: { documentElement: { scrollTop: 42 } } })
await nextTick()
expect(mocks.triggerHook).toHaveBeenCalledWith('VIEW_SCROLL', expect.any(Object))
expect(wrapper.find('.scroll-to-top').classes()).not.toContain('hide')
Object.defineProperty(wrapper.find('.default-previewer').element, 'clientHeight', {
value: 640,
configurable: true,
})
mocks.hooks.get('GLOBAL_RESIZE')?.()
await nextTick()
expect(mocks.lastIframe.contentDocument.documentElement.style.getPropertyValue('--previewer-height')).toBe('640px')
const tapper = mocks.fileTabsTapActionBtns.mock.calls[0][0]
mocks.storeState.currentFile = undefined
const btns: any[] = []
tapper(btns)
expect(btns).toEqual([])
mocks.lastIframe.onbeforeunload({})
await nextTick()
expect(wrapper.find('.iframe-stub').exists()).toBe(false)
vi.advanceTimersByTime(3000)
expect(mocks.toastShow).toHaveBeenCalledWith('warning', 'IFrame Error!')
vi.useRealTimers()
})
})
describe('ExportPanel extra branches', () => {
@@ -165,6 +165,45 @@ describe('renderer directives', () => {
expect(textarea.value).toBe('second')
})
test('up-down-history ignores guarded keys and trims stored history length', () => {
const directive = installOne(upDownHistory, 'up-down-history')
const textarea = document.createElement('textarea')
const inputListener = vi.fn()
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
lineHeight: '20px',
paddingTop: '0px',
paddingBottom: '0px',
} as CSSStyleDeclaration)
Object.defineProperty(textarea, 'clientHeight', { value: 40, configurable: true })
textarea.addEventListener('input', inputListener)
directive.mounted(textarea, { value: { maxLength: 1 } })
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
textarea.value = 'ignored'
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true }))
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }))
textarea.value = 'first'
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
textarea.value = 'second'
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
textarea.value = 'middle'
textarea.setSelectionRange(1, 1)
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true }))
expect(textarea.value).toBe('middle')
Object.defineProperty(textarea, 'clientHeight', { value: 10, configurable: true })
textarea.setSelectionRange(0, 0)
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true }))
expect(textarea.value).toBe('second')
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }))
expect(textarea.value).toBe('')
expect(inputListener).toHaveBeenCalled()
})
test('fixed-float manages focus, blur, self-click, and escape close reasons', () => {
const directive = installOne(fixedFloat, 'fixed-float')
const el = document.createElement('div')
@@ -193,6 +232,25 @@ describe('renderer directives', () => {
expect(onClose).toHaveBeenLastCalledWith('esc')
})
test('fixed-float ignores invalid or disabled bindings and honors autofocus opt-out', () => {
const directive = installOne(fixedFloat, 'fixed-float')
const invalid = document.createElement('div')
const disabled = document.createElement('div')
const noFocus = document.createElement('div')
vi.spyOn(invalid, 'focus')
vi.spyOn(disabled, 'focus')
vi.spyOn(noFocus, 'focus')
directive.mounted(invalid, { value: undefined })
directive.mounted(disabled, { value: { disable: true, onClose: vi.fn() } })
directive.mounted(noFocus, { value: { disableAutoFocus: true, onClose: vi.fn() } })
expect(invalid.focus).not.toHaveBeenCalled()
expect(disabled.focus).not.toHaveBeenCalled()
expect(noFocus.tabIndex).toBe(0)
expect(noFocus.focus).not.toHaveBeenCalled()
})
test('auto-z-index bumps layers and unregisters escape handler', () => {
const directive = installOne(autoZIndex, 'auto-z-index')
const lower = document.createElement('div')
@@ -281,4 +281,51 @@ describe('editor-paste plugin', () => {
expect(mocks.insert).not.toHaveBeenCalled()
expect(mocks.refreshTree).toHaveBeenCalledTimes(2)
})
test('status menu actions paste clipboard image as base64 and html as markdown', async () => {
const ctx = createCtx()
editorPaste.register(ctx)
await ctx.editor.whenEditorReady.mock.results[0].value
mocks.fileToBase64URL.mockResolvedValue('data:image/png;base64,bWVudQ==')
ctx.base.readFromClipboard.mockImplementationOnce((fn: Function) => {
fn('image/png', async () => new Blob(['menu image'], { type: 'image/png' }))
})
ctx._statusMenus['status-bar-insert'].list[0].onClick()
await vi.waitFor(() => expect(mocks.insert).toHaveBeenCalledWith('![Img](data:image/png;base64,bWVudQ==)\n'))
await vi.waitFor(() => expect(mocks.editor.focus).toHaveBeenCalled())
mocks.insert.mockClear()
mocks.editor.focus.mockClear()
ctx.base.readFromClipboard.mockImplementationOnce((fn: Function) => {
fn('text/html', async () => new Blob(['<h1>Clip</h1><p>Body</p>'], { type: 'text/html' }))
})
ctx._statusMenus['status-bar-insert'].list[1].onClick()
await vi.waitFor(() => expect(mocks.insert).toHaveBeenCalledWith(expect.stringContaining('# Clip')))
expect(mocks.insert).toHaveBeenCalledWith(expect.stringContaining('Body'))
expect(mocks.editor.focus).toHaveBeenCalled()
})
test('paste listener exits when editor is unfocused or clipboard data is missing', () => {
const ctx = createCtx()
editorPaste.register(ctx)
mocks.editor.hasTextFocus.mockReturnValueOnce(false)
pasteListener({
clipboardData: { items: [{ type: 'image/png', getAsFile: vi.fn() }] },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
})
pasteListener({
clipboardData: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
})
expect(mocks.insert).not.toHaveBeenCalled()
expect(mocks.upload).not.toHaveBeenCalled()
expect(mocks.editor.getSelections).not.toHaveBeenCalled()
})
})
@@ -140,4 +140,47 @@ describe('history-stack plugin', () => {
expect(menus['status-bar-navigation'].list.map(item => item.disabled)).toEqual([true, true])
expect(schema.navigation.items.map(item => item.disabled)).toEqual([true, true])
})
test('records skipped same-document positions and ignores history-stack sourced switches', async () => {
const ctx = createCtx()
historyStack.register(ctx)
const positionA = { viewScrollTop: 30, editorScrollTop: 40 }
const positionB = { viewScrollTop: 50, editorScrollTop: 60 }
ctx.hooks.get('DOC_SWITCH_SKIPPED')[0]({ doc: docA, opts: { position: positionA } })
ctx.hooks.get('DOC_SWITCH_SKIPPED')[0]({ doc: docA, opts: { source: 'history-stack', position: positionB } })
ctx.hooks.get('DOC_SWITCH_SKIPPED')[0]({ doc: docB, opts: { position: positionB } })
ctx.actions.get('plugin.document-history-stack.back')()
await Promise.resolve()
expect(ctx.doc.switchDoc).toHaveBeenCalledWith(docA, { source: 'history-stack', position: positionA })
ctx.doc.switchDoc.mockClear()
ctx.hooks.get('DOC_SWITCHED')[0]({ doc: docB, opts: { source: 'history-stack' } })
ctx.actions.get('plugin.document-history-stack.forward')()
await Promise.resolve()
expect(ctx.doc.switchDoc).toHaveBeenCalledWith(docB, { source: 'history-stack', position: positionB })
})
test('removes deleted, moved, and failed documents from the navigation stack', () => {
const ctx = createCtx()
historyStack.register(ctx)
ctx.hooks.get('DOC_SWITCHED')[0]({ doc: docA, opts: {} })
ctx.hooks.get('DOC_SWITCHED')[0]({ doc: docB, opts: {} })
ctx.hooks.get('DOC_DELETED')[0]({ doc: docA })
const menusAfterDelete = { 'status-bar-navigation': { list: [] as any[] } }
ctx.hooks.get('STARTUP')[0]()
ctx.statusBar.tapMenus.mock.calls.at(-1)[0](menusAfterDelete)
expect(menusAfterDelete['status-bar-navigation'].list.map(item => item.disabled)).toEqual([true, true])
ctx.hooks.get('DOC_MOVED')[0]({ oldDoc: docB })
ctx.hooks.get('DOC_SWITCH_FAILED')[0]({ doc: docA })
const schema = { navigation: { items: [] as any[] } }
ctx.workbench.ControlCenter.tapSchema.mock.calls[0][0](schema)
expect(schema.navigation.items.map(item => item.disabled)).toEqual([true, true])
})
})
@@ -16,7 +16,11 @@ vi.mock('@fe/core/hook', () => ({
}))
vi.mock('@fe/support/embed', () => ({
IFrame: { name: 'IFrame' },
IFrame: {
name: 'IFrame',
props: ['html', 'debounce', 'globalStyle', 'iframeProps', 'onLoad'],
template: '<iframe />',
},
}))
vi.mock('@fe/utils', () => ({
@@ -24,6 +28,7 @@ vi.mock('@fe/utils', () => ({
}))
import MarkdownIt from 'markdown-it'
import { mount } from '@vue/test-utils'
import markdownApplet from '../markdown-applet'
function createCtx (md = new MarkdownIt()) {
@@ -106,6 +111,56 @@ describe('markdown-applet plugin', () => {
expect(vnode.props.html).toContain('<input>')
})
test('applet component toggles with render hooks and initializes the iframe', async () => {
const ctx = createCtx()
markdownApplet.register(ctx)
const fence = ctx._md.renderer.rules.fence
const vnode = fence([
{
info: 'html',
content: '<!-- --applet-- -->\n<script>window.loaded = true</script>',
meta: { attrs: { sandbox: 'allow-scripts' } },
},
], 0, {}, { safeMode: false }, {}) as any
const wrapper = mount(vnode.type, { props: vnode.props })
expect(wrapper.findComponent({ name: 'IFrame' }).exists()).toBe(false)
mocks.hooks.get('VIEW_RENDERED')![0]()
await wrapper.vm.$nextTick()
const iframe = document.createElement('iframe')
const contentWindow = { init: vi.fn(), resize: vi.fn() }
Object.defineProperty(iframe, 'contentWindow', {
value: contentWindow,
configurable: true,
})
const frame = wrapper.findComponent({ name: 'IFrame' })
expect(frame.props()).toMatchObject({
debounce: 1000,
globalStyle: true,
iframeProps: {
class: 'applet-iframe',
height: '20px',
sandbox: 'allow-scripts',
},
})
frame.props('onLoad')(iframe)
expect(contentWindow).toMatchObject({ appletId: 'applet-hash123-0' })
expect(contentWindow.init).toHaveBeenCalled()
expect(contentWindow.resize).toHaveBeenCalled()
mocks.hooks.get('DOC_SWITCHED')![0]()
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'IFrame' }).exists()).toBe(false)
wrapper.unmount()
expect(mocks.removeHook).toHaveBeenCalledWith('DOC_SWITCHED', expect.any(Function))
expect(mocks.removeHook).toHaveBeenCalledWith('VIEW_BEFORE_REFRESH', expect.any(Function))
expect(mocks.removeHook).toHaveBeenCalledWith('VIEW_RENDERED', expect.any(Function))
})
test('falls back to the default fence renderer for non applet, non-html, or safe-mode fences', () => {
const ctx = createCtx()
markdownApplet.register(ctx)
@@ -55,6 +55,15 @@ describe('markdown-html plugin', () => {
expect(spanOpen?.attrs).toEqual([['data-id', 'ok']])
})
test('rejects script tags, incomplete comments, and unsupported safe-mode tags', () => {
const md = new MarkdownIt({ html: true })
markdownHtml.register(createCtx(md))
expect(md.parse('<script>alert(1)</script>', {}).some(token => token.tag === 'script')).toBe(false)
expect(md.parse('<!-- missing close', {}).some(token => token.type === 'comment')).toBe(false)
expect(md.parse('<custom>x</custom>', { safeMode: true }).some(token => token.type === 'html_open')).toBe(false)
})
test('hides complete multi-line html comments', () => {
const md = new MarkdownIt({ html: true })
markdownHtml.register(createCtx(md))
@@ -95,4 +104,13 @@ describe('markdown-html plugin', () => {
const indented = md.parse(' <div>code</div>', {})
expect(indented.some(token => token.type === 'html_open')).toBe(false)
})
test('rejects incomplete multi-line blocks and blocks with interrupted indentation', () => {
const md = new MarkdownIt({ html: true })
markdownHtml.register(createCtx(md))
expect(md.parse('<div\nclass="note"', {}).some(token => token.type === 'html_end')).toBe(false)
expect(md.parse('<div>\n\n</div>', {}).some(token => token.type === 'html_end')).toBe(true)
expect(md.parse('<div\n class="note"\n</div>', {}).some(token => token.type === 'html_end')).toBe(true)
})
})
@@ -34,6 +34,15 @@ describe('markdown-imsize plugin', () => {
expect(html).toContain('<img src="tall.png" alt="tall" height="240">')
})
test('keeps title attributes and trailing whitespace around image sizes', () => {
const md = new MarkdownIt()
markdownImsize.register(createCtx(md))
const html = md.render('![alt](photo.png "Caption" =120x80 )')
expect(html).toContain('<img src="photo.png" alt="alt" title="Caption" width="120" height="80">')
})
test('falls back to normal image parsing when size syntax is invalid', () => {
const md = new MarkdownIt()
markdownImsize.register(createCtx(md))
@@ -45,6 +54,16 @@ describe('markdown-imsize plugin', () => {
expect(html).not.toContain('height=')
})
test('rejects malformed links before mutating parser position', () => {
const md = new MarkdownIt()
markdownImsize.register(createCtx(md))
expect(md.render('![alt](photo.png =120)')).toContain('<p>![alt](photo.png =120)</p>')
expect(md.render('![alt](javascript:alert(1) =120x80)')).toContain('<p>![alt](javascript:alert(1) =120x80)</p>')
expect(md.render('![alt]( )')).toContain('<img src="" alt="alt">')
expect(md.render('![alt')).toContain('<p>![alt</p>')
})
test('keeps reference image behavior while installing the custom rule', () => {
const md = new MarkdownIt()
markdownImsize.register(createCtx(md))
@@ -53,4 +72,13 @@ describe('markdown-imsize plugin', () => {
expect(html).toContain('<img src="/img.png" alt="Alt" title="Title">')
})
test('supports collapsed and shortcut reference image labels', () => {
const md = new MarkdownIt()
markdownImsize.register(createCtx(md))
expect(md.render('![Alt][]\n\n[Alt]: /collapsed.png')).toContain('<img src="/collapsed.png" alt="Alt">')
expect(md.render('![Alt]\n\n[Alt]: /shortcut.png')).toContain('<img src="/shortcut.png" alt="Alt">')
expect(md.render('![Missing][ref]')).toContain('<p>![Missing][ref]</p>')
})
})
@@ -169,6 +169,73 @@ describe('markdown-macro plugin', () => {
expect(env.source).toBe('ADA')
})
test('skips macro transform when disabled or in safe mode', () => {
const md = new MarkdownIt()
const ctx = createCtx(md)
markdownMacro.register(ctx)
const disabledEnv: any = { attributes: { enableMacro: false }, source: 'before' }
const safeEnv: any = { attributes: { enableMacro: true }, safeMode: true, source: 'before' }
md.parse('[= 1 + 1 =]', disabledEnv)
md.parse('[= 1 + 1 =]', safeEnv)
expect(disabledEnv.source).toBe('before')
expect(safeEnv.source).toBe('before')
expect(ctx.renderer.getRenderCache).not.toHaveBeenCalled()
})
test('shares exported vars and sequence counters across synchronous macros', () => {
const md = new MarkdownIt()
const ctx = createCtx(md)
markdownMacro.register(ctx)
const env: any = {
attributes: { enableMacro: true },
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
}
md.parse('[= $seq("n") =],[= $seq("n") =],[= $export("name", "Ada") =][= name =]', env)
expect(env.source).toBe('n1,n2,Ada')
})
test('caches include errors for non-markdown paths without reading files', async () => {
const md = new MarkdownIt()
const ctx = createCtx(md)
markdownMacro.register(ctx)
const env: any = {
attributes: { enableMacro: true },
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
}
md.parse('[= await $include("child.txt") =]', env)
expect(env.source).toBe('running...')
await vi.waitFor(() => {
const retryEnv: any = {
attributes: { enableMacro: true },
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
}
md.parse('[= await $include("child.txt") =]', retryEnv)
expect(retryEnv.source).toBe('Error: $include path is not a plain file')
})
expect(mocks.readFile).not.toHaveBeenCalled()
})
test('reports after-macro errors through toast and keeps transformed body', () => {
const md = new MarkdownIt()
const ctx = createCtx(md)
markdownMacro.register(ctx)
const env: any = {
attributes: { enableMacro: true },
file: { type: 'file', repo: 'main', name: 'note.md', path: '/repo/note.md' },
}
md.parse('[= $afterMacro(() => { throw new Error("bad hook") }) =]body', env)
expect(env.source).toBe('body')
expect(mocks.toastShow).toHaveBeenCalledWith('warning', '[$afterMacro]: Error: bad hook')
})
test('resolves included markdown with front matter vars through the render cache', async () => {
const md = new MarkdownIt()
const ctx = createCtx(md)
@@ -97,6 +97,21 @@ describe('markdown-toc plugin', () => {
log.mockRestore()
})
test('handles inline toc markers and empty heading sets', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const md = new MarkdownIt()
markdownToc.register(createCtx(md))
expect(md.render('plain [toc] text')).toContain('[object Object]')
const tokens = md.parse('[toc]\n\nparagraph only', {})
const tocToken = tokens.flatMap(token => token.children || []).find(token => token.type === 'toc_body')!
const vnode = md.renderer.rules.toc_body!([tocToken] as any, 0, md.options, {}, md.renderer as any) as any
expect(vnode.props.innerHTML).toContain('<ul></ul>')
log.mockRestore()
})
test('renders full toc chunks and preserves existing heading attrs', () => {
const md = new MarkdownIt()
markdownToc.register(createCtx(md))
@@ -556,6 +556,27 @@ describe('document service pure helpers', () => {
await expect(createDir({ repo: 'main', content: undefined }, { ...fileDoc, type: 'dir', path: '/folder' })).resolves.toBeUndefined()
})
test('lets the create-file panel update doc type before building the path', async () => {
const customType = {
id: 'vector',
displayName: 'Vector',
extension: ['.svg'],
plain: true,
buildNewContent: (filename: string) => `<svg data-name="${filename}" />`,
}
modalMocks.input.mockImplementationOnce((opts: any) => {
const vnode = opts.component()
expect(vnode.props.currentPath).toBe('/folder')
vnode.props.onUpdateDocType(customType)
return 'icon'
})
const doc = await createDoc({ repo: 'main' }, { ...fileDoc, type: 'dir', path: '/folder' })
expect(doc).toMatchObject({ path: '/folder/icon.svg', name: 'icon.svg' })
expect(apiMocks.writeFile).toHaveBeenLastCalledWith(doc, '<svg data-name="icon.svg" />', false)
})
test('handles create-doc builder errors, inline base64 payloads, and encrypted creation', async () => {
const category: DocCategory = {
category: 'custom-docs',
@@ -601,6 +622,34 @@ describe('document service pure helpers', () => {
expect(apiMocks.writeFile).toHaveBeenLastCalledWith(encrypted, 'encrypted:secret', false)
})
test('runs custom save-confirm actions and tolerates force switching after save errors', async () => {
const doc = { ...fileDoc, status: 'loaded' as const }
storeMock.state.currentFile = doc
storeMock.state.currentContent = 'changed'
storeMock.getters.isSaved.value = false
settingMocks.values.set('auto-save', 0)
modalMocks.confirm.mockImplementationOnce((opts: any) => {
opts.action.children[0].props.onClick()
return new Promise(() => undefined)
})
await ensureCurrentFileSaved()
expect(apiMocks.writeFile).toHaveBeenCalledWith(doc, 'changed')
storeMock.state.currentFile = { ...fileDoc, status: 'loaded' as const }
storeMock.state.currentContent = 'still changed'
storeMock.getters.isSaved.value = false
modalMocks.confirm.mockResolvedValueOnce(false)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
await switchDoc({ ...fileDoc, path: '/dir/forced.md', name: 'forced.md' }, { force: true })
expect(consoleError).toHaveBeenCalledWith(expect.any(Error))
expect(storeMock.state.currentFile).toMatchObject({ path: '/dir/forced.md', status: 'loaded' })
consoleError.mockRestore()
})
test('covers guarded document operations and error branches', async () => {
await expect(duplicateDoc({ ...fileDoc, type: 'dir' as any })).rejects.toThrow('Invalid document type')
modalMocks.input.mockResolvedValueOnce('')
+26
View File
@@ -16,6 +16,9 @@ vi.mock('@fe/utils/storage', () => storageMocks)
vi.mock('@fe/core/hook', () => hookMocks)
import { defineComponent, h, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
describe('renderer i18n service', () => {
beforeEach(() => {
storageMocks.values.clear()
@@ -107,4 +110,27 @@ describe('renderer i18n service', () => {
expect(() => i18n.useI18n()).toThrow('VM Error')
})
test('installs component translator and unregisters language hook on unmount', async () => {
const i18n = await import('@fe/services/i18n')
i18n.setLanguage('en')
const component = defineComponent({
setup () {
const { $t } = i18n.useI18n()
return () => h('span', $t.value('app.quit' as any))
},
})
const wrapper = mount(component)
await nextTick()
expect(wrapper.text()).toBe(i18n.t('app.quit' as any))
expect((wrapper.vm as any).$t('app.quit')).toBe(i18n.t('app.quit' as any))
expect(hookMocks.registerHook).toHaveBeenCalledWith('I18N_CHANGE_LANGUAGE', expect.any(Function))
hookMocks.registerHook.mock.calls[0][1]()
wrapper.unmount()
expect(hookMocks.removeHook).toHaveBeenCalledWith('I18N_CHANGE_LANGUAGE', expect.any(Function))
})
})
+14 -1
View File
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
fetchTree: vi.fn(),
toastShow: vi.fn(),
warn: vi.fn(),
watchCallback: undefined as any,
}))
vi.mock('vue', async importOriginal => ({
@@ -44,7 +45,9 @@ vi.mock('@fe/core/ioc', () => ({
vi.mock('@fe/support/store', () => ({
default: {
state: mocks.state,
watch: vi.fn(),
watch: vi.fn((_getter: Function, callback: Function) => {
mocks.watchCallback = callback
}),
},
}))
@@ -142,3 +145,13 @@ test('does not fetch without a current repo and reveals current node via action'
expect(mocks.warn).toHaveBeenCalledWith('No repo')
expect(mocks.reveal).toHaveBeenCalledTimes(1)
})
test('refreshes and reveals when tree sort changes', async () => {
mocks.state.currentRepo = { name: 'notes' }
mocks.fetchTree.mockResolvedValue([{ name: '/', children: [] }])
await mocks.watchCallback()
expect(mocks.fetchTree).toHaveBeenCalledWith('notes', { by: 'serial', order: 'asc' })
expect(mocks.reveal).toHaveBeenCalledTimes(1)
})