clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / test / preview-routing.test.js
preview-routing.test.js 9.1K
1import assert from 'node:assert/strict'2import test from 'node:test'3import {4 PreviewController,5 previewBackgroundForAppearance,6 previewThemeForAppearance,7 previewViewKey,8} from '../src/preview.js'9import { httpRendererUnitFor } from '../src/renderer-units.js'1011const request = {12 source: 'blockdiag { A -> B; }',13 options: {},14 meta: {},15 presentation: {},16}1718function controllerWith(renderRequest) {19 const controller = Object.create(PreviewController.prototype)20 controller.renderRequest = renderRequest21 controller.renderResponse = async response => {22 if (!response.ok) throw new Error(await response.text())23 return response24 }25 return controller26}2728test('surfaces a dedicated BlockDiag renderer failure', async () => {29 const endpoints = []30 const controller = controllerWith(async endpoint => {31 endpoints.push(endpoint)32 return new Response('unavailable', { status: 503 })33 })3435 await assert.rejects(36 controller.renderThroughUnit({ ...request, type: 'blockdiag' }, new AbortController().signal),37 /unavailable/,38 )39 assert.deepEqual(endpoints, ['https://blockdiag.render.diagram.zip/v1/svg'])40})4142test('surfaces a dedicated BlockDiag renderer network error', async () => {43 const endpoints = []44 const controller = controllerWith(async endpoint => {45 endpoints.push(endpoint)46 throw new TypeError('network unavailable')47 })4849 await assert.rejects(50 controller.renderThroughUnit({ ...request, type: 'seqdiag' }, new AbortController().signal),51 /network unavailable/,52 )53 assert.deepEqual(endpoints, ['https://seqdiag.render.diagram.zip/v1/svg'])54})5556test('surfaces a GraphViz-family renderer failure', async () => {57 const endpoints = []58 const controller = controllerWith(async endpoint => {59 endpoints.push(endpoint)60 return new Response('unavailable', { status: 503 })61 })6263 await assert.rejects(64 controller.renderThroughUnit({ ...request, type: 'erd' }, new AbortController().signal),65 /unavailable/,66 )67 assert.deepEqual(endpoints, ['https://erd.render.diagram.zip/v1/svg'])68})6970test('routes formerly client-rendered engines through HTTP units', () => {71 for (const type of ['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz']) {72 assert.equal(httpRendererUnitFor(type), `https://${type}.render.diagram.zip/v1/svg`)73 }74})7576test('keeps render details out of the status hint', () => {77 const attributes = new Map()78 const status = {79 dataset: {},80 setAttribute(name, value) { attributes.set(name, value) },81 }82 const controller = { status }8384 PreviewController.prototype.setStatus.call(controller, 'The renderer is still working.', 'loading')8586 assert.equal(status.textContent, 'Rendering')87 assert.equal(status.title, '')88 assert.equal(status.dataset.state, 'loading')89 assert.equal(attributes.get('aria-label'), 'Rendering')90})9192test('routes render errors to the error callback and resets the status hint', () => {93 const statuses = []94 const errors = []95 const controller = {96 setStatus(message, state) { statuses.push({ message, state }) },97 onError(message) { errors.push(message) },98 }99100 PreviewController.prototype.handleRenderError.call(controller, 'The renderer rejected this source.')101102 assert.deepEqual(statuses, [{ message: 'Ready', state: 'idle' }])103 assert.deepEqual(errors, ['The renderer rejected this source.'])104})105106test('matches the preview substrate to explicit and automatic SVG appearances', () => {107 assert.equal(previewThemeForAppearance('light-transparent'), 'light')108 assert.equal(previewThemeForAppearance('dark-framed'), 'dark')109 assert.equal(previewThemeForAppearance('auto-transparent'), 'auto')110 assert.equal(previewBackgroundForAppearance('light-transparent', '#fff'), 'var(--preview-light-bg)')111 assert.equal(previewBackgroundForAppearance('dark-transparent', '#fff'), 'var(--preview-dark-bg)')112 assert.equal(previewBackgroundForAppearance('auto-framed', '#fff'), 'var(--preview-auto-bg)')113 assert.equal(previewBackgroundForAppearance('raw', '#f4f4f4'), '#f4f4f4')114})115116test('rematerializes automatic appearances when the preview system theme changes', () => {117 const displays = []118 const controller = {119 autoTheme: 'light',120 latestCanonicalSvg: '<svg/>',121 latestPresentation: { appearance: 'auto-transparent' },122 latestAppearance: 'auto-transparent',123 displayCanonical(...args) { displays.push(args) },124 }125126 PreviewController.prototype.setAutoTheme.call(controller, 'dark')127128 assert.equal(controller.autoTheme, 'dark')129 assert.deepEqual(displays, [[controller.latestCanonicalSvg, controller.latestPresentation, controller.latestAppearance]])130})131132test('keeps one viewport identity across presentation-only changes', () => {133 const diagram = { type: 'd2', source: 'a -> b', options: {} }134 assert.equal(135 previewViewKey({ ...diagram, presentation: { appearance: 'light-transparent' } }),136 previewViewKey({ ...diagram, presentation: { appearance: 'dark-framed' } }),137 )138 assert.notEqual(139 previewViewKey(diagram),140 previewViewKey({ ...diagram, source: 'a -> c' }),141 )142})143144test('returns canonical SVG only for the exact current editable state', () => {145 const controller = {146 latestRenderKey: JSON.stringify(request),147 latestCanonicalSvg: '<svg data-dz-schema="1"></svg>',148 }149 assert.equal(PreviewController.prototype.canonicalSvgFor.call(controller, request), controller.latestCanonicalSvg)150 assert.equal(PreviewController.prototype.canonicalSvgFor.call(controller, { ...request, source: 'changed' }), null)151})152153test('preserves the current transform when a presentation-only image loads', () => {154 const calls = []155 const controller = {156 image: { src: 'blob:current', naturalWidth: 120, naturalHeight: 100 },157 activeImageUrl: 'blob:current',158 imageFallbackUrl: 'fallback',159 fitOnNextLoad: false,160 previousImageSize: { width: 100, height: 80 },161 scale: 2,162 x: 10,163 y: 20,164 setStatus() {},165 fit() { calls.push('fit') },166 updateTransform() { calls.push('preserve') },167 }168169 PreviewController.prototype.handleImageLoad.call(controller)170171 assert.deepEqual(calls, ['preserve'])172 assert.equal(controller.imageFallbackUrl, null)173 assert.equal(controller.x, -10)174 assert.equal(controller.y, 0)175 assert.equal(controller.previousImageSize, null)176})177178test('fits once when a different diagram identity loads', () => {179 const calls = []180 const controller = {181 image: { src: 'blob:current', naturalWidth: 120, naturalHeight: 100 },182 activeImageUrl: 'blob:current',183 imageFallbackUrl: null,184 fitOnNextLoad: true,185 previousImageSize: { width: 100, height: 80 },186 setStatus() {},187 fit() { calls.push('fit') },188 updateTransform() { calls.push('preserve') },189 }190191 PreviewController.prototype.handleImageLoad.call(controller)192193 assert.deepEqual(calls, ['fit'])194 assert.equal(controller.fitOnNextLoad, false)195})196197test('keeps the current image until its decoded replacement can be committed', async () => {198 let finishDecode199 const revoked = []200 const originalRevokeObjectUrl = URL.revokeObjectURL201 URL.revokeObjectURL = url => revoked.push(url)202 const image = { src: 'blob:current', hidden: false, naturalWidth: 120, naturalHeight: 80 }203 const minimapImage = { src: 'blob:current' }204 const backgrounds = []205 const controller = {206 imageSwapNumber: 2,207 objectUrl: 'blob:current',208 activeImageUrl: 'blob:current',209 imageFallbackUrl: null,210 image,211 minimapImage,212 fitOnNextLoad: false,213 stage: {214 dataset: { previewTheme: 'light' },215 style: { setProperty(name, value) { backgrounds.push([name, value]) } },216 },217 decodeImage() { return new Promise(resolve => { finishDecode = resolve }) },218 }219220 try {221 const swap = PreviewController.prototype.swapDecodedImage.call(controller, {222 objectUrl: 'blob:next',223 fallbackUrl: 'data:next',224 background: 'var(--preview-dark-bg)',225 theme: 'dark',226 }, 2)227228 await Promise.resolve()229 assert.equal(image.src, 'blob:current')230 assert.equal(minimapImage.src, 'blob:current')231 assert.deepEqual(revoked, [])232233 finishDecode()234 await swap235236 assert.equal(image.src, 'blob:next')237 assert.equal(minimapImage.src, 'blob:next')238 assert.equal(controller.stage.dataset.previewTheme, 'dark')239 assert.deepEqual(backgrounds, [['--render-background', 'var(--preview-dark-bg)']])240 assert.deepEqual(revoked, ['blob:current'])241 } finally {242 URL.revokeObjectURL = originalRevokeObjectUrl243 }244})245246test('discards a decoded image when a newer render has superseded it', async () => {247 const revoked = []248 const originalRevokeObjectUrl = URL.revokeObjectURL249 URL.revokeObjectURL = url => revoked.push(url)250 const controller = {251 imageSwapNumber: 4,252 objectUrl: 'blob:current',253 image: { src: 'blob:current' },254 minimapImage: { src: 'blob:current' },255 async decodeImage() {},256 }257258 try {259 await PreviewController.prototype.swapDecodedImage.call(controller, {260 objectUrl: 'blob:stale',261 fallbackUrl: 'data:stale',262 background: '#000000',263 theme: 'dark',264 }, 3)265266 assert.equal(controller.image.src, 'blob:current')267 assert.equal(controller.minimapImage.src, 'blob:current')268 assert.deepEqual(revoked, ['blob:stale'])269 } finally {270 URL.revokeObjectURL = originalRevokeObjectUrl271 }272})