import assert from 'node:assert/strict'; 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'; import { createInertiaApp } from '@inertiajs/vue3'; const serve = process.argv.includes('--serve'); const server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite/project-board-settings-fixture', plugins: [vue(), ...(serve ? [tailwind()] : [])], resolve: { alias: { '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), }, }, server: serve ? { host: '127.0.0.1', port: 4188, strictPort: true, hmr: false } : { middlewareMode: true, hmr: false, watch: null }, appType: 'custom', }); const makeStatus = (id, name, column, position = 1) => ({ id, name, board_column_id: column, position, allowed_target_statuses: [], }); function initial() { const backlog = makeStatus(1, 'Backlog', null); const ready = makeStatus(2, 'Ready', 10); const active = makeStatus(3, 'Building', 20); const review = makeStatus(4, 'Review', 20, 2); backlog.allowed_target_statuses = [ready]; const columns = [ { id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] }, { id: 20, name: 'In progress', position: 2, ticket_statuses: [active, review], }, ]; return { project: { id: 1, key: 'CORE', name: 'Core platform' }, status: null, templateState: null, backlogTicketStatuses: [backlog], boardColumns: columns, ticketStatuses: [ backlog, ...columns.flatMap((column) => column.ticket_statuses.map((status) => ({ ...status, board_column: { id: column.id, name: column.name }, })), ), ], }; } function makePage( extra = {}, url = '/projects/1/settings/board', component = 'Board', ) { return { component, url, version: null, clearHistory: false, encryptHistory: false, props: { name: 'KanbanGit', errors: {}, sidebarOpen: true, appearance: 'system', auth: { user: { id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null, avatar_color: '#10B981', avatar_is_generated: true, }, permissions: { globalAdmin: false, projectCreate: false, canManageTeams: false, }, }, navigation: { project: { id: 1, name: 'Core platform', key: 'CORE', canManage: true, }, }, ...initial(), ...extra, }, }; } if (serve) { let data = initial(); let nextId = 100; server.middlewares.use(async (request, response, next) => { if (!request.url.startsWith('/projects')) { return next(); } const url = new URL(request.url, 'http://localhost'); const mutation = request.method !== 'GET'; if (!mutation && url.searchParams.has('reset')) { data = initial(); } let errors = {}; if (mutation) { const result = request.headers.cookie?.match( /workflow_test_result=(\w+)/, )?.[1] ?? 'success'; let body = ''; for await (const chunk of request) { body += chunk; } const submitted = JSON.parse(body || '{}'); if (['server', 'forbidden'].includes(result)) { response.statusCode = result === 'server' ? 503 : 403; response.end('Simulated request failure'); return; } if (result === 'validation') { errors = url.pathname.endsWith('/transitions') ? { 'allowed_ticket_status_ids.0': 'Choose a destination from this project.', } : request.method === 'DELETE' ? { ticket_status_id: 'This group needs at least one status.', } : { name: 'Choose a different name.' }; } else if (result === 'position') { errors = { position: 'The position must be at least 1.' }; } else { const segments = url.pathname.split('/'); const id = Number(segments.at(-1)); const columns = data.boardColumns; if (url.pathname.includes('/columns')) { if (request.method === 'POST') { const column = { id: nextId++, name: submitted.name, position: columns.length + 1, ticket_statuses: [], }; column.ticket_statuses.push( makeStatus(nextId++, submitted.name, column.id), ); columns.push(column); } else if (request.method === 'PATCH') { const column = columns.find((item) => item.id === id); column.name = submitted.name; columns.splice(columns.indexOf(column), 1); columns.splice( Math.max( 0, Math.min( Number(submitted.position) - 1, columns.length, ), ), 0, column, ); } else { const column = columns.find((item) => item.id === id); for (const status of column.ticket_statuses) { status.board_column_id = null; status.position = data.backlogTicketStatuses.length + 1; data.backlogTicketStatuses.push(status); } columns.splice(columns.indexOf(column), 1); } columns.forEach((column, index) => { column.position = index + 1; }); } else if (url.pathname.endsWith('/transitions')) { const sourceId = Number(segments.at(-2)); const statuses = [ ...data.backlogTicketStatuses, ...columns.flatMap((column) => column.ticket_statuses), ]; statuses.find( (status) => status.id === sourceId, ).allowed_target_statuses = ( submitted.allowed_ticket_status_ids ?? [] ).map((id) => ({ id: Number(id) })); } else if (request.method === 'POST') { const group = submitted.board_column_id === null ? data.backlogTicketStatuses : columns.find( (item) => item.id === submitted.board_column_id, ).ticket_statuses; group.push( makeStatus( nextId++, submitted.name, submitted.board_column_id, group.length + 1, ), ); } else { const group = [ data.backlogTicketStatuses, ...columns.map((column) => column.ticket_statuses), ].find((items) => items.some((item) => item.id === id)); const status = group.find((item) => item.id === id); if (request.method === 'PATCH') { status.name = submitted.name; } else { group.splice(group.indexOf(status), 1); } } data.ticketStatuses = [ ...data.backlogTicketStatuses, ...columns.flatMap((column) => column.ticket_statuses.map((status) => ({ ...status, board_column: { id: column.id, name: column.name }, })), ), ]; data.status = 'Updated'; } } if (url.searchParams.has('empty')) { data.boardColumns = []; data.ticketStatuses = data.backlogTicketStatuses; } const page = makePage({ ...data, errors }); response.setHeader('Vary', 'X-Inertia'); if (request.headers['x-inertia']) { response.setHeader('X-Inertia', 'true'); response.setHeader('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('Project board settings fixture ready.'); } else { try { const { createLocalization } = await server.ssrLoadModule( '/resources/js/lib/localization.ts', ); const { createAppearance } = await server.ssrLoadModule( '/resources/js/composables/useAppearance.ts', ); const { en } = await server.ssrLoadModule( '/resources/js/locales/en.ts', ); const component = ( await server.ssrLoadModule( '/resources/js/pages/projects/settings/Board.vue', ) ).default; const render = async (extra = {}, locale = createLocalization()) => ( await createInertiaApp({ page: makePage(extra), resolve: () => component, render: renderToString, setup: ({ App, props, plugin }) => createSSRApp({ render: () => h(App, props) }) .use(plugin) .use(locale) .use(createAppearance()), }) ).body; const html = await render(); assert.match(html, /Board workflow/); assert.match(html, /Off the board/); assert.match(html, /aria-current="page"/); assert.ok( html.indexOf('data-column-id="10"') < html.indexOf('data-column-id="20"'), ); assert.match(html, /From status/); assert.match(html, /Saved destinations: 1/); assert.match( await render({ ticketStatuses: [] }), /Add a status to configure/, ); assert.match( await render({ ticketStatuses: [makeStatus(1, 'Backlog', null)] }), /No other statuses exist yet/, ); const buttons = html.match(/]*>[\s\S]*?<\/button>/g); for (const name of ['Backlog', 'Ready']) { assert.match( buttons.find((button) => button.includes('aria-label="Delete status ' + name + '"'), ), /aria-disabled="true"/, ); } assert.doesNotMatch( buttons.find((button) => button.includes('aria-label="Delete status Review"'), ), /aria-disabled="true"/, ); assert.match( await render({ boardColumns: [] }), /No board columns yet/, ); assert.match( await render( {}, createLocalization( { locale: 'de' }, { de: { ...en, 'workflow.title': 'Board-Ablauf' } }, ), ), /Board-Ablauf/, ); console.log('Project board settings rendering checks passed.'); } finally { await server.close(); } }