255 lines
9.0 KiB
JavaScript
255 lines
9.0 KiB
JavaScript
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,
|
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
|
|
),
|
|
);
|
|
});
|
|
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, /<h2>Goal<\/h2>/);
|
|
assert.match(html, /<strong>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, /<h2>Goal<\/h2>/);
|
|
assert.match(exampleHtml, /<strong>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<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
|
|
assert.equal(supportsVisualDescription(markdown), true);
|
|
const rendered = await render(markdown);
|
|
for (const snippet of [
|
|
'<h1>Title</h1>',
|
|
'<strong>Bold</strong>',
|
|
'<em>italic</em>',
|
|
'<s>strike</s>',
|
|
'<ul>',
|
|
'Nested',
|
|
'<ol start="3">',
|
|
'<blockquote>',
|
|
'<code>inline</code>',
|
|
'<tag>',
|
|
'rel="noopener noreferrer"',
|
|
'<hr>',
|
|
]) {
|
|
assert.ok(
|
|
rendered.includes(snippet),
|
|
`Missing formatted output: ${snippet}`,
|
|
);
|
|
}
|
|
assert.match(await render('First\nSecond'), /First<br>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 [
|
|
'<script>alert(1)</script>',
|
|
'<img src=x onerror=alert(1)>',
|
|
'| 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, /<script|<img|href="javascript:/);
|
|
assert.ok(
|
|
html.includes(
|
|
value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>'),
|
|
),
|
|
'Original source is retained',
|
|
);
|
|
}
|
|
const maliciousNode = {
|
|
type: 'paragraph',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<script>bad</script>',
|
|
marks: [
|
|
{ type: 'link', attrs: { href: 'javascript:bad()' } },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const safe = await renderToString(
|
|
createSSRApp({
|
|
render: () =>
|
|
h('div', {}, [renderDescriptionNode(maliciousNode)]),
|
|
}),
|
|
);
|
|
assert.doesNotMatch(safe, /<script|<a/);
|
|
assert.match(safe, /<script>/);
|
|
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
|
|
let updates = 0;
|
|
const initial = await renderToString(
|
|
createSSRApp({
|
|
render: () =>
|
|
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();
|
|
}
|
|
}
|