Files
KanbanGit/tests/Fixtures/ticket-detail.mjs
T
Twyco c8f494fe7c
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m2s
CI/CD / Publish immutable image (pull_request) Skipped
feat: simplify ticket links to action and ticket (KAN-12)
2026-10-01 22:32:02 +02:00

707 lines
26 KiB
JavaScript

import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h, reactive, ref } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/ticket-detail-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
};
const project = {
id: 7,
key: 'ENG',
name: 'Engineering',
show_author: true,
show_story_points: true,
};
const repository = {
id: 9,
provider: 'gitea',
provider_label: 'Gitea',
base_url: 'https://git.example.test',
repository_full_name: 'Engineering/platform',
repository_url: 'https://git.example.test/Engineering/platform',
has_personal_access: true,
};
const parent = {
id: 40,
ticket_key: 'ENG-40',
title: 'Platform delivery',
project_id: 7,
};
const linked = {
id: 41,
ticket_key: 'ENG-41',
title: 'Related infrastructure',
project_id: 7,
};
const ticket = {
id: 42,
project_id: 7,
sequence_number: 42,
ticket_key: 'ENG-42',
title: 'Implement a resilient caching layer for the project API',
description:
'Keep every existing ticket action available.\nLongUnbrokenDescriptionThatMustWrapWithoutCausingHorizontalScrollingOnTheSmallestSupportedMobileViewport.',
acceptance_criteria: [
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
{
id: 11,
text: 'Invalidate when records change',
checked: false,
indent: 1,
},
],
assignee_user_id: 1,
assignee: user,
author_user_id: 2,
author: { ...user, id: 2, name: 'Sam Chen' },
parent_ticket_id: 40,
parent_ticket: parent,
is_blocked: true,
blocked_comment: 'Waiting for infrastructure approval.',
story_points: 0,
ticket_type_id: 1,
ticket_type: { id: 1, name: 'Custom development task', is_parent: false },
ticket_priority_id: 2,
ticket_priority: { id: 2, name: 'Urgent project priority' },
ticket_status_id: 3,
ticket_status: {
id: 3,
name: 'In progress',
board_column: { id: 1, name: 'Delivery' },
},
field_values: [
{
id: 1,
ticket_field_definition_id: 1,
value_number: 0,
value_user_id: null,
},
{
id: 2,
ticket_field_definition_id: 2,
value_number: null,
value_user_id: 1,
user_value: user,
},
],
worklog_entries: [
{
id: 2,
actor: null,
actor_id: null,
entry_type: 'status_changed',
summary: 'Moved from Backlog to Delivery / In progress.',
created_at: '2026-09-11T13:00:00Z',
},
{
id: 1,
actor: user,
actor_id: user.id,
entry_type: 'created',
summary: 'Ticket created in the project backlog.',
created_at: '2026-09-11T12:00:00Z',
},
],
created_at: '2026-09-10T12:00:00Z',
updated_at: '2026-09-11T12:00:00Z',
};
const fieldDefinitions = [
{ id: 1, label: 'Impact score', field_type: 'number' },
{ id: 2, label: 'Reviewer', field_type: 'user' },
];
let savedForm = {};
function makePage(url) {
const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams;
const creating = parsed.pathname.endsWith('/create');
const detail =
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
const data = structuredClone(ticket);
if (parsed.pathname.endsWith('/41')) {
Object.assign(data, linked, {
parent_ticket_id: null,
parent_ticket: null,
title: 'Related infrastructure',
});
}
if (params.has('saved')) {
Object.assign(data, savedForm, { title: params.get('saved') });
}
if (params.has('long')) {
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
}
if (params.has('parent')) {
data.ticket_type.is_parent = true;
}
if (params.has('empty')) {
Object.assign(data, {
description: null,
acceptance_criteria: [],
assignee: null,
assignee_user_id: null,
author: null,
author_user_id: null,
parent_ticket: null,
parent_ticket_id: null,
is_blocked: false,
blocked_comment: null,
story_points: null,
ticket_status: null,
field_values: [],
worklog_entries: [],
});
}
const noAccess = params.has('noAccess');
const repo = { ...repository, has_personal_access: !noAccess };
const repositoryError = params.has('gitError')
? 'Repository temporarily unavailable.'
: null;
return {
component: creating
? 'TicketCreate'
: detail
? 'Ticket'
: 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: params.has('linkRejected')
? {
ticket_link_type_id: 'Invalid relationship type.',
linked_ticket_id: 'Choose another ticket.',
}
: params.has('rejected')
? {
title: 'This title is not allowed.',
...(params.has('allErrors')
? {
description: 'Invalid description.',
ticket_type_id: 'Invalid type.',
ticket_priority_id: 'Invalid priority.',
ticket_status_id: 'Invalid status.',
assignee_user_id: 'Invalid assignee.',
author_user_id: 'Invalid author.',
parent_ticket_id: 'Invalid parent.',
story_points: 'Invalid points.',
'acceptance_criteria.0.text':
'Invalid checklist.',
blocked_comment: 'Invalid blocker.',
'field_values.0.value_number':
'Invalid number.',
'field_values.1.value_user_id':
'Invalid reviewer.',
}
: {}),
}
: {},
sidebarOpen: true,
auth: {
user,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: {
project: {
...project,
canManage: false,
canCreateTickets: !params.has('readonly'),
},
},
project: {
...project,
show_author: !params.has('hideAuthor'),
show_story_points: !params.has('hidePoints'),
},
ticket: data,
canEditTicket: !params.has('readonly'),
canManageProject: params.has('admin'),
status: params.has('saved') ? 'Ticket updated.' : null,
users: [user, ticket.author],
ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
source: params.get('source') === 'board' ? 'board' : 'backlog',
defaultTicketStatusId: 3,
ticketStatusOptions: [
{
id: 3,
name: 'In progress',
board_column: { name: 'Delivery' },
},
{ id: 4, name: 'Backlog', board_column: null },
],
ticketPriorities: [data.ticket_priority],
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
parentTicketOptions: [parent],
childTicketTree: params.has('parent')
? [
{
...linked,
ticket_type_name: 'Epic',
children: [
{
id: 43,
ticket_key: 'ENG-43',
title: 'Nested child with a long title that must wrap on mobile',
ticket_type_name: 'Task',
children: [],
},
],
children_count: 0,
},
]
: [],
linkedTickets:
params.has('empty') || params.has('noLinks')
? []
: [
{
id: 3,
source_ticket_id: params.has('incoming')
? 41
: 42,
target_ticket_id: params.has('incoming')
? 42
: 41,
ticket_link_type: {
id: 1,
label: params.has('incoming')
? 'Blocked by'
: 'Blocks',
},
linked_ticket: linked,
},
],
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
ticketLinkTypes: params.has('noTypes')
? []
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }],
repositoryBranches: params.has('empty')
? []
: [
{
repository_connection: repo,
branches: [{ name: 'feature/ENG-42-cache' }],
error: repositoryError,
},
],
repositoryPullRequests: params.has('empty')
? []
: [
{
repository_connection: repo,
pull_requests: [
{
number: 12,
status: 'open',
title: 'Cache implementation',
source_branch: 'feature/ENG-42-cache',
html_url:
'https://git.example.test/Engineering/platform/pulls/12',
},
],
error: repositoryError,
},
],
suggestedBranchName: `${data.ticket_key.toLowerCase()}-cache`,
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
if (request.url.includes('/branches/validate')) {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
is_valid: true,
message: 'Branch name is available.',
}),
);
return;
}
if (request.method !== 'GET') {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = body ? JSON.parse(body) : {};
const previous = new URL(request.headers.referer);
const params = previous.searchParams;
if (request.url.includes('/branches') && params.has('slowBranch')) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (params.has('serverError')) {
response.writeHead(503);
response.end('Simulated failure');
return;
}
if (!request.headers['x-inertia']) {
assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/projects/7/tickets/42');
assert.equal(
data.title,
ticket.title,
'Checklist toggle must not save the title draft.',
);
assert.equal(
data.acceptance_criteria[0].checked,
data.acceptance_criteria[1].checked,
'Toggling a parent must update its descendants.',
);
response.writeHead(params.has('rejectToggle') ? 422 : 200, {
'Content-Type': 'application/json',
});
response.end('{}');
return;
}
if (request.url.includes('/branches')) {
assert.equal(data.branch_name, 'eng-42-cache');
assert.equal(Number(data.repository_connection_id), 9);
} else if (request.url.includes('/links')) {
assert.ok(
request.url.endsWith('/links/3?_method=DELETE') ||
request.method === 'DELETE' ||
Number(data.linked_ticket_id) === 41,
);
if (params.has('rejectLink')) {
params.set('linkRejected', '1');
} else if (request.method === 'DELETE') {
params.set('noLinks', '1');
} else {
params.delete('noLinks');
params.delete('linkRejected');
if (data.link_direction === 'counterpart') {
params.set('incoming', '1');
} else {
params.delete('incoming');
}
}
} else {
assert.ok(
(request.method === 'POST' || 'field_values' in data) &&
'acceptance_criteria' in data &&
'parent_ticket_id' in data,
);
if (params.has('reject')) {
params.set('rejected', '1');
} else {
savedForm = {
...data,
blocked_comment: data.is_blocked
? data.blocked_comment
: null,
field_values: data.field_values.map((fieldValue) => ({
...fieldValue,
user_value:
Number(fieldValue.value_user_id) === user.id
? user
: null,
})),
};
params.set('saved', data.title);
}
}
response.writeHead(303, {
Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
});
response.end();
return;
}
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.writeHead(200, {
'X-Inertia': 'true',
'Content-Type': 'application/json',
});
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '&lt;');
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" data-page="${escaped}"></div><script type="module" src="/tests/Fixtures/ticket-detail-preview.mjs"></script></body></html>`,
),
);
});
await server.listen();
console.log(
'Ticket detail fixture ready at http://127.0.0.1:4185/projects/7/tickets/42',
);
} else {
try {
const { useTicketLinkActions } = await server.ssrLoadModule(
'/resources/js/composables/useTicketLinkActions.ts',
);
const linkTypes = ref([
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
]);
const linkForm = reactive({
ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41,
});
const { actions, selectedAction, selectedActionLabel } =
useTicketLinkActions(linkTypes, linkForm);
assert.deepEqual(
actions.value.map((action) => action.label),
['Blocks', 'Blocked by', 'Relates to'],
);
assert.equal(selectedActionLabel.value, 'Blocks');
selectedAction.value = '1:counterpart';
assert.equal(selectedActionLabel.value, 'Blocked by');
assert.deepEqual(
{ ...linkForm },
{
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
},
);
selectedAction.value = '2:source';
assert.equal(linkForm.ticket_link_type_id, 2);
assert.equal(linkForm.link_direction, 'source');
selectedAction.value = 'unknown';
assert.equal(selectedActionLabel.value, 'Relates to');
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Ticket } = await server.ssrLoadModule(
'/resources/js/pages/projects/Ticket.vue',
);
const { default: TicketCreate } = await server.ssrLoadModule(
'/resources/js/pages/projects/TicketCreate.vue',
);
const render = async (query = '', creating = false) => {
const result = await createInertiaApp({
page: makePage(
`/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
),
resolve: () => (creating ? TicketCreate : Ticket),
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return result.body.split('id="main-content"')[1];
};
const html = await render();
assert.ok(!html.includes('id="link_direction"'));
assert.ok(html.includes('Blocked by'));
const linkFields = [
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
].find(([fieldset]) =>
fieldset.includes('id="ticket_link_type_id"'),
)?.[0];
assert.ok(linkFields);
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
assert.match(
linkFields,
/<option value="1:source"[^>]*>Blocks<\/option>/,
);
assert.match(
linkFields,
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
);
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
for (const text of [
ticket.title,
'Keep every existing ticket action available.',
'Cache requests safely',
'Invalidate when records change',
'Urgent project priority',
'Delivery / In progress',
'Alex Morgan',
'Sam Chen',
'Impact score',
'Reviewer',
'Waiting for infrastructure approval.',
'feature/ENG-42-cache',
'Cache implementation',
'Ticket created in the project backlog.',
'Ticket created',
'Status changed',
'System',
]) {
assert.ok(
html.includes(text),
`Missing ticket information: ${text}`,
);
}
for (const section of [
'description',
'checklist',
'metadata',
'blocker',
'relationships',
'history',
'git',
]) {
assert.ok(html.includes(`href="#ticket-${section}"`));
assert.ok(html.includes(`id="ticket-${section}"`));
}
for (const route of [
'/projects/7',
'/projects/7/backlog',
'/projects/7/board',
'/projects/7/tickets/40',
'/projects/7/tickets/41',
]) {
assert.ok(html.includes(`href="${route}"`));
}
assert.match(html, /<details[^>]+id="ticket-edit"/);
for (const field of [
'title',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'author_user_id',
'story_points',
'parent_ticket_id',
'description',
'is_blocked',
'blocked_comment',
'field-1',
'field-2',
]) {
assert.ok(
html.includes(`id="${field}"`),
`Missing editor: ${field}`,
);
}
assert.match(html, /aria-label="Criterion 1"/);
assert.match(html, /Add criterion/);
assert.match(html, /Create branch/);
assert.match(
html,
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(
html,
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(html, /Save link/);
assert.doesNotMatch(html, /Outgoing/);
const incoming = await render('?incoming');
assert.doesNotMatch(incoming, /Incoming/);
assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/);
assert.match(
await render('?noTargets'),
/No other tickets are available/,
);
assert.match(html, /Remove/);
const readOnly = await render('?readonly');
assert.doesNotMatch(
readOnly,
/<form|<details|Create branch|Save changes|Save link/,
);
assert.match(readOnly, /type="checkbox" disabled/);
assert.match(readOnly, /Read only/);
const hidden = await render('?readonly&hideAuthor&hidePoints');
assert.doesNotMatch(hidden, /Sam Chen|Estimated story points/);
assert.match(await render('?readonly&hideAuthor&admin'), /Sam Chen/);
const empty = await render('?empty');
for (const text of [
'No description has been added yet.',
'No acceptance criteria yet.',
'No status',
'Unassigned',
'No author',
'Not set',
'No linked repositories',
'No worklog entries exist',
]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
}
const hierarchy = await render('?parent');
assert.match(hierarchy, /Child tickets/);
assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/);
const noAccess = await render('?noAccess');
assert.match(noAccess, /Personal Git access required/);
assert.match(noAccess, /Open Git connections/);
const gitError = await render('?gitError');
assert.match(gitError, /Branches unavailable/);
assert.match(gitError, /Pull requests unavailable/);
assert.match(gitError, /Repository temporarily unavailable/);
const create = await render('?source=board', true);
assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/);
assert.match(create, /Back to board/);
assert.match(create, /id="ticket_status_id"/);
assert.doesNotMatch(
create,
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
);
assert.doesNotMatch(
await render('?hidePoints', true),
/id="story_points"/,
);
const unavailable = await render('?noMetadata', true);
assert.doesNotMatch(unavailable, /<form/);
assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [
'title',
'description',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'parent_ticket_id',
'story_points',
]) {
assert.ok(invalid.includes(`id="${id}-error"`));
}
}
console.log('Ticket detail behavior checks passed.');
} finally {
await server.close();
}
}