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, /

Goal<\/h2>/); assert.match(html, /context<\/strong>/); assert.equal( parseDescription(saved.description).content[0].type, 'heading', ); for (const criterion of saved.criteria) { assert.ok( !html.includes(criterion.text), 'Criteria stay in the separate checklist', ); } } const exampleHtml = await render(contract.example); assert.match(exampleHtml, /

Goal<\/h2>/); assert.match(exampleHtml, /context<\/strong>/); assert.doesNotMatch(exampleHtml, /acceptance_criteria|task checkboxes/); const markdown = '# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n\n```\n\n[Link](https://example.test/path)\n\n---'; assert.equal(supportsVisualDescription(markdown), true); const rendered = await render(markdown); for (const snippet of [ '

Title

', 'Bold', 'italic', 'strike', '
    ', 'Nested', '
      ', '
      ', '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 |', '![Image](https://example.test/image.png)', '- [x] Done', '[unsafe](javascript:alert%281%29)', ]) { assert.equal(supportsVisualDescription(value), false, value); const html = await render(value); assert.doesNotMatch(html, /', '>'), ), 'Original source is retained', ); } const maliciousNode = { type: 'paragraph', content: [ { type: 'text', text: '', marks: [ { type: 'link', attrs: { href: 'javascript:bad()' } }, ], }, ], }; const safe = await renderToString( createSSRApp({ render: () => h('div', {}, [renderDescriptionNode(maliciousNode)]), }), ); assert.doesNotMatch(safe, / h(Editor, { id: 'description', label: 'Description', modelValue: markdown, error: 'Invalid description.', disabled: true, 'onUpdate:modelValue': () => updates++, }), }).use(createLocalization()), ); assert.match(initial, /id="description"/); assert.match(initial, /aria-invalid="true"/); assert.match(initial, /id="description-error"/); assert.match(initial, /disabled/); assert.equal( updates, 0, 'Mounting must not rewrite existing descriptions', ); console.log('Ticket description checks passed.'); } finally { await server.close(); } }