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-notification-settings-fixture', plugins: [vue(), ...(serve ? [tailwind()] : [])], resolve: { alias: { '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), }, }, server: serve ? { host: '127.0.0.1', port: 4195, strictPort: true, hmr: false } : { middlewareMode: true, hmr: false, watch: null }, appType: 'custom', }); const options = (values) => values.map((value) => ({ value, label: value })); function initial() { return { project: { id: 1, key: 'CORE', name: 'Core platform' }, status: null, templateState: null, channelOptions: options(['discord', 'webhook']), eventTypeOptions: options([ 'ticket_status_changed', 'ticket_create', 'ticket_assignee_changed', ]), filterAttributeOptions: options([ 'project.id', 'ticket.key', 'status.to.label', 'actor.name', ]), filterOperatorOptions: options([ 'equals', 'not_equals', 'exists', 'not_exists', 'in', ]), discordAllowedMentionOptions: options(['everyone', 'roles', 'users']), templateVariables: [ { value: '$ticket_key', description: 'Ticket key' }, { value: '$url', description: 'Ticket URL' }, ], notificationActions: [ { id: 10, project_id: 1, name: 'Release alerts', enabled: true, event_type: 'ticket_status_changed', event_type_label: 'Ticket status changed', channel: 'discord', channel_label: 'Discord', channel_configuration: { allowed_mentions: ['users'] }, has_delivery_url: true, message_template: '$ticket_key moved to $status_to: $url', filters: [ { attribute: 'ticket.key', operator: 'in', value: ['CORE-1', 'CORE-2'], }, ], created_at: '2026-09-01T09:00:00Z', updated_at: '2026-09-02T12:00:00Z', }, { id: 20, project_id: 1, name: 'Audit webhook', enabled: false, event_type: 'ticket_create', event_type_label: 'Ticket create', channel: 'webhook', channel_label: 'Webhook', channel_configuration: {}, has_delivery_url: false, message_template: null, filters: [], created_at: null, updated_at: null, }, ], }; } function makePage( extra = {}, url = '/projects/1/settings/notifications', component = 'Notifications', ) { 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(); nextId = 100; } let errors = {}; if (mutation) { let body = ''; for await (const chunk of request) { body += chunk; } const submitted = JSON.parse(body || '{}'); const result = request.headers.cookie?.match( /notification_test_result=(\w+)/, )?.[1] ?? 'success'; if (result === 'server' || result === 'forbidden') { response.statusCode = result === 'server' ? 500 : 403; response.end('Unavailable'); return; } if (result === 'validation') { errors = { delivery_url: 'Enter a valid URL.', 'filters.0.value': 'Choose at least one ticket.', 'channel_configuration.allowed_mentions.0': 'This mention type is not supported.', }; } else { const id = Number(url.pathname.split('/').at(-1)); if (request.method === 'DELETE') { data.notificationActions = data.notificationActions.filter( (entry) => entry.id !== id, ); } else { const { delivery_url, ...safe } = submitted; if (request.method === 'POST') { data.notificationActions.push({ id: nextId++, project_id: 1, ...safe, has_delivery_url: !!delivery_url, created_at: '2026-09-15T10:00:00Z', updated_at: null, }); } else { const entry = data.notificationActions.find( (entry) => entry.id === id, ); Object.assign(entry, safe, { has_delivery_url: entry.has_delivery_url || !!delivery_url, }); } } } data.status = Object.keys(errors).length ? null : 'Updated'; } if (url.searchParams.has('empty')) { data.notificationActions = []; } 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('Notification 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/Notifications.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, /Notifications/); assert.match(html, /aria-current="page"/); assert.match(html, /Release alerts/); assert.match(html, /Audit webhook/); assert.match(html, /Secret URL saved/); assert.match(html, /No delivery URL saved/); assert.match(html, /1 filter ยท all must match/); assert.match(html, /Disabled/); assert.match(html, /Ticket status changed/); assert.match(html, /No saved changes yet/); assert.match( await render({ notificationActions: [] }), /No notifications yet/, ); assert.match( await render({ status: 'Saved' }), /Notification settings saved/, ); assert.match( await render( {}, createLocalization( { locale: 'de' }, { de: { ...en, 'notifications.title': 'Benachrichtigungen', }, }, ), ), /Benachrichtigungen/, ); const { serializeNotificationFilters } = await server.ssrLoadModule( '/resources/js/lib/notification-filters.ts', ); assert.deepEqual( serializeNotificationFilters([ { id: 1, attribute: 'ticket.key', operator: 'in', value: ' CORE-1, \nCORE-2, ', }, { id: 2, attribute: 'actor.name', operator: 'exists', value: 'ignored', }, { id: 3, attribute: 'actor.name', operator: 'not_exists', value: 'ignored', }, { id: 4, attribute: 'status.to.label', operator: 'equals', value: ' Done ', }, { id: 5, attribute: 'status.to.label', operator: 'not_equals', value: ' Doing ', }, ]), [ { attribute: 'ticket.key', operator: 'in', value: ['CORE-1', 'CORE-2'], }, { attribute: 'actor.name', operator: 'exists', value: null }, { attribute: 'actor.name', operator: 'not_exists', value: null, }, { attribute: 'status.to.label', operator: 'equals', value: 'Done', }, { attribute: 'status.to.label', operator: 'not_equals', value: 'Doing', }, ], ); assert.deepEqual(serializeNotificationFilters([]), []); console.log('Notification settings checks passed.'); } finally { await server.close(); } }