481 lines
16 KiB
JavaScript
481 lines
16 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 } 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',
|
|
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: 1,
|
|
actor: user,
|
|
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' },
|
|
];
|
|
function makePage(url) {
|
|
const parsed = new URL(url, 'http://localhost');
|
|
const params = parsed.searchParams;
|
|
const detail = 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')) {
|
|
data.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: detail ? 'Ticket' : 'Destination',
|
|
url,
|
|
version: null,
|
|
clearHistory: false,
|
|
encryptHistory: false,
|
|
props: {
|
|
name: 'KanbanGit',
|
|
errors: params.has('rejected')
|
|
? { title: 'This title is not allowed.' }
|
|
: {},
|
|
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: [data.ticket_type],
|
|
ticketPriorities: [data.ticket_priority],
|
|
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
|
|
parentTicketOptions: [parent],
|
|
childTicketTree: params.has('parent')
|
|
? [
|
|
{
|
|
...linked,
|
|
ticket_type: { is_parent: false },
|
|
children: [],
|
|
children_count: 0,
|
|
},
|
|
]
|
|
: [],
|
|
linkedTickets: params.has('empty')
|
|
? []
|
|
: [
|
|
{
|
|
id: 3,
|
|
ticket_link_type: { id: 1, label: 'Blocks' },
|
|
linked_ticket: linked,
|
|
},
|
|
],
|
|
ticketLinkOptions: [linked],
|
|
ticketLinkTypes: [
|
|
{ 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 = JSON.parse(body);
|
|
const previous = new URL(request.headers.referer);
|
|
const params = previous.searchParams;
|
|
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') ||
|
|
Number(data.linked_ticket_id) === 41,
|
|
);
|
|
} else {
|
|
assert.ok(
|
|
'field_values' in data &&
|
|
'acceptance_criteria' in data &&
|
|
'parent_ticket_id' in data,
|
|
);
|
|
if (params.has('reject')) {
|
|
params.set('rejected', '1');
|
|
} else {
|
|
params.set('saved', data.title);
|
|
}
|
|
}
|
|
response.writeHead(303, {
|
|
Location: `/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('<', '<');
|
|
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 { 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 render = async (query = '') => {
|
|
const result = await createInertiaApp({
|
|
page: makePage(`/projects/7/tickets/42${query}`),
|
|
resolve: () => 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();
|
|
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.',
|
|
]) {
|
|
assert.ok(
|
|
html.includes(text),
|
|
`Missing ticket information: ${text}`,
|
|
);
|
|
}
|
|
for (const section of [
|
|
'description',
|
|
'checklist',
|
|
'metadata',
|
|
'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',
|
|
'acceptance_criteria_checklist',
|
|
'is_blocked',
|
|
'blocked_comment',
|
|
'field-1',
|
|
'field-2',
|
|
]) {
|
|
assert.ok(
|
|
html.includes(`id="${field}"`),
|
|
`Missing editor: ${field}`,
|
|
);
|
|
}
|
|
assert.match(html, /Create branch/);
|
|
assert.match(html, /Save link/);
|
|
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 have been added yet.',
|
|
'No status',
|
|
'Unassigned',
|
|
'No author',
|
|
'Not set',
|
|
'No matching branches',
|
|
'No matching pull requests',
|
|
'No worklog entries exist',
|
|
]) {
|
|
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
|
}
|
|
assert.match(await render('?parent'), /Child tickets/);
|
|
assert.match(await render('?noAccess'), /Open Git connections/);
|
|
assert.match(
|
|
await render('?gitError'),
|
|
/Repository temporarily unavailable/,
|
|
);
|
|
console.log('Ticket detail behavior checks passed.');
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
}
|