558 lines
22 KiB
TypeScript
558 lines
22 KiB
TypeScript
import { describe, expect, mock, test } from 'bun:test';
|
|
|
|
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
|
|
|
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
|
|
|
type FakeElement = {
|
|
childNodes: FakeElement[];
|
|
children: FakeElement[];
|
|
parentNode: FakeElement | null;
|
|
attributes: Map<string, string>;
|
|
style: { display: string; setProperty: () => void };
|
|
innerHTML: string;
|
|
setAttribute: (name: string, value: string) => void;
|
|
getAttribute: (name: string) => string | null;
|
|
appendChild: (child: FakeElement) => FakeElement;
|
|
replaceWith: (replacement: FakeElement) => void;
|
|
remove: () => void;
|
|
querySelector: (selector: string) => FakeElement | null;
|
|
querySelectorAll: <T>(selector: string) => T[];
|
|
addEventListener: () => void;
|
|
removeEventListener: () => void;
|
|
contains: (child: FakeElement) => boolean;
|
|
isEqualNode: () => boolean;
|
|
};
|
|
|
|
type FakeDocument = { createElement: () => FakeElement };
|
|
type FakeJsxProps = {
|
|
ref?: { current: FakeElement | null };
|
|
children?: FakeElement | FakeElement[];
|
|
className?: string;
|
|
'data-markdown-content'?: boolean;
|
|
};
|
|
|
|
let syncRenderCalls = 0;
|
|
let morphCalls = 0;
|
|
let decorateCalls = 0;
|
|
let mermaidRegistryCreates = 0;
|
|
let mermaidRegistryCleanups = 0;
|
|
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
|
|
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
|
|
let currentContextVersion = 0;
|
|
const layoutEffects: Array<() => void> = [];
|
|
const passiveEffects: Array<() => void | (() => void)> = [];
|
|
let hookCursor = 0;
|
|
let hookStates: Array<{ current: null } | undefined> = [];
|
|
let activeFakeDocument: FakeDocument | null = null;
|
|
|
|
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
|
let html = '';
|
|
const element: FakeElement = {
|
|
childNodes: [],
|
|
children: [],
|
|
parentNode: null,
|
|
attributes: new Map(),
|
|
style: { display: '', setProperty: () => undefined },
|
|
get innerHTML() {
|
|
return html;
|
|
},
|
|
set innerHTML(value: string) {
|
|
html = value;
|
|
},
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, value);
|
|
},
|
|
getAttribute(name) {
|
|
return this.attributes.get(name) ?? null;
|
|
},
|
|
appendChild(child) {
|
|
child.parentNode = this;
|
|
this.childNodes.push(child);
|
|
this.children.push(child);
|
|
return child;
|
|
},
|
|
replaceWith(replacement) {
|
|
if (!this.parentNode) return;
|
|
const parent = this.parentNode;
|
|
const index = parent.children.indexOf(this);
|
|
if (index < 0) return;
|
|
replacement.parentNode = parent;
|
|
parent.children[index] = replacement;
|
|
parent.childNodes[index] = replacement;
|
|
this.parentNode = null;
|
|
},
|
|
remove() {
|
|
if (!this.parentNode) return;
|
|
const parent = this.parentNode;
|
|
parent.children = parent.children.filter((child) => child !== this);
|
|
parent.childNodes = parent.childNodes.filter((child) => child !== this);
|
|
this.parentNode = null;
|
|
},
|
|
querySelector(selector) {
|
|
if (selector === '[data-markdown-content]') {
|
|
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
|
|
}
|
|
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
|
|
return this;
|
|
}
|
|
for (const child of this.children) {
|
|
const match = child.querySelector(selector);
|
|
if (match) return match;
|
|
}
|
|
return null;
|
|
},
|
|
querySelectorAll: () => [],
|
|
addEventListener: () => undefined,
|
|
removeEventListener: () => undefined,
|
|
contains(child) {
|
|
return child === this || this.children.some((candidate) => candidate.contains(child));
|
|
},
|
|
isEqualNode: () => false,
|
|
};
|
|
return element;
|
|
};
|
|
|
|
const installRendererDom = () => {
|
|
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
|
|
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
|
|
activeFakeDocument = documentStub;
|
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: {
|
|
matchMedia: () => ({ matches: false }),
|
|
setTimeout,
|
|
clearTimeout,
|
|
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
|
},
|
|
});
|
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
|
configurable: true,
|
|
value: class {
|
|
observe() {}
|
|
disconnect() {}
|
|
},
|
|
});
|
|
return () => {
|
|
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
|
|
else Reflect.deleteProperty(globalThis, 'document');
|
|
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
|
else Reflect.deleteProperty(globalThis, 'window');
|
|
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
|
|
else Reflect.deleteProperty(globalThis, 'MutationObserver');
|
|
activeFakeDocument = null;
|
|
};
|
|
};
|
|
|
|
const rendererThemes = [{
|
|
metadata: { id: 'renderer-test' },
|
|
colors: {
|
|
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
|
|
interactive: { border: '#ccc' },
|
|
primary: { base: '#00f' },
|
|
},
|
|
}, {
|
|
metadata: { id: 'renderer-test-next' },
|
|
colors: {
|
|
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
|
|
interactive: { border: '#bbb' },
|
|
primary: { base: '#f00' },
|
|
},
|
|
}];
|
|
let rendererThemeIndex = 0;
|
|
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
|
|
const rendererUiState = {
|
|
codeBlockLineWrap: false,
|
|
mermaidRenderingMode: 'svg',
|
|
setCodeBlockLineWrap: () => undefined,
|
|
openContextPreview: () => undefined,
|
|
};
|
|
|
|
const fakeReact = {
|
|
useCallback: <T>(callback: T): T => {
|
|
hookCursor += 1;
|
|
return callback;
|
|
},
|
|
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
|
|
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
|
|
useMemo: <T>(factory: () => T): T => {
|
|
hookCursor += 1;
|
|
return factory();
|
|
},
|
|
useRef: <T>(current: T) => {
|
|
const index = hookCursor;
|
|
hookCursor += 1;
|
|
if (!hookStates[index]) hookStates[index] = { current: null };
|
|
// SAFETY: this test hook preserves one mutable ref slot per hook index.
|
|
return hookStates[index] as { current: T };
|
|
},
|
|
memo: <T>(component: T): T => component,
|
|
};
|
|
|
|
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
|
|
const ref = props?.ref;
|
|
// SAFETY: the renderer test installs the typed fake document before JSX is
|
|
// evaluated; this branch only supplies its fake element factory.
|
|
const fakeDocument = activeFakeDocument;
|
|
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
|
|
const element = ref?.current ?? makeFakeElement(fakeDocument);
|
|
if (!ref?.current) {
|
|
element.childNodes.length = 0;
|
|
element.children.length = 0;
|
|
}
|
|
if (props) {
|
|
if (ref) ref.current = element;
|
|
if (props.className) element.setAttribute('class', props.className);
|
|
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
|
|
}
|
|
const jsxChildren = props?.children;
|
|
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
|
|
for (const child of allChildren) {
|
|
if (child) element.appendChild(child);
|
|
}
|
|
return element;
|
|
};
|
|
|
|
mock.module('react', () => ({ default: fakeReact }));
|
|
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
|
|
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
|
|
mock.module('beautiful-mermaid', () => ({
|
|
renderMermaidASCII: () => '',
|
|
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
|
|
}));
|
|
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
|
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
|
|
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
|
mock.module('@/lib/url', () => ({
|
|
getUrlScheme: () => null,
|
|
isAppLinkUrl: () => false,
|
|
isExternalHttpUrl: () => false,
|
|
openConfirmedAppLinkUrl: async () => false,
|
|
openExternalUrl: async () => undefined,
|
|
}));
|
|
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
|
|
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
|
|
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
|
|
type RendererUiSelectorResult = boolean | string | (() => void);
|
|
const fakeUseUIStore = Object.assign(
|
|
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
|
|
{ getState: () => rendererUiState },
|
|
);
|
|
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
|
|
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
|
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
|
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
|
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
|
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
|
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
|
|
mock.module('./markdown/markdownCore', () => ({
|
|
getCachedMarkdownBlocks: () => cachedRendererBlocks,
|
|
renderMarkdownBlocks: async () => renderedRendererBlocks,
|
|
renderMarkdownSync: () => {
|
|
syncRenderCalls += 1;
|
|
return '<p>cold</p>';
|
|
},
|
|
}));
|
|
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
|
|
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
|
|
type TestDecorateContext = {
|
|
labels: { copy: string };
|
|
codeBlockLineWrap: boolean;
|
|
renderMermaid: (source: string) => { svg?: string };
|
|
};
|
|
mock.module('./markdown/decorate', () => ({
|
|
attachMarkdownInteractions: () => () => undefined,
|
|
applyMarkdownCodeBlockWrapState: () => undefined,
|
|
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
|
|
decorateCalls += 1;
|
|
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
|
|
root.setAttribute('data-test-decoration-marker', 'true');
|
|
root.setAttribute(
|
|
'data-test-decoration',
|
|
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
|
|
);
|
|
},
|
|
getMarkdownCodeText: () => '',
|
|
}));
|
|
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
|
|
mock.module('./markdown/mermaidViewer', () => ({
|
|
createMermaidViewerRegistry: () => {
|
|
mermaidRegistryCreates += 1;
|
|
return {
|
|
refresh: () => undefined,
|
|
cleanup: () => { mermaidRegistryCleanups += 1; },
|
|
};
|
|
},
|
|
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
|
|
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
|
|
}));
|
|
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
|
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
|
|
|
|
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
|
|
|
|
const resetRendererTestState = () => {
|
|
cachedRendererBlocks = null;
|
|
renderedRendererBlocks = [];
|
|
syncRenderCalls = 0;
|
|
morphCalls = 0;
|
|
decorateCalls = 0;
|
|
mermaidRegistryCreates = 0;
|
|
mermaidRegistryCleanups = 0;
|
|
hookCursor = 0;
|
|
hookStates = [];
|
|
layoutEffects.length = 0;
|
|
passiveEffects.length = 0;
|
|
currentContextVersion = 0;
|
|
rendererThemeIndex = 0;
|
|
rendererUiState.codeBlockLineWrap = false;
|
|
};
|
|
|
|
const beginRendererRender = () => {
|
|
hookCursor = 0;
|
|
return renderMarkdownForTest();
|
|
};
|
|
|
|
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
|
|
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
|
|
throw new Error('Renderer test did not return its fake JSX root');
|
|
}
|
|
// SAFETY: the structural check confirms this ReactNode is the object
|
|
// returned by the mocked JSX runtime.
|
|
const candidate = value as object;
|
|
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
|
|
return candidate as FakeElement;
|
|
};
|
|
|
|
const runRendererLayoutEffects = () => {
|
|
const pending = layoutEffects.splice(0);
|
|
for (const effect of pending) effect();
|
|
};
|
|
|
|
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
|
|
|
|
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
|
|
if (root.getAttribute('data-md-id') === id) return root;
|
|
for (const child of root.children) {
|
|
const match = findBlock(child, id);
|
|
if (match) return match;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderMarkdownForTest = () => MarkdownRenderer({
|
|
content: 'cached markdown',
|
|
messageId: 'message-1',
|
|
isAnimated: false,
|
|
isStreaming: false,
|
|
});
|
|
|
|
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
|
|
const restoreDom = installRendererDom();
|
|
const previousThemeIndex = rendererThemeIndex;
|
|
try {
|
|
await run();
|
|
} finally {
|
|
rendererThemeIndex = previousThemeIndex;
|
|
restoreDom();
|
|
}
|
|
};
|
|
|
|
describe('parseFileReference', () => {
|
|
test('returns null for empty or whitespace input', () => {
|
|
expect(parse('')).toBeNull();
|
|
expect(parse(' ')).toBeNull();
|
|
});
|
|
|
|
test('parses bare path', () => {
|
|
expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' });
|
|
});
|
|
|
|
test('parses path with single line', () => {
|
|
expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 });
|
|
});
|
|
|
|
test('parses path with line and column', () => {
|
|
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
|
});
|
|
|
|
test('parses path with line range', () => {
|
|
expect(parse('src/foo.ts:42-58')).toEqual({
|
|
path: 'src/foo.ts',
|
|
line: 42,
|
|
endLine: 58,
|
|
});
|
|
});
|
|
|
|
test('parses path with single-line range (start equals end)', () => {
|
|
expect(parse('src/foo.ts:10-10')).toEqual({
|
|
path: 'src/foo.ts',
|
|
line: 10,
|
|
endLine: 10,
|
|
});
|
|
});
|
|
|
|
test('rejects range with end before start', () => {
|
|
expect(parse('src/foo.ts:20-10')).toBeNull();
|
|
});
|
|
|
|
test('falls back to path-only when range endpoint is non-numeric', () => {
|
|
// `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the
|
|
// line info is discarded and only the path is returned (the trailing
|
|
// `:`-suffix is stripped).
|
|
expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' });
|
|
expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' });
|
|
});
|
|
|
|
test('strips backtick and quote wrapping from range forms', () => {
|
|
expect(parse('`src/foo.ts:10-20`')).toEqual({
|
|
path: 'src/foo.ts',
|
|
line: 10,
|
|
endLine: 20,
|
|
});
|
|
expect(parse('"src/foo.ts:1-3"')).toEqual({
|
|
path: 'src/foo.ts',
|
|
line: 1,
|
|
endLine: 3,
|
|
});
|
|
});
|
|
|
|
test('parses absolute Windows path with line range', () => {
|
|
expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({
|
|
path: 'C:/repo/src/foo.ts',
|
|
line: 5,
|
|
endLine: 9,
|
|
});
|
|
});
|
|
|
|
test('preserves line:col form (does not interpret as range)', () => {
|
|
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
|
});
|
|
|
|
test('preserves hash form', () => {
|
|
expect(parse('src/foo.ts#L42C8')).toEqual({
|
|
path: 'src/foo.ts',
|
|
line: 42,
|
|
column: 8,
|
|
});
|
|
expect(parse('src/foo.ts#L42')).toEqual({
|
|
path: 'src/foo.ts',
|
|
line: 42,
|
|
});
|
|
});
|
|
|
|
test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => {
|
|
const result = parse('src/foo.ts:42-58');
|
|
expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
|
|
});
|
|
});
|
|
|
|
describe('localPathFromFileUrl', () => {
|
|
test('converts local file URLs to absolute paths', () => {
|
|
expect(localPathFromFileUrl('file:///private/tmp/report%20viewer.html')).toBe('/private/tmp/report viewer.html');
|
|
expect(localPathFromFileUrl('file://localhost/private/tmp/REPORT.md')).toBe('/private/tmp/REPORT.md');
|
|
expect(localPathFromFileUrl('file:///C:/Users/test/report.html')).toBe('C:/Users/test/report.html');
|
|
});
|
|
|
|
test('rejects non-file URLs and remote file hosts', () => {
|
|
expect(localPathFromFileUrl('https://example.com/report.html')).toBeNull();
|
|
expect(localPathFromFileUrl('file://remote-host/share/report.html')).toBeNull();
|
|
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('MarkdownRenderer warm settled path', () => {
|
|
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
|
|
await withRendererDom(async () => {
|
|
resetRendererTestState();
|
|
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
|
|
renderedRendererBlocks = cachedRendererBlocks;
|
|
syncRenderCalls = 0;
|
|
morphCalls = 0;
|
|
decorateCalls = 0;
|
|
|
|
// SAFETY: the test JSX adapter returns the fake element assigned to the
|
|
// renderer container ref and exposes the DOM members used below.
|
|
const root = rendererRoot(beginRendererRender());
|
|
runRendererLayoutEffects();
|
|
expect(syncRenderCalls).toBe(0);
|
|
const block = findBlock(root, 'full:cached');
|
|
expect(block).not.toBeNull();
|
|
expect(block?.innerHTML).toBe('<p>cached</p>');
|
|
expect(block?.getAttribute('data-md-block')).toBe('');
|
|
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
|
|
expect(block?.style.display).toBe('contents');
|
|
expect(decorateCalls).toBe(1);
|
|
|
|
runRendererPassiveEffects();
|
|
await Promise.resolve();
|
|
expect(morphCalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
|
|
return withRendererDom(() => {
|
|
resetRendererTestState();
|
|
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
|
|
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
|
|
renderedRendererBlocks = cachedRendererBlocks;
|
|
mermaidRegistryCreates = 0;
|
|
mermaidRegistryCleanups = 0;
|
|
morphCalls = 0;
|
|
|
|
const root = rendererRoot(beginRendererRender());
|
|
runRendererLayoutEffects();
|
|
expect(mermaidRegistryCreates).toBe(1);
|
|
const cleanups = runRendererPassiveEffects();
|
|
for (const cleanup of cleanups) cleanup?.();
|
|
expect(mermaidRegistryCleanups).toBe(1);
|
|
|
|
beginRendererRender();
|
|
runRendererLayoutEffects();
|
|
expect(mermaidRegistryCreates).toBe(2);
|
|
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
|
|
expect(morphCalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
|
|
await withRendererDom(async () => {
|
|
resetRendererTestState();
|
|
cachedRendererBlocks = [{
|
|
id: 'full:context',
|
|
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
|
|
}];
|
|
renderedRendererBlocks = cachedRendererBlocks;
|
|
|
|
const root = rendererRoot(beginRendererRender());
|
|
runRendererLayoutEffects();
|
|
const block = findBlock(root, 'full:context');
|
|
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
|
|
expect(firstDecorationId).not.toBeNull();
|
|
const firstDecorateCalls = decorateCalls;
|
|
|
|
rendererThemeIndex = 1;
|
|
currentContextVersion = 1;
|
|
rendererUiState.codeBlockLineWrap = true;
|
|
beginRendererRender();
|
|
runRendererLayoutEffects();
|
|
runRendererPassiveEffects();
|
|
await Promise.resolve();
|
|
|
|
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
|
|
expect(syncRenderCalls).toBe(0);
|
|
expect(morphCalls).toBe(0);
|
|
const updatedBlock = findBlock(root, 'full:context');
|
|
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
|
|
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
|
|
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
|
|
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
|
|
expect(mermaidRegistryCreates).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
});
|