diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php index 1007bdd..1e037ca 100644 --- a/app/Http/Requests/UpdateProjectTemplateRequest.php +++ b/app/Http/Requests/UpdateProjectTemplateRequest.php @@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest 'copy_areas' => ['required', 'array', 'min:1'], 'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())], 'confirm_linked_projects_update' => [ + Rule::excludeIf(! $requiresImpactConfirmation), Rule::requiredIf($requiresImpactConfirmation), 'accepted', ], diff --git a/resources/js/components/projects/ProjectTemplateForm.vue b/resources/js/components/projects/ProjectTemplateForm.vue new file mode 100644 index 0000000..f0bb3f0 --- /dev/null +++ b/resources/js/components/projects/ProjectTemplateForm.vue @@ -0,0 +1,347 @@ + + + diff --git a/resources/js/composables/useProjectTemplateAreas.ts b/resources/js/composables/useProjectTemplateAreas.ts new file mode 100644 index 0000000..c8f2aac --- /dev/null +++ b/resources/js/composables/useProjectTemplateAreas.ts @@ -0,0 +1,27 @@ +import { useLocalization } from '@/composables/useLocalization'; + +const areaKeys = { + project_settings: 'projectCreation.areas.projectSettings', + board_columns: 'projectCreation.areas.boardColumns', + ticket_statuses: 'projectCreation.areas.ticketStatuses', + status_transitions: 'projectCreation.areas.statusTransitions', + ticket_types: 'projectCreation.areas.ticketTypes', + ticket_priorities: 'projectCreation.areas.ticketPriorities', + ticket_fields: 'projectCreation.areas.ticketFields', + ticket_link_types: 'projectCreation.areas.ticketLinkTypes', + repository_connections: 'projectCreation.areas.repositories', + notification_actions: 'projectCreation.areas.notifications', +} as const; + +export function useProjectTemplateAreas() { + const { t } = useLocalization(); + function areaText( + value: string, + part: 'label' | 'description', + fallback = value, + ): string { + const key = areaKeys[value as keyof typeof areaKeys]; + return key ? t(`${key}.${part}`) : fallback; + } + return { areaText }; +} diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index fd2438c..94fb7a9 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,68 @@ export const en = { + 'templates.description': + 'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.', + 'templates.updated': 'Project templates updated.', + 'templates.fromProject': 'Start with an existing project', + 'templates.fromProjectHelp': + 'Open a project and choose “Save as new template” to capture its configuration.', + 'templates.browseProjects': 'Browse projects', + 'templates.empty': 'No published templates yet', + 'templates.emptyHelp': + 'Save a project as a template to reuse its settings. Internal project baselines are kept out of this list.', + 'templates.published': 'Published project templates', + 'templates.linkedCount': { + one: '{count} linked project', + other: '{count} linked projects', + }, + 'templates.savedFrom': 'Saved from {project} ({key}).', + 'templates.sourceUnavailable': 'Source project unavailable', + 'templates.edit': 'Edit', + 'templates.use': 'Use template', + 'templates.createTitle': 'Save {project} as a template', + 'templates.createDescription': + 'Choose the configuration to reuse in future projects.', + 'templates.editTitle': 'Edit {name}', + 'templates.editDescription': + 'Refresh the template from its source project and review the impact on linked projects.', + 'templates.defaultName': '{project} template', + 'templates.details': 'Template details', + 'templates.name': 'Template name', + 'templates.namePlaceholder': 'Foundation template', + 'templates.slug': 'Template slug', + 'templates.slugPlaceholder': 'foundation-template', + 'templates.slugHelp': + 'Slugs are normalized automatically. After creation, the slug cannot be changed.', + 'templates.slugLocked': + 'The slug is locked so existing template references remain stable.', + 'templates.groups': 'Configuration groups', + 'templates.createGroupsHelp': + 'Only selected groups are saved. Related groups required by the workflow are included automatically.', + 'templates.editGroupsHelp': + 'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.', + 'templates.impact': 'Template impact', + 'templates.inheritance': 'Inheritance', + 'templates.cleanStart': + 'Tickets and other operational ticket data are never copied. The source project stays unchanged.', + 'templates.refreshHelp': + 'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.', + 'templates.linkedWarning': 'Linked projects will be affected', + 'templates.linkedImpact': + 'Saving updates the baseline for {count} linked projects. Inherited groups follow the new configuration; existing local overrides stay unchanged.', + 'templates.acknowledge': + 'I understand that projects inheriting from this template will be affected.', + 'templates.saveNew': 'Save new template', + 'templates.saveChanges': 'Save template changes', + 'templates.missingSource': 'This template cannot be refreshed', + 'templates.missingSourceHelp': + 'The source project is no longer available. You can still use the saved template configuration to create a project.', + 'templates.deleteTitle': 'Delete {name}', + 'templates.delete': 'Delete template', + 'templates.deleting': 'Deleting template…', + 'templates.deleteHelp': + 'This permanently removes the reusable template. It will no longer be available for new projects.', + 'templates.deleteImpact': + '{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.', + 'workflow.title': 'Board workflow', 'workflow.pageTitle': '{project} · Board workflow', 'workflow.description': diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue index 3df553c..e9eb28e 100644 --- a/resources/js/pages/project-templates/Create.vue +++ b/resources/js/pages/project-templates/Create.vue @@ -1,174 +1,38 @@ diff --git a/resources/js/pages/project-templates/Edit.vue b/resources/js/pages/project-templates/Edit.vue index 8da0ed7..01e1c79 100644 --- a/resources/js/pages/project-templates/Edit.vue +++ b/resources/js/pages/project-templates/Edit.vue @@ -1,223 +1,36 @@ diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue index c3fbc53..aea924a 100644 --- a/resources/js/pages/project-templates/Index.vue +++ b/resources/js/pages/project-templates/Index.vue @@ -1,158 +1,258 @@ diff --git a/tests/Feature/Projects/ProjectTemplateControllerTest.php b/tests/Feature/Projects/ProjectTemplateControllerTest.php index 9bb0057..900e4ca 100644 --- a/tests/Feature/Projects/ProjectTemplateControllerTest.php +++ b/tests/Feature/Projects/ProjectTemplateControllerTest.php @@ -163,6 +163,26 @@ class ProjectTemplateControllerTest extends TestCase ->assertNotFound(); } + public function test_unlinked_templates_can_be_updated_without_an_impact_confirmation(): void + { + $user = User::factory()->create(); + $source = Project::factory()->create(); + $template = ProjectTemplate::factory()->create([ + 'source_project_id' => $source->id, + ]); + $this->syncGlobalPermissions($user, ['project:create']); + + $this->actingAs($user) + ->patch(route('project-templates.update', $template), [ + 'name' => 'Updated reusable template', + 'copy_areas' => ['project_settings'], + ]) + ->assertSessionHasNoErrors() + ->assertRedirect(route('project-templates.index')); + + $this->assertSame('Updated reusable template', $template->fresh()->name); + } + public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void { $user = User::factory()->create(); diff --git a/tests/Feature/Projects/ProjectTemplatesUiTest.php b/tests/Feature/Projects/ProjectTemplatesUiTest.php new file mode 100644 index 0000000..8381e81 --- /dev/null +++ b/tests/Feature/Projects/ProjectTemplatesUiTest.php @@ -0,0 +1,18 @@ +setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Project template checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-templates-browser.mjs b/tests/Fixtures/project-templates-browser.mjs new file mode 100644 index 0000000..4ca9967 --- /dev/null +++ b/tests/Fixtures/project-templates-browser.mjs @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, +}); +const page = await context.newPage(); +page.setDefaultTimeout(10000); +const base = 'http://127.0.0.1:4191'; +const errors = []; +const mutations = []; +page.on('pageerror', (error) => errors.push(error.message)); +page.on('request', (request) => { + if (['POST', 'PATCH', 'DELETE'].includes(request.method())) { + mutations.push(request); + } +}); +const button = (name) => page.getByRole('button', { name, exact: true }); +const result = (value) => + context.addCookies([{ name: 'template_test_result', value, url: base }]); +const visit = async (path) => { + await result('success'); + await page.goto(base + path); + await page.locator('h1').waitFor(); +}; +const noOverflow = async () => + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); +const feedback = (state) => + page + .locator('[data-form-state="' + state + '"]') + .waitFor({ state: 'visible' }); +try { + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 1000 }); + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: theme }); + for (const path of [ + '/project-templates?reset', + '/projects/1/templates/create?reset', + '/project-templates/2/edit?reset', + ]) { + await visit(path); + await noOverflow(); + assert.equal( + await page + .locator('html') + .evaluate((el) => el.classList.contains('dark')), + theme === 'dark', + ); + if (width === 390 || width === 1440) { + await page.screenshot({ + path: + '/private/tmp/KAN-46-' + + width + + '-' + + theme + + '-' + + (path.includes('/edit') + ? 'edit' + : path.includes('/create') + ? 'create' + : 'index') + + '.png', + fullPage: true, + }); + } + } + assert.equal(await page.locator('#slug').isDisabled(), true); + assert.equal( + await button('Save template changes').isDisabled(), + true, + ); + const checkbox = page.locator('#confirm-linked'); + await checkbox.focus(); + await page.keyboard.press('Space'); + assert.equal(await checkbox.isChecked(), true); + assert.equal( + await button('Save template changes').isEnabled(), + true, + ); + await visit('/project-templates?reset'); + assert.equal( + await page + .getByText('Internal baseline', { exact: true }) + .count(), + 0, + ); + const trigger = button('Delete Foundation'); + await trigger.focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByRole('dialog'); + await dialog.waitFor(); + await page.keyboard.press('Tab'); + assert.equal( + await dialog.evaluate((el) => + el.contains(document.activeElement), + ), + true, + ); + await noOverflow(); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + assert.equal( + await trigger.evaluate((el) => document.activeElement === el), + true, + ); + assert.equal(mutations.length, 0); + } + } + await visit('/projects/1/templates/create?reset'); + await page.locator('#name').fill('Release setup'); + await page.locator('#slug').fill('release-setup'); + const groups = page.locator('fieldset input[type="checkbox"]'); + for (const group of await groups.all()) { + await group.uncheck(); + } + await page + .getByRole('checkbox', { name: 'Project settings', exact: true }) + .check(); + await page + .getByRole('checkbox', { name: 'Ticket types', exact: true }) + .check(); + await result('validation'); + await button('Save new template').click(); + await feedback('validation'); + assert.equal(await page.locator('#name').inputValue(), 'Release setup'); + assert.equal( + await page.locator('#name').getAttribute('aria-invalid'), + 'true', + ); + await page + .getByText('Choose a valid configuration group.', { exact: true }) + .waitFor(); + assert.equal( + await page + .locator('[data-form-state="validation"]') + .evaluate((el) => el === document.activeElement), + true, + ); + await result('server'); + await button('Save new template').click(); + await feedback('server'); + await result('success'); + const created = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await button('Try again').click(); + assert.deepEqual((await created).postDataJSON(), { + name: 'Release setup', + slug: 'release-setup', + copy_areas: ['project_settings', 'ticket_types'], + }); + await page + .getByRole('heading', { name: 'Project templates', exact: true }) + .waitFor(); + + await visit('/project-templates/2/edit?reset'); + const before = mutations.length; + await page + .locator('form') + .evaluate((el) => + el.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + assert.equal( + mutations.length, + before, + 'Linked updates must wait for acknowledgement', + ); + await page.locator('#confirm-linked').check(); + await page.locator('#name').fill('Updated foundation'); + await page.route('**/project-templates/2', (route) => + route.abort('failed'), + ); + await button('Save template changes').click(); + await feedback('network'); + assert.equal(await page.locator('#confirm-linked').isChecked(), true); + await page.unroute('**/project-templates/2'); + const updated = page.waitForRequest( + (request) => request.method() === 'PATCH', + ); + await button('Try again').click(); + assert.deepEqual((await updated).postDataJSON(), { + name: 'Updated foundation', + copy_areas: ['project_settings', 'ticket_types'], + confirm_linked_projects_update: true, + }); + await page + .getByRole('heading', { name: 'Project templates', exact: true }) + .waitFor(); + + await visit('/project-templates/2/edit?reset&unlinked'); + assert.equal(await page.locator('#confirm-linked').count(), 0); + const unlinked = page.waitForRequest( + (request) => request.method() === 'PATCH', + ); + await button('Save template changes').click(); + assert.deepEqual((await unlinked).postDataJSON(), { + name: 'Foundation', + copy_areas: ['project_settings', 'ticket_types'], + }); + await page + .getByRole('heading', { name: 'Project templates', exact: true }) + .waitFor(); + await visit('/project-templates/2/edit?reset&missing'); + await page + .getByText('This template cannot be refreshed', { exact: true }) + .waitFor(); + assert.equal(await button('Save template changes').isDisabled(), true); + + await visit('/projects/1/templates/create?reset'); + await page.locator('#name').fill('Unsaved work'); + let prompted = false; + page.once('dialog', async (dialog) => { + prompted = true; + await dialog.dismiss(); + }); + await page.getByRole('link', { name: 'Cancel', exact: true }).click(); + assert.equal(prompted, true); + assert.equal(await page.locator('#name').inputValue(), 'Unsaved work'); + // Reload starts the next isolated scenario; accept the browser's unload prompt. + page.once('dialog', (dialog) => dialog.accept()); + await visit('/project-templates?reset'); + await button('Delete Foundation').click(); + await result('server'); + await button('Delete template').click(); + await feedback('server'); + await result('success'); + const deleted = page.waitForRequest( + (request) => request.method() === 'DELETE', + ); + await button('Try again').click(); + assert.equal( + new URL((await deleted).url()).pathname, + '/project-templates/2', + ); + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + await page + .getByText('No published templates yet', { exact: true }) + .waitFor(); + await visit('/project-templates?reset'); + await page.getByRole('link', { name: 'Use template', exact: true }).click(); + await page.getByRole('heading', { name: 'Destination reached' }).waitFor(); + assert.match(page.url(), /\/projects\/create\?template=2$/); + assert.deepEqual(errors, []); + console.log( + 'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/project-templates-preview.mjs b/tests/Fixtures/project-templates-preview.mjs new file mode 100644 index 0000000..d94105a --- /dev/null +++ b/tests/Fixtures/project-templates-preview.mjs @@ -0,0 +1,20 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Index from '../../resources/js/pages/project-templates/Index.vue'; +import Create from '../../resources/js/pages/project-templates/Create.vue'; +import Edit from '../../resources/js/pages/project-templates/Edit.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; +createInertiaApp({ + resolve: (name) => + ({ Index, Create, Edit })[name] ?? { + render: () => h('h1', 'Destination reached'), + }, + setup: ({ el, App, props, plugin }) => + createApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()) + .mount(el), +}); diff --git a/tests/Fixtures/project-templates.mjs b/tests/Fixtures/project-templates.mjs new file mode 100644 index 0000000..406f883 --- /dev/null +++ b/tests/Fixtures/project-templates.mjs @@ -0,0 +1,288 @@ +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-templates-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); +const project = { id: 1, name: 'Core platform', key: 'CORE' }; +const areas = [ + 'project_settings', + 'board_columns', + 'ticket_statuses', + 'status_transitions', + 'ticket_types', + 'ticket_priorities', + 'ticket_fields', + 'ticket_link_types', + 'repository_connections', + 'notification_actions', +]; +const template = { + id: 2, + name: 'Foundation', + slug: 'foundation-template', + is_published: true, + source_project: project, + copy_areas: ['project_settings', 'ticket_types'], + linked_project_count: 2, +}; +const initial = () => ({ + project, + copyAreaOptions: areas.map((value) => ({ + value, + label: value, + description: value, + })), + template: structuredClone(template), + templates: [ + structuredClone(template), + { ...template, id: 3, name: 'Internal baseline', is_published: false }, + ], +}); +function makePage(component = 'Index', extra = {}, url = '/project-templates') { + 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: true, + canManageTeams: false, + }, + }, + navigation: { project: null }, + ...initial(), + ...extra, + }, + }; +} +if (serve) { + let data = initial(); + server.middlewares.use(async (request, response, next) => { + if (!request.url.startsWith('/project')) { + return next(); + } + const url = new URL(request.url, 'http://localhost'); + let component = url.pathname.endsWith('/edit') + ? 'Edit' + : url.pathname.includes('/templates/create') + ? 'Create' + : url.pathname === '/project-templates' + ? 'Index' + : 'Destination'; + if (url.searchParams.has('reset')) { + data = initial(); + } + if (url.searchParams.has('unlinked')) { + data.template.linked_project_count = 0; + } + if (url.searchParams.has('missing')) { + data.template.source_project = null; + } + if (url.searchParams.has('empty')) { + data.templates = []; + } + let errors = {}; + if (request.method !== 'GET') { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const submitted = JSON.parse(body || '{}'); + const result = + request.headers.cookie?.match( + /template_test_result=(\w+)/, + )?.[1] ?? 'success'; + if (result === 'server') { + response.statusCode = 500; + response.end('Unavailable'); + return; + } + if (result === 'validation') { + component = + request.method === 'POST' + ? 'Create' + : request.method === 'PATCH' + ? 'Edit' + : 'Index'; + errors = { + name: 'This template name is unavailable.', + 'copy_areas.0': 'Choose a valid configuration group.', + }; + } else { + if (request.method === 'DELETE') { + data.templates = data.templates.filter( + (item) => item.id !== 2, + ); + } else { + data.templates = [{ ...data.template, ...submitted }]; + } + component = 'Index'; + } + } + const page = makePage( + component, + { + ...data, + errors, + status: + request.method !== 'GET' && !Object.keys(errors).length + ? 'Saved' + : null, + }, + component === 'Index' ? '/project-templates' : request.url, + ); + 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 template 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 render = async ( + name, + extra = {}, + locale = createLocalization(), + ) => { + const component = ( + await server.ssrLoadModule( + '/resources/js/pages/project-templates/' + name + '.vue', + ) + ).default; + return ( + await createInertiaApp({ + page: makePage(name, extra), + resolve: () => component, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(locale) + .use(createAppearance()), + }) + ).body.replace(/ data-page="[^"]*"/, ''); + }; + const list = await render('Index'); + assert.match(list, /Foundation/); + assert.doesNotMatch(list, /Internal baseline/); + assert.match(list, /2 linked projects/); + assert.match(list, /Project settings/); + assert.match(list, /href="\/projects\/create\?template=2"/); + assert.match( + await render('Index', { templates: [] }), + /No published templates yet/, + ); + const create = await render('Create'); + assert.match(create, /value="Core platform template"/); + assert.equal((create.match(/type="checkbox"/g) ?? []).length, 10); + assert.doesNotMatch( + create.match(/]*id="slug"[^>]*>/)?.[0] ?? '', + / disabled(?:[ =>])/, + ); + const edit = await render('Edit'); + assert.match( + edit.match(/]*id="slug"[^>]*>/)?.[0] ?? '', + / disabled(?:[ =>])/, + ); + assert.match(edit, /Linked projects will be affected/); + assert.match( + edit.match(/]*type="submit"[^>]*>/)?.[0] ?? '', + / disabled(?:[ =>])/, + ); + const unlinked = await render('Edit', { + template: { ...template, linked_project_count: 0 }, + }); + assert.doesNotMatch(unlinked, /id="confirm-linked"/); + assert.doesNotMatch( + unlinked.match(/]*type="submit"[^>]*>/)?.[0] ?? '', + / disabled(?:[ =>])/, + ); + assert.match( + await render('Edit', { + template: { ...template, source_project: null }, + }), + /This template cannot be refreshed/, + ); + assert.match( + await render( + 'Index', + {}, + createLocalization( + { locale: 'de' }, + { + de: { + ...en, + 'navigation.templates': 'Projektvorlagen', + }, + }, + ), + ), + /Projektvorlagen/, + ); + console.log('Project template checks passed.'); + } finally { + await server.close(); + } +}