KAN-73: document and validate MCP ticket description format

This commit is contained in:
2026-10-15 07:06:05 +00:00
parent ca1f243234
commit 5567eea06f
16 changed files with 643 additions and 28 deletions
@@ -3,6 +3,7 @@ import TicketDescriptionEditor from '../../resources/js/components/tickets/Ticke
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
import { createLocalization } from '../../resources/js/lib/localization';
import '../../resources/css/app.css';
import descriptionFormat from '../../resources/js/lib/ticket-description-format.json';
const original = 'First line\nSecond line\n\n**Existing bold**';
const state = reactive({
@@ -199,6 +200,33 @@ try {
state.value =
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
await tick();
state.value = descriptionFormat.example;
await tick();
assert(
document.querySelector('#description h2')?.textContent === 'Goal',
'MCP example opens formatted in WYSIWYG',
);
assert(
document.querySelector('#description strong')?.textContent ===
'context',
'MCP emphasis is formatted',
);
assert(
document.querySelectorAll('#description li').length === 2,
'MCP ordinary lists render',
);
assert(
!document.querySelector('textarea#description'),
'MCP example avoids source fallback',
);
state.value = '## Legacy\n\n- [ ] Criterion';
await tick();
assert(
document.querySelector('textarea#description')?.value === state.value,
'Legacy task checkboxes retain original source',
);
state.value = descriptionFormat.example;
await tick();
state.result =
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
} catch (error) {
@@ -0,0 +1,167 @@
[
{
"name": "plain text",
"value": "München 🚀 & details\nSecond line",
"supported": true
},
{
"name": "formatting",
"value": "# 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---",
"supported": true
},
{
"name": "setext heading",
"value": "Title\n===\n\nText",
"supported": true
},
{
"name": "safe links",
"value": "[http](http://example.test) [mail](mailto:test@example.test) [root](/projects/1) [fragment](#part)",
"supported": true
},
{
"name": "reference link",
"value": "[Guide][ref]\n\n[ref]: https://example.test",
"supported": true
},
{
"name": "autolinks",
"value": "<https://example.test> and https://example.test and test@example.test",
"supported": true
},
{
"name": "literal code",
"value": "```markdown\n- [x] Done\n<table></table>\n![image](bad)\n[a](javascript:bad)\n```\n\n`- [ ] task`\n\n <script>example</script>",
"supported": true
},
{
"name": "escaped text",
"value": "\\<tag> and \\![image](https://example.test)\n\n- \\[x] ordinary list",
"supported": true
},
{
"name": "entities",
"value": "&lt;img src=x onerror=alert(1)&gt;",
"supported": true
},
{
"name": "empty",
"value": "",
"supported": true
},
{
"name": "task",
"value": "## Plan\n\n- [ ] Criteria",
"supported": false
},
{
"name": "checked",
"value": "- [x] Done",
"supported": false
},
{
"name": "nested task",
"value": "> 1. [X] Done",
"supported": false
},
{
"name": "table",
"value": "| A | B |\n| - | - |\n| a | b |",
"supported": false
},
{
"name": "html",
"value": "<script>alert(1)</script>",
"supported": false
},
{
"name": "inline html",
"value": "Text <b>bold</b>",
"supported": false
},
{
"name": "html comment",
"value": "<!-- comment -->",
"supported": false
},
{
"name": "image",
"value": "![Image](https://example.test/image.png)",
"supported": false
},
{
"name": "reference image",
"value": "![alt][img]\n\n[img]: https://example.test/image.png",
"supported": false
},
{
"name": "javascript",
"value": "[unsafe](javascript:alert%281%29)",
"supported": false
},
{
"name": "data",
"value": "[unsafe](data:text/html,test)",
"supported": false
},
{
"name": "protocol relative",
"value": "[unsafe](//evil.test)",
"supported": false
},
{
"name": "relative",
"value": "[unsafe](other/page)",
"supported": false
},
{
"name": "unsafe reference",
"value": "[unsafe][ref]\n\n[ref]: javascript:alert(1)",
"supported": false
},
{
"name": "malformed https",
"value": "[unsafe](https://)",
"supported": false
},
{
"name": "backslash",
"value": "[unsafe](https://example.test/a\\\\b)",
"supported": false
},
{
"name": "invalid ipv6",
"value": "[unsafe](https://[invalid]/)",
"supported": false
},
{
"name": "encoded backslash",
"value": "[unsafe](https://example.test/%5cpath)",
"supported": false
},
{
"name": "encoded whitespace",
"value": "[unsafe](https://example.test/a%20b)",
"supported": false
},
{
"name": "invalid encoding",
"value": "[unsafe](https://example.test/%ff)",
"supported": false
},
{
"name": "too large port",
"value": "[unsafe](https://example.test:99999)",
"supported": false
},
{
"name": "ipv6",
"value": "[safe](https://[::1]/path)",
"supported": true
},
{
"name": "international domain",
"value": "[safe](https://münchen.example/🚀)",
"supported": true
}
]
+70
View File
@@ -1,4 +1,5 @@
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';
@@ -11,7 +12,24 @@ 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)),
},
@@ -56,6 +74,58 @@ if (serve) {
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);