import { expect, test, type Page } from '@playwright/test'; test.describe.configure({ timeout: 120_000 }); const openFixture = async (page: Page): Promise => { await page.goto('/test/browser/fixtures/lifecycle.html'); await expect(page.locator('body')).toHaveAttribute('data-ready', 'true', { timeout: 30_000 }); }; test('reconnects during an extension render without adopting the obsolete session', async ({ page }) => { await openFixture(page); const failures: string[] = []; page.on('pageerror', (error) => failures.push(error.message)); const result = await page.evaluate(async () => { let start!: () => void; let finish!: () => void; const started = new Promise((resolve) => (start = resolve)); const gate = new Promise((resolve) => (finish = resolve)); const active = new Set(); const engine = new window.markdownTestApi!.MarkdownEngine({ profile: 'core', extensions: [ { name: 'fixture', fences: [ { language: 'fixture', className: 'fixture', async render(container, source, context) { active.add(container); if (source === 'slow') { start(); await gate; } if (!context.signal?.aborted) container.textContent = source; }, dispose(container) { active.delete(container); container.replaceChildren(); }, }, ], }, ], }); const viewer = document.createElement('markdown-document'); viewer.engine = engine; viewer.markdown = '```fixture\nslow\n```'; let readyEvents = 0; viewer.addEventListener('markdown-ready', () => readyEvents++); document.body.append(viewer); await started; viewer.remove(); viewer.markdown = '```fixture\ncurrent\n```'; const ready = new Promise((resolve) => viewer.addEventListener('markdown-ready', () => resolve(), { once: true })); document.body.append(viewer); finish(); await ready; const content = viewer.shadowRoot?.querySelector('[part="content"]')?.textContent; const disposed = viewer.documentSession?.disposed; await Promise.all([viewer.dispose(), viewer.dispose()]); await engine.dispose(); return { content, disposed, readyEvents, resources: active.size }; }); expect(result.content).toContain('current'); expect(result.content).not.toContain('slow'); expect(result.disposed).toBe(false); expect(result.readyEvents).toBe(1); expect(result.resources).toBe(0); expect(failures).toEqual([]); }); test('explicit disposal cancels a load requested in the same turn', async ({ page }) => { await openFixture(page); const result = await page.evaluate(async () => { const viewer = document.createElement('markdown-document'); viewer.engine = new window.markdownTestApi!.MarkdownEngine({ profile: 'core' }); document.body.append(viewer); viewer.markdown = '# Cancelled'; await viewer.dispose(); await new Promise((resolve) => setTimeout(resolve, 0)); const result = { state: viewer.state, session: Boolean(viewer.documentSession), content: viewer.shadowRoot?.querySelector('[part="content"]')?.textContent }; await viewer.engine.dispose(); return result; }); expect(result).toEqual({ state: 'empty', session: false, content: '' }); }); test('aborts a stale document request before it can replace the current document', async ({ page }) => { await openFixture(page); await page.evaluate(() => { const api = window.markdownTestApi!; const aborted: string[] = []; const viewer = document.createElement('markdown-document'); viewer.engine = api.createEducationalMarkdownEngine({ features: [] }); viewer.context = { fetchResource: (url: URL, init?: RequestInit) => new Promise((resolve, reject) => { const signal = init?.signal; const timer = window.setTimeout( () => resolve(new Response(url.pathname.includes('slow') ? '# Stale document' : '# Current document')), url.pathname.includes('slow') ? 2_000 : 10, ); signal?.addEventListener( 'abort', () => { window.clearTimeout(timer); aborted.push(url.pathname); reject(new DOMException('Aborted', 'AbortError')); }, { once: true }, ); }), }; viewer.src = './slow.md'; document.body.append(viewer); window.markdownLifecycle = { viewer, aborted }; }); const viewer = page.locator('markdown-document'); await expect(viewer).toHaveAttribute('state', 'loading'); await page.evaluate(() => { window.markdownLifecycle!.viewer.src = './current.md'; }); await expect(viewer).toHaveAttribute('state', 'ready'); await expect(viewer.locator('h1')).toHaveText('Current document'); await expect(viewer.locator('text=Stale document')).toHaveCount(0); await expect.poll(() => page.evaluate(() => window.markdownLifecycle!.aborted.includes('/test/browser/fixtures/slow.md'))).toBe(true); }); test('disconnecting during a request prevents late rendering and releases the session', async ({ page }) => { await openFixture(page); const result = await page.evaluate(async () => { const api = window.markdownTestApi!; let aborted = false; const viewer = document.createElement('markdown-document'); viewer.engine = api.createEducationalMarkdownEngine({ features: [] }); viewer.context = { fetchResource: (_url: URL, init?: RequestInit) => new Promise((resolve, reject) => { const timer = window.setTimeout(() => resolve(new Response('# Too late')), 1_000); init?.signal?.addEventListener( 'abort', () => { aborted = true; window.clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); }, { once: true }, ); }), }; viewer.src = './pending.md'; document.body.append(viewer); await new Promise((resolve) => viewer.addEventListener('markdown-state-change', () => viewer.state === 'loading' && resolve(), { once: true })); viewer.remove(); await new Promise((resolve) => window.setTimeout(resolve, 50)); return { aborted, state: viewer.state, content: viewer.shadowRoot?.querySelector('[part="content"]')?.textContent ?? '' }; }); expect(result.aborted).toBe(true); expect(result.content).toBe(''); }); test('keeps an invalid extension local and renders the remaining blocks', async ({ page }) => { await openFixture(page); await page.evaluate(() => { const viewer = document.createElement('markdown-document'); viewer.engine = window.markdownTestApi!.createEducationalMarkdownEngine({ features: ['mermaid', 'graphviz'] }); viewer.markdown = `# Isolated failure \`\`\`mermaid this is not a mermaid diagram \`\`\` \`\`\`dot digraph { valid -> result } \`\`\``; document.body.append(viewer); }); const viewer = page.locator('markdown-document'); await expect(viewer).toHaveAttribute('state', 'ready'); await expect(viewer.locator('[data-markdown-extension="mermaid"] [role="alert"]')).toContainText('Mermaid (mermaid):'); await expect(viewer.locator('[data-markdown-extension="mermaid"] pre').last()).toContainText('not a mermaid diagram'); await expect(viewer.locator('[data-markdown-extension="graphviz"] svg')).toBeVisible(); }); test('dispose disconnects extension ResizeObservers and removes interactive output', async ({ page }) => { await openFixture(page); await page.evaluate(() => { const NativeResizeObserver = window.ResizeObserver; const active = new Set(); class TrackedResizeObserver implements ResizeObserver { private readonly native: ResizeObserver; public constructor(callback: ResizeObserverCallback) { this.native = new NativeResizeObserver(callback); } public observe(target: Element, options?: ResizeObserverOptions): void { active.add(this); this.native.observe(target, options); } public unobserve(target: Element): void { this.native.unobserve(target); } public disconnect(): void { active.delete(this); this.native.disconnect(); } } window.ResizeObserver = TrackedResizeObserver; window.markdownResizeObservers = active; const viewer = document.createElement('markdown-document'); viewer.engine = window.markdownTestApi!.createEducationalMarkdownEngine({ features: ['maps', 'vega-lite'] }); viewer.markdown = `# Disposable resources \`\`\`geojson {"type":"Point","coordinates":[-46.63,-23.55]} \`\`\` \`\`\`vega-lite {"data":{"values":[{"x":"A","y":1}]},"mark":"bar","encoding":{"x":{"field":"x"},"y":{"field":"y","type":"quantitative"}}} \`\`\``; document.body.append(viewer); window.markdownLifecycle = { viewer, aborted: [] }; }); const viewer = page.locator('markdown-document'); await expect(viewer).toHaveAttribute('state', 'ready'); await expect(viewer.locator('.leaflet-container')).toBeVisible(); await expect(viewer.locator('[data-markdown-extension="vega-lite"] canvas, [data-markdown-extension="vega-lite"] svg').first()).toBeVisible(); await expect.poll(() => page.evaluate(() => window.markdownResizeObservers!.size)).toBeGreaterThanOrEqual(2); await page.evaluate(() => window.markdownLifecycle!.viewer.dispose()); await expect(viewer).toHaveAttribute('state', 'empty'); await expect(viewer.locator('.leaflet-container, canvas, [data-markdown-extension]')).toHaveCount(0); await expect.poll(() => page.evaluate(() => window.markdownResizeObservers!.size)).toBe(0); }); test('dispose cancels the Three.js animation loop and removes its WebGL canvas', async ({ page, browserName }) => { const warnings: string[] = []; page.on('console', (message) => { if (message.text().includes('THREE.Clock')) warnings.push(message.text()); }); test.skip(browserName === 'firefox', 'Playwright Firefox does not provide the WebGL implementation required by Three.js on this test host.'); await openFixture(page); const hasWebGL = await page.evaluate(() => Boolean(document.createElement('canvas').getContext('webgl2') ?? document.createElement('canvas').getContext('webgl'))); test.skip(!hasWebGL, 'This browser runtime does not provide a WebGL context.'); await page.evaluate(() => { const nativeRequest = window.requestAnimationFrame.bind(window); const nativeCancel = window.cancelAnimationFrame.bind(window); const active = new Set(); window.requestAnimationFrame = (callback: FrameRequestCallback): number => { const id = nativeRequest((time) => { active.delete(id); callback(time); }); active.add(id); return id; }; window.cancelAnimationFrame = (id: number): void => { active.delete(id); nativeCancel(id); }; window.markdownAnimationFrames = active; const visibilityListeners = new Set(); const nativeAdd = document.addEventListener.bind(document); const nativeRemove = document.removeEventListener.bind(document); document.addEventListener = ((type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) => { if (type === 'visibilitychange' && listener) visibilityListeners.add(listener); nativeAdd(type, listener!, options); }) as typeof document.addEventListener; document.removeEventListener = ((type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => { if (type === 'visibilitychange' && listener) visibilityListeners.delete(listener); nativeRemove(type, listener!, options); }) as typeof document.removeEventListener; window.markdownTimerListeners = visibilityListeners; const viewer = document.createElement('markdown-document'); viewer.engine = window.markdownTestApi!.createEducationalMarkdownEngine({ features: ['model-3d'] }); viewer.markdown = `# Disposable animated model \`\`\`gltf { "asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{}], "buffers": [{"byteLength": 32, "uri": "data:application/octet-stream;base64,AAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAA="}], "bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 8}, {"buffer": 0, "byteOffset": 8, "byteLength": 24}], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR", "min": [0], "max": [1]}, {"bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC3"} ], "animations": [{"samplers": [{"input": 0, "output": 1}], "channels": [{"sampler": 0, "target": {"node": 0, "path": "translation"}}]}] } \`\`\``; document.body.append(viewer); window.markdownLifecycle = { viewer, aborted: [] }; }); const viewer = page.locator('markdown-document'); await expect(viewer).toHaveAttribute('state', 'ready'); await expect(viewer.locator('[data-markdown-extension="model-3d"] canvas')).toBeVisible(); await expect.poll(() => page.evaluate(() => window.markdownAnimationFrames!.size)).toBeGreaterThan(0); expect(warnings).toEqual([]); expect(await page.evaluate(() => window.markdownTimerListeners!.size)).toBe(1); await page.evaluate(() => window.markdownLifecycle!.viewer.dispose()); await expect(viewer).toHaveAttribute('state', 'empty'); await expect(viewer.locator('canvas')).toHaveCount(0); await expect.poll(() => page.evaluate(() => window.markdownAnimationFrames!.size)).toBe(0); expect(await page.evaluate(() => window.markdownTimerListeners!.size)).toBe(0); }); declare global { interface Window { markdownTestApi?: typeof import('mathjslab-markdown/browser'); markdownLifecycle?: { viewer: import('mathjslab-markdown/component').MarkdownDocumentElement; aborted: string[] }; markdownResizeObservers?: Set; markdownAnimationFrames?: Set; markdownTimerListeners?: Set; } }