Files
Notes/tests/unit/markdown-viewer.test.js
2026-09-12 14:15:26 +08:00

778 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/markdown-viewer.js 测试
//
// 覆盖:
// - 链接点击委托:内部锚点 / 外部链接 / 空 href 区分
// - scrollToAnchor含百分号编码 + 中文 id
// - scrollPositions LRU 淘汰:超出上限时按插入顺序删除最早
// - onHeadingChange 订阅 + 立即触发
// - showUnsupportedFile清空 heading observer
// - clearscrollPositions 清空 + 当前文件清空
//
// 不测试 resolveRelativeImages 的实际路径计算(依赖 utils 的 dirnameOf / resolvePath
// 仅测试设置 loading="lazy" / decoding="async" 的副作用。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MarkdownViewer } from '../../src/markdown-viewer.js';
// jsdom 不实现 scrollIntoViewpolyfill 成 noop 让 scrollToAnchor 路径
// 不会因为浏览器 API 缺失抛错。生产环境Chromium总是有这个方法。
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// jsdom 在 POSIX 路径下行为一致resolvePath 直接走字符串处理);
// 跨盘符Windows D:\\ → C:\\)的真机差异由 utils.isPathInside 自己负责,
// 这里只验证「resolveRelativeImages 调用了边界检查」+「拦截行为正确」。
// 提到 module-scope 让所有 describe 都能直接复用。
function posixPath(s) {
return s.replace(/\\/g, '/');
}
// 把 marked 替换成可控的渲染器:避免依赖完整 marked + DOMPurify 初始化
// preload 才有完整链路;单测只关心 MarkdownViewer 自身逻辑)。
//
// 实际上 MarkdownViewer 内部调的是 utils.safeRenderMarkdown —— 这是个
// preload contextBridge 的产物,单测里没有。我们用 vi.spyOn 拦截 renderMarkdown
// 行为不可行renderMarkdown 是私有),所以:
// 1) 把 bodyElement 的 innerHTML 提前设好,模拟已渲染内容
// 2) 直接调 viewer 暴露的方法scrollToAnchor / resolveRelativeImages / onHeadingChange 等)
// 3) 不走 loadContent 的渲染链路
describe('MarkdownViewer 链接点击委托', () => {
let body;
let empty;
let viewer;
let onExternalLink;
beforeEach(() => {
document.body.innerHTML = `
<article class="body"><a href="https://example.com">外链</a>
<a href="#section-1">内部锚点</a>
<a href="">空 href</a>
</article>
<div class="empty"></div>
`;
body = document.querySelector('.body');
empty = document.querySelector('.empty');
onExternalLink = vi.fn();
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
onExternalLink,
});
});
it('点击外部链接 → onExternalLink 收到 hrefpreventDefault', () => {
const link = body.querySelector('a[href="https://example.com"]');
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(ev);
expect(onExternalLink).toHaveBeenCalledWith('https://example.com');
expect(ev.defaultPrevented).toBe(true);
});
it('点击内部锚点 → 不调 onExternalLink自己 scrollToAnchor', () => {
body.innerHTML = '<a id="section-1"></a><a href="#section-1">跳到 section-1</a>';
const link = body.querySelector('a[href="#section-1"]');
// scrollIntoView 在 jsdom 里是 noop不会抛错
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onExternalLink).not.toHaveBeenCalled();
});
it('点击空 href → 既不触发外链也不报错', () => {
const link = body.querySelector('a[href=""]');
expect(() => {
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}).not.toThrow();
expect(onExternalLink).not.toHaveBeenCalled();
});
it('链接之外的 click<p>)→ 不触发任何回调', () => {
body.appendChild(document.createTextNode('普通文本'));
body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onExternalLink).not.toHaveBeenCalled();
});
});
describe('MarkdownViewer scrollToAnchor', () => {
let body;
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('能找到 id → scrollIntoView + 返回 true', () => {
const target = document.createElement('h2');
target.id = 'section-1';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
const ok = viewer.scrollToAnchor('section-1');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalledWith({ block: 'start' });
});
it('百分号编码的 id → 先 decode 再匹配', () => {
// marked 会把 href 里的中文 percent-encode
const target = document.createElement('h2');
target.id = '你好';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
// '你好' percent-encoded = %E4%BD%A0%E5%A5%BD
const ok = viewer.scrollToAnchor('%E4%BD%A0%E5%A5%BD');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalled();
});
it('非法编码 → 不抛错,正常返回', () => {
// 半截编码在现代 WHATWG 解码下不会抛错,而是返回 replacement char (U+FFFD)。
// 测试契约:调用本身不抛异常;找不到 id 返回 false。
const target = document.createElement('h2');
target.id = 'plain';
body.appendChild(target);
expect(() => viewer.scrollToAnchor('%E0%A4%A')).not.toThrow();
// 因为 decode 出 replacement char原文与目标都不匹配 → 找不到
expect(viewer.scrollToAnchor('%E0%A4%A')).toBe(false);
});
it('找不到 id → 返回 false', () => {
expect(viewer.scrollToAnchor('nonexistent')).toBe(false);
});
it('空 hash → 返回 false', () => {
expect(viewer.scrollToAnchor('')).toBe(false);
});
it('id 含双引号 → 用属性选择器 escape 仍能匹配', () => {
// 防御性:用户笔记的 heading id 含特殊字符时不应破坏选择器
const target = document.createElement('h2');
target.id = 'quote"inside';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
const ok = viewer.scrollToAnchor('quote"inside');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalled();
});
});
describe('MarkdownViewer scrollPositions LRU 淘汰', () => {
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
viewer = new MarkdownViewer({
bodyElement: document.querySelector('.body'),
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('超出 SCROLL_POSITION_CACHE_LIMIT 后按插入顺序淘汰最旧', () => {
const LIMIT = viewer.SCROLL_POSITION_CACHE_LIMIT;
// 注入 LIMIT + 5 条
for (let i = 0; i < LIMIT + 5; i += 1) {
viewer.scrollPositions.set(`path-${i}`, i * 10);
}
// 触发 LRU 淘汰loadContent 会做,但更直接是模拟 setFiles 的行为:
// 不暴露外部接口添加,就通过 Map 自身操作 + verify size
expect(viewer.scrollPositions.size).toBe(LIMIT + 5);
// 这里我们直接构造 LRU 行为(同 loadContent 内部代码):
while (viewer.scrollPositions.size > LIMIT) {
const oldest = viewer.scrollPositions.keys().next().value;
viewer.scrollPositions.delete(oldest);
}
// 验证path-0 ~ path-4 已被淘汰path-5 还在
expect(viewer.scrollPositions.has('path-0')).toBe(false);
expect(viewer.scrollPositions.has('path-4')).toBe(false);
expect(viewer.scrollPositions.has('path-5')).toBe(true);
expect(viewer.scrollPositions.has(`path-${LIMIT + 4}`)).toBe(true);
});
it('clear() 不清空 scrollPositions保留 LRU 缓存)', () => {
// audit fix (C2-renderer)clear 只应清当前文件的视图状态body hidden、
// currentFile=null不应清 scrollPositions —— loadContent 已经在切换前把
// top 存进 scrollPositions再清一次反而覆盖。
viewer.scrollPositions.set('foo', 100);
viewer.scrollPositions.set('bar', 200);
viewer.clear();
expect(viewer.scrollPositions.size).toBe(2);
expect(viewer.scrollPositions.get('foo')).toBe(100);
expect(viewer.scrollPositions.get('bar')).toBe(200);
});
});
describe('MarkdownViewer 订阅契约', () => {
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
viewer = new MarkdownViewer({
bodyElement: document.querySelector('.body'),
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('onHeadingChange 订阅时立即触发一次(拿到当前 heading', () => {
const cb = vi.fn();
viewer.onHeadingChange(cb);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(null);
});
it('返回的 unsubscribe 函数可移除订阅', () => {
const cb = vi.fn();
const unsub = viewer.onHeadingChange(cb);
expect(cb).toHaveBeenCalledTimes(1); // 立即触发
unsub();
// clear / loadContent 不会再调 cb
viewer.clear();
expect(cb).toHaveBeenCalledTimes(1);
});
it('订阅者抛错不应让其它订阅者收不到回调', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const cb1 = vi.fn(() => { throw new Error('boom'); });
const cb2 = vi.fn();
viewer.onHeadingChange(cb1);
viewer.onHeadingChange(cb2);
expect(cb2).toHaveBeenCalledTimes(1); // 立即触发时 cb2 也跑过了
cb1.mockClear();
cb2.mockClear();
viewer.clear();
expect(cb1).toHaveBeenCalledTimes(1); // cb1 又跑了一次并抛错
expect(cb2).toHaveBeenCalledTimes(1); // cb2 仍然被调到
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
it('非函数 onHeadingChange → 返回 noop unsubscribe不抛错', () => {
expect(() => {
const ret = viewer.onHeadingChange(null);
expect(typeof ret).toBe('function');
}).not.toThrow();
});
});
describe('MarkdownViewer showUnsupportedFile / clear', () => {
let viewer;
let body;
let empty;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty">默认空态</div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('showUnsupportedFile → body 清空 + empty 改为"不支持预览" + heading 清空', () => {
viewer.showUnsupportedFile({ name: 'foo.py', path: '/d/foo.py', size: 100 });
expect(body.innerHTML).toBe('');
expect(empty.innerHTML).toMatch(/不支持预览/);
expect(viewer._headingMap.size).toBe(0);
expect(viewer.currentHeadingId).toBe(null);
});
it('clear → body hidden + currentFile=null + scrollPositions 保留', () => {
// scrollPositions 保留 —— 上面的 LRU 测试已覆盖,这里只校验 body / currentFile
viewer.scrollPositions.set('foo', 100);
viewer.currentFile = { path: '/d/foo', name: 'foo' };
viewer.clear();
expect(body.hidden).toBe(true);
expect(viewer.currentFile).toBe(null);
expect(viewer.scrollPositions.has('foo')).toBe(true);
});
it('loadContent 路径里恢复空态默认文案(被 showUnsupportedFile 改过后)', () => {
// 模拟 showUnsupportedFile 改写空态
viewer.showUnsupportedFile({ name: 'foo.py', path: '/d/foo.py' });
expect(empty.innerHTML).toMatch(/不支持预览/);
// 直接用 setBodyElement.innerHTML 模拟 loadContent 里的 _resetEmptyState
// (避免依赖 utils.safeRenderMarkdown
viewer._resetEmptyState();
expect(empty.innerHTML).toBe('默认空态');
});
it('Phase N M-1 修复loadContent(空字符串) → 显示「(空文件)」占位', () => {
// 旧实现 renderMarkdown('') → bodyElement.innerHTML='' → 用户看到空白面板,
// 没有"文件确实是空的"提示。现在显示「(空文件)」占位文案。
viewer.loadContent({ name: 'empty.md', path: '/d/empty.md', size: 0 }, '');
expect(body.hidden).toBe(false);
expect(body.innerHTML).toMatch(/空文件/);
// emptyElement 仍然隐藏(文件是打开状态,不是"未打开文件"空态)
expect(empty.hidden).toBe(true);
// headingMap 清空,避免出现 stale heading
expect(viewer._headingMap.size).toBe(0);
});
it('Phase N M-1loadContent(纯空白字符串) 同样显示「(空文件)」', () => {
// trim 后为空就走占位路径,否则 \n\n\n 这种纯空白文件还走 markdown 渲染
// 会出现一个空的 <p></p> 占位区域(实际 DOM 是空),用户依然没"是空文件"提示。
viewer.loadContent({ name: 'blank.md', path: '/d/blank.md', size: 3 }, ' \n\n ');
expect(body.innerHTML).toMatch(/空文件/);
});
it('Phase N M-1loadContent(非空) 走原 markdown 渲染路径', () => {
// 防御:占位特判只对 trim 后为空的 content 生效。非空文件保持原 renderMarkdown 流程。
viewer.loadContent({ name: 'real.md', path: '/d/real.md', size: 5 }, '# hi');
expect(body.innerHTML).not.toMatch(/空文件/);
expect(body.innerHTML).toMatch(/hi/);
});
});
describe('MarkdownViewer 状态栏路径跟随当前目录 (无文件时)', () => {
let viewer;
let body;
let empty;
let statusPath;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
statusPath = document.createElement('div');
});
function makeViewer(getCurrentDir) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath,
statusMeta: document.createElement('div'),
getCurrentDir,
});
}
it('无 getCurrentDir 时 clear() 沿用「—」占位', () => {
viewer = makeViewer(undefined);
viewer.clear();
expect(statusPath.textContent).toBe('—');
});
it('getCurrentDir 返回路径时clear() 用文件夹路径替代「—」', () => {
viewer = makeViewer(() => '/data/notes/sub');
viewer.clear();
expect(statusPath.textContent).toBe('/data/notes/sub');
});
it('refreshCurrentDir 在无文件时把状态栏路径切到当前目录', () => {
// 模拟「目录路径后续才可用」viewer 构造时 callback 还返回空clear 后再赋值
let cur = '';
viewer = makeViewer(() => cur);
viewer.clear();
expect(statusPath.textContent).toBe('—');
cur = '/data/notes';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes');
cur = '/data/notes/sub/deeper';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes/sub/deeper');
});
it('refreshCurrentDir 不覆盖已打开文件的路径(文件路径优先)', () => {
viewer = makeViewer(() => '/data/notes');
viewer.currentFile = { path: '/data/notes/foo.md', name: 'foo.md' };
statusPath.textContent = '/data/notes/foo.md';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes/foo.md');
});
});
describe('MarkdownViewer resolveRelativeImages stale-body 防护 (perf-MEDIUM)', () => {
let viewer;
let body;
it('loadContent B 替换 body 后A 启动的 resolveRelativeImages 不污染 B', async () => {
// 模拟「A→B 快速切换」场景A 的 resolveRelativeImages 启动后,
// loadContent(B) 已替换 bodyElementA 的 await 回来后必须放弃修改。
//
// 用一个 awaitable getDataDir 模拟 IPC调用 resolveRelativeImages 后
// 不 await 立刻构造新 viewer替换 bodyElement再 await 完成。
document.body.innerHTML = '<article class="body"><img src="img.png"></article><div class="empty"></div>';
body = document.querySelector('.body');
let resolveGDR;
const slowDataDir = new Promise((r) => { resolveGDR = r; });
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir: () => slowDataDir,
});
// A 的解析启动fire-and-forget
const aPromise = viewer.resolveRelativeImages('/notes/a.md');
// 等一个 microtask 让 resolveRelativeImages 进入 await
await Promise.resolve();
// B 替换 body —— 直接覆盖 bodyElement 模拟「A→B 切文件」,
// 并自增 _resolveGen generation token让 A 那个早于本次的
// resolveRelativeImages 回调在 await getDataDir 之后失配并自行 return。
// 这是 commit 3cba60d 把 capturedBody 改成 generation token 后的真实路径:
// loadContent/loadContentLive 在替换 innerHTML 之前 ++_resolveGen
// 我们这里直接调那个副作用。
const newBody = document.createElement('article');
newBody.className = 'body';
document.body.appendChild(newBody);
viewer.bodyElement = newBody;
// _resolveGen 是私有字段,但 resolveRelativeImages 入口会 ++ 它并捕获
// 到局部变量 myGen这里再 ++ 一次代表「被 loadContent 顶掉」。
viewer._resolveGen += 1;
// 把新 body 放一个 srcB 不该被 A 改写
const bImg = document.createElement('img');
bImg.setAttribute('src', 'b.png');
newBody.appendChild(bImg);
// A 的 getDataDir 现在 resolve —— A 应当 detect generation 已变并直接 return
resolveGDR(posixPath('/notes'));
await aPromise;
// 关键断言B 的 img src 必须是原始 b.png不能被 A 的解析覆盖
expect(newBody.querySelector('img').getAttribute('src')).toBe('b.png');
});
it('未替换 body 时,正常修改应继续生效(不退化)', async () => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir: () => posixPath('/notes'),
});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
});
});
describe('MarkdownViewer resolveRelativeImages 副作用', () => {
let viewer;
let body;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('图片自动加 loading="lazy" + decoding="async"', () => {
body.innerHTML = '<img src="img.png" /><img src="img2.png" />';
viewer.resolveRelativeImages('/notes/test.md');
for (const img of body.querySelectorAll('img')) {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
}
});
it('已有 loading/decoding 不覆盖', () => {
body.innerHTML = '<img src="img.png" loading="eager" decoding="sync" />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('loading')).toBe('eager');
expect(img.getAttribute('decoding')).toBe('sync');
});
it('data: URL 图片不动', () => {
body.innerHTML = '<img src="data:image/png;base64,iVBOR..." />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBOR...');
// 不加 loading/lazy绝对 / data: 不参与处理
expect(img.getAttribute('loading')).toBeNull();
});
it('绝对 file:// / https:// URL 图片不动', () => {
body.innerHTML = '<img src="https://cdn.example.com/img.png" />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('https://cdn.example.com/img.png');
});
});
describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)', () => {
let viewer;
let body;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
});
// audit C3恶意 markdown 可以写 `<img src="../../../etc/passwd.png">`
// 之前 resolveRelativeImages 不做任何边界检查,解析后的 file:// URL
// 直接让 Electron 加载受害机器上的任意可读文件。现在构造时传入 getDataDir
// 越界图片会被剥 src + 加 data-blocked="external" 标记(不暴露外部路径)。
function makeViewer(getDataDir) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir,
});
}
// jsdom 在 POSIX 路径下行为一致resolvePath 直接走字符串处理);
// 跨盘符Windows D:\\ → C:\\)的真机差异由 utils.isPathInside 自己负责,
// 这里只验证「resolveRelativeImages 调用了边界检查」+「拦截行为正确」。
// posixPath 已上提到 module-scope与 stale-body 测试共用)。
it('图片 src 越出数据目录 → src 被剥 + data-blocked="external"', async () => {
viewer = makeViewer(() => posixPath('/home/user/Notes'));
// 父文件在 /home/user/Notes/,相对路径 "../../../etc/passwd.png"
// 解析后会跳到 /etc/passwd.png —— 越过 /home/user/Notes 边界。
body.innerHTML = '<img src="../../../etc/passwd.png" alt="secret">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.hasAttribute('src')).toBe(false);
expect(img.getAttribute('data-blocked')).toBe('external');
// alt 提到 title可访问性兜底
expect(img.getAttribute('title')).toMatch(/外部图片已拦截secret/);
});
it('图片 src 在数据目录内 → 不被拦截', async () => {
viewer = makeViewer(() => posixPath('/home/user/Notes'));
body.innerHTML = '<img src="subdir/img.png" alt="x">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.hasAttribute('data-blocked')).toBe(false);
// src 应该被改成 file:// 绝对路径
expect(img.getAttribute('src')).toMatch(/^file:/);
expect(img.getAttribute('src')).toMatch(/img\.png$/);
});
it('getDataDir 返回 null旧版未注入 → 不做边界检查,仅防 ../.. 越界', async () => {
viewer = makeViewer(() => null);
// 不传 / 传 null 时 resolveRelativeImages 跳过 isPathInside 校验。
// resolvePath 内部仍会拒绝 '../..' 越界(保留旧行为)。
body.innerHTML = '<img src="../../outside.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
const img = body.querySelector('img');
// resolvePath 返回 null → src 不变
expect(img.hasAttribute('src')).toBe(true);
expect(img.getAttribute('src')).toBe('../../outside.png');
});
it('getDataDir 抛错 → 不阻断catch 降级,旧行为继续)', async () => {
viewer = makeViewer(() => { throw new Error('IPC down'); });
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
// 不抛错;图片被正常解析
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
consoleWarn.mockRestore();
});
it('getDataDir 返回 Promise异步 IPC → 正确 await', async () => {
viewer = makeViewer(async () => posixPath('/home/user/Notes'));
body.innerHTML = '<img src="../../../etc/passwd.png">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.getAttribute('data-blocked')).toBe('external');
});
it('未传 getDataDir 选项 → 不做边界检查(旧行为兼容)', async () => {
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
// 不传 getDataDir
});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
});
});
// audit fix (Round 12)onExternalLink async 回调未接 promise → IPC throw 时
// 变成 unhandledRejection。viewer 这层 catch 住,调用方不能再依赖「调用 fire-and-forget」
// 必须接 promise 或主动 catch。
describe('MarkdownViewer 外链回调错误边界 (audit Round 12 P2)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
function makeViewer(onExternalLink) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
onExternalLink,
});
}
function makeLink(href) {
const a = document.createElement('a');
a.setAttribute('href', href);
a.textContent = 'x';
body.appendChild(a);
return a;
}
it('onExternalLink 同步 throw → 不冒到 document unhandledRejection', () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => { throw new Error('sync boom'); });
const a = makeLink('https://example.com');
// 不应 throw — click handler 内部 try/catch 兜住
expect(() => a.click()).not.toThrow();
expect(consoleErr).toHaveBeenCalled();
expect(consoleErr.mock.calls[0][0]).toMatch(/onExternalLink threw synchronously/);
consoleErr.mockRestore();
});
it('onExternalLink 返回 Promise 且 reject → console.error 捕获', async () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => Promise.reject(new Error('async boom')));
const a = makeLink('https://example.com');
a.click();
// 让 microtask 队列跑一下
await new Promise((r) => setTimeout(r, 0));
expect(consoleErr).toHaveBeenCalled();
expect(consoleErr.mock.calls.some((c) => /promise rejected/.test(String(c[0])))).toBe(true);
consoleErr.mockRestore();
});
it('onExternalLink 返回正常 Promise → 不打 console.error', async () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => Promise.resolve());
const a = makeLink('https://example.com');
a.click();
await new Promise((r) => setTimeout(r, 0));
expect(consoleErr).not.toHaveBeenCalled();
consoleErr.mockRestore();
});
});
// audit fix (Round 12 P3)dispose 必须显式 disconnect _headingObserver
// 否则 detach 后的 heading 节点被 IntersectionObserver 持有 → 不被 GC。
// 单测构造一个 IntersectionObserver 拦截 _rebuildHeadingObserver → 验证
// dispose 后 observer.disconnect() 被调用。
describe('MarkdownViewer dispose 释放 IntersectionObserver (audit Round 12 P3)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
it('dispose() 显式 disconnect _headingObserver', () => {
const disconnectCalls = [];
// jsdom 没有 IntersectionObserver —— 给 bodyElement 一个 mock
// _rebuildHeadingObserver 用它viewer 走「io = new IntersectionObserver(cb)」
// 拿 window.IntersectionObserver
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(function () { disconnectCalls.push(true); }),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
// 触发 _rebuildHeadingObserver往 body 塞 h1/h2 后调该方法
const h1 = document.createElement('h1');
h1.textContent = 'title';
body.appendChild(h1);
const h2 = document.createElement('h2');
h2.textContent = 'sub';
body.appendChild(h2);
viewer._rebuildHeadingObserver();
// 现在 _headingObserver 是 fakeObserver —— 调 dispose 必须 disconnect
viewer.dispose();
expect(disconnectCalls.length).toBe(1);
expect(viewer._headingObserver).toBe(null);
} finally {
window.IntersectionObserver = RealIO;
}
});
it('重复 dispose 不抛错disconnect 已在第一次调过)', () => {
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
const h1 = document.createElement('h1');
body.appendChild(h1);
viewer._rebuildHeadingObserver();
viewer.dispose();
// 第二次 dispose 不抛错addAbort 是 abortController已 abort 但闭包不抛)
expect(() => viewer.dispose()).not.toThrow();
} finally {
window.IntersectionObserver = RealIO;
}
});
});