import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import vue from '@vitejs/plugin-vue'; import tailwind from '@tailwindcss/vite'; import { createServer } from 'vite'; import { createSSRApp, h } from 'vue'; import { renderToString } from 'vue/server-renderer'; const serve = process.argv.includes('--serve'); const server = await createServer({ configFile: false, plugins: [vue(), ...(serve ? [tailwind()] : [])], optimizeDeps: { include: [ '@tiptap/core', '@tiptap/pm/state', '@tiptap/vue-3', '@tiptap/markdown', '@tiptap/starter-kit', ], }, resolve: { dedupe: [ 'vue', '@tiptap/core', '@tiptap/pm', 'prosemirror-state', 'prosemirror-model', 'prosemirror-view', ], alias: { '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), }, }, server: serve ? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false } : { middlewareMode: true, hmr: false, watch: null }, appType: 'custom', }); if (serve) { server.middlewares.use(async (request, response) => { response.setHeader('Content-Type', 'text/html'); response.end( await server.transformIndexHtml( request.url, '
', ), ); }); await server.listen(); console.log('Description fixture ready on port 4186'); } else { try { const { safeDescriptionLink, supportsVisualDescription, parseDescription, renderDescriptionNode, } = await server.ssrLoadModule( '/resources/js/lib/ticket-description.ts', ); const { default: Description } = await server.ssrLoadModule( '/resources/js/components/tickets/TicketDescription.vue', ); const { default: Editor } = await server.ssrLoadModule( '/resources/js/components/tickets/TicketDescriptionEditor.vue', ); const { createLocalization } = await server.ssrLoadModule( '/resources/js/lib/localization.ts', ); const render = (description) => renderToString( createSSRApp({ render: () => h(Description, { description }) }), ); const cases = JSON.parse( readFileSync( new URL('./ticket-description-cases.json', import.meta.url), 'utf8', ), ); const contract = JSON.parse( readFileSync( new URL( '../../resources/js/lib/ticket-description-format.json', import.meta.url, ), 'utf8', ), ); cases.push({ name: 'MCP contract example', value: contract.example, supported: true, }); for (const item of cases) { assert.equal( supportsVisualDescription(item.value), item.supported, item.name, ); if (item.supported) { assert.ok(parseDescription(item.value)); await render(item.value); } } if (process.argv.includes('--stored-description')) { const saved = JSON.parse(readFileSync(0, 'utf8')); assert.equal(supportsVisualDescription(saved.description), true); const html = await render(saved.description); assert.match(html, /', 'inline', '<tag>', 'rel="noopener noreferrer"', '
', ]) { assert.ok( rendered.includes(snippet), `Missing formatted output: ${snippet}`, ); } assert.match(await render('First\nSecond'), /First
Second/); assert.match( await render('München 🚀 & details'), /München 🚀 & details/, ); assert.match( await render('<img src=x onerror=alert(1)>'), /<img/, ); for (const value of [ 'https://example.test/a?b=1&c=2', 'http://example.test', 'mailto:test@example.test', '/projects/1', '#section', ]) { assert.equal(safeDescriptionLink(value), value); } for (const value of [ 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'data:text/html,test', 'vbscript:test', '//evil.test', '/\\evil.test', '\u0000https://example.test', 'https://', 'https://exa mple.test', 'java\nscript:alert(1)', null, ]) { assert.equal(safeDescriptionLink(value), null); } for (const value of [ '', '', '| A | B |\n| - | - |\n| a | b |', '', '- [x] Done', '[unsafe](javascript:alert%281%29)', ]) { assert.equal(supportsVisualDescription(value), false, value); const html = await render(value); assert.doesNotMatch(html, /', marks: [ { type: 'link', attrs: { href: 'javascript:bad()' } }, ], }, ], }; const safe = await renderToString( createSSRApp({ render: () => h('div', {}, [renderDescriptionNode(maliciousNode)]), }), ); assert.doesNotMatch(safe, /