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', 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'); } } 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('<', '<'); response.setHeader('Content-Type', 'text/html'); response.end( await server.transformIndexHtml( request.url, `
`, ), ); }); 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 { 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(); 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, /]+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, /]*href="https:\/\/git\.example\.test\/Engineering\/platform")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/, ); assert.match( html, /]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/, ); assert.match(html, /Save link/); assert.match(html, /Outgoing/); const incoming = await render('?incoming'); assert.match(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, /