feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading

This commit is contained in:
Florian Sylvain committed 2026-10-13 03:19:25 +02:00
1 parent fe254112a2
commit 0632bf53cb
12 files changed
+702 -194

No files matched your search

+2 -12
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
import LanguageChip from '@/components/LanguageChip.vue'
import { useCardHeading } from '@/composables/documentTitle'
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
const props = defineProps<{
@@ -70,16 +69,7 @@ const colorModeSelectUi = computed(() => ({
content: 'w-auto min-w-36',
}))
watch(
() => props.title,
(title) => {
if (!title) {
return
}
document.title = t('{0} · RenewCMS', [title])
},
{ immediate: true },
)
useCardHeading(() => props.title)
watch(
() => props.title,
+8 -22
View File
@@ -8,10 +8,7 @@ beforeEach(() => {
resetAuthShellNavigation()
})
function mountShell(
props: Record<string, unknown>,
slots: Record<string, unknown> = {},
) {
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
return mount(AuthShell, {
props,
slots,
@@ -21,36 +18,29 @@ function mountShell(
})
}
describe('AuthShell renders its title as the page heading and sets the document title', () => {
it('renders an h1 containing the title and sets document.title', () => {
describe('AuthShell renders its title as the page heading', () => {
it('renders an h1 containing the title', () => {
const wrapper = mountShell({ title: 'Log in' })
const heading = wrapper.find('h1')
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(document.title).toBe('Log in · RenewCMS')
})
})
describe('AuthShell with an empty title renders no heading and leaves document.title untouched', () => {
it('mounts directly with an empty title without rendering an h1 or touching document.title', () => {
document.title = 'Existing title · RenewCMS'
describe('AuthShell with an empty title renders no heading', () => {
it('mounts with an empty title without rendering an h1', () => {
const wrapper = mountShell({ title: '' })
expect(wrapper.find('h1').exists()).toBe(false)
expect(document.title).toBe('Existing title · RenewCMS')
})
it('leaves document.title untouched and does not crash when the title changes to empty', async () => {
it('removes the heading when the title changes to empty', async () => {
const wrapper = mountShell({ title: 'Create the owner account' })
expect(document.title).toBe('Create the owner account · RenewCMS')
await wrapper.setProps({ title: '' })
expect(wrapper.find('h1').exists()).toBe(false)
expect(document.title).toBe('Create the owner account · RenewCMS')
})
})
@@ -66,17 +56,13 @@ describe('AuthShell renders its notice and footer slots', () => {
const notice = wrapper.find('span')
expect(notice.text()).toBe('Your session expired.')
expect(
wrapper.findAll('[data-slot="root"][data-orientation]').length,
).toBe(1)
expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1)
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.element.contains(notice.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<h1')).toBeLessThan(
html.indexOf('Your session expired.'),
)
expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
+66
View File
@@ -0,0 +1,66 @@
import { getCurrentScope, onScopeDispose, shallowRef, watchEffect } from 'vue'
import type { Router } from 'vue-router'
import { t, translate } from '@/i18n'
const PRODUCT_NAME = 'RenewCMS'
type TitleSource = () => string | undefined
const overrides = shallowRef<TitleSource[]>([])
const headings = shallowRef<TitleSource[]>([])
function register(list: typeof overrides, source: TitleSource) {
list.value = [...list.value, source]
if (getCurrentScope()) {
onScopeDispose(() => {
list.value = list.value.filter((entry) => entry !== source)
})
}
}
function firstName(sources: TitleSource[]) {
for (const source of [...sources].reverse()) {
const name = source()
if (name) {
return name
}
}
return undefined
}
export function usePageTitleOverride(source: TitleSource) {
register(overrides, source)
}
export function useCardHeading(source: TitleSource) {
register(headings, source)
}
function formatTitle(name: string | undefined) {
return name ? t('{0} · RenewCMS', [name]) : PRODUCT_NAME
}
function routeName(router: Router) {
const meta = router.currentRoute.value.meta
if (typeof meta.documentTitle === 'string') {
return meta.documentTitle
}
const override = firstName(overrides.value)
if (override) {
return override
}
const heading = firstName(headings.value)
if (heading) {
return heading
}
return typeof meta.title === 'string' ? translate(meta.title) : undefined
}
let stopTitleWatcher: (() => void) | null = null
export function installDocumentTitle(router: Router) {
stopTitleWatcher?.()
stopTitleWatcher = watchEffect(() => {
document.title = formatTitle(routeName(router))
})
}
-2
View File
@@ -797,8 +797,6 @@ export const messages = {
'{0} {1} attention': '{0} {1} votre attention',
'{0} {1} rejected': '{0} {1} refusées',
'{0} {1} the same content.': '{0} {1} le même contenu.',
'Check your email · RenewCMS': 'Consultez vos e-mails · RenewCMS',
'This site already has an owner · RenewCMS': 'Ce site a déjà un propriétaire · RenewCMS',
'{0} · RenewCMS': '{0} · RenewCMS',
Enter: 'Entrée',
Escape: 'Échap',
+157 -148
View File
@@ -1,128 +1,128 @@
import {
createRouter,
createWebHistory,
type RouterHistory,
type RouteLocationNormalized,
type RouteLocationRaw,
type RouteRecordRaw,
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { installDocumentTitle } from '@/composables/documentTitle'
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: () => import('@/views/RootView.vue'),
meta: { auth: 'any' },
},
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
meta: { auth: 'public' },
},
{
path: '/register',
component: () => import('@/views/RegisterView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/pending',
component: () => import('@/views/PendingView.vue'),
meta: { auth: 'session' },
},
{
path: '/register/validate',
component: () => import('@/views/VerifyView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/request',
component: () => import('@/views/ResetRequestView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/validate',
component: () => import('@/views/ResetConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/invite/accept',
component: () => import('@/views/AcceptInviteView.vue'),
meta: { auth: 'public' },
},
{
path: '/home',
component: () => import('@/views/HomeView.vue'),
meta: { auth: 'verified', title: 'Home' },
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/media',
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
},
{
path: '/profile',
component: () => import('@/views/ProfileView.vue'),
meta: { auth: 'verified', title: 'Profile' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/integration/api-keys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/variants',
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/cache',
component: () => import('@/views/CachePage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
meta: { auth: 'any' },
},
],
})
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/views/RootView.vue'),
meta: { auth: 'any' },
},
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
meta: { auth: 'public' },
},
{
path: '/register',
component: () => import('@/views/RegisterView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/pending',
component: () => import('@/views/PendingView.vue'),
meta: { auth: 'session' },
},
{
path: '/register/validate',
component: () => import('@/views/VerifyView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/request',
component: () => import('@/views/ResetRequestView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/validate',
component: () => import('@/views/ResetConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/invite/accept',
component: () => import('@/views/AcceptInviteView.vue'),
meta: { auth: 'public' },
},
{
path: '/home',
component: () => import('@/views/HomeView.vue'),
meta: { auth: 'verified', title: 'Home' },
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/media',
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
},
{
path: '/profile',
component: () => import('@/views/ProfileView.vue'),
meta: { auth: 'verified', title: 'Profile' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/integration/api-keys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/variants',
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/cache',
component: () => import('@/views/CachePage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
meta: { auth: 'public', documentTitle: '404' },
},
]
export function resolveAuthRedirect(
to: RouteLocationNormalized,
@@ -163,41 +163,50 @@ export function resolveAuthRedirect(
return true
}
router.beforeEach((to) => {
if (to.meta.auth === 'public') {
return true
}
export function createAppRouter(history: RouterHistory) {
const router = createRouter({ history, routes })
installDocumentTitle(router)
const session = useSessionStore()
if (session.status === 'idle') {
session.check()
return true
}
if (session.status === 'checking') {
return true
}
return resolveAuthRedirect(to, session)
})
router.afterEach((to) => {
if (to.meta.auth === 'public') {
return
}
const session = useSessionStore()
const settled =
session.status === 'idle' || session.status === 'checking'
? session.check()
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
if (destination !== true) {
router.replace(destination)
router.beforeEach((to) => {
if (to.meta.auth === 'public') {
return true
}
const session = useSessionStore()
if (session.status === 'idle') {
session.check()
return true
}
if (session.status === 'checking') {
return true
}
return resolveAuthRedirect(to, session)
})
})
router.afterEach((to) => {
if (to.meta.auth === 'public') {
return
}
const session = useSessionStore()
const settled =
session.status === 'idle' || session.status === 'checking'
? session.check()
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
if (destination !== true) {
router.replace(destination)
}
})
})
return router
}
const router = createAppRouter(createWebHistory(import.meta.env.BASE_URL))
export default router
+3
View File
@@ -15,6 +15,7 @@ import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
import EditorStatusBar from '@/components/EditorStatusBar.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
@@ -43,6 +44,8 @@ const seenImages = useSeenImagesStore()
const variantCatalog = useVariantCatalogStore()
const canManageVariants = useCanManageVariants()
usePageTitleOverride(() => article.value?.title)
const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
const article = ref<Article | null>(null)
+10 -1
View File
@@ -1,16 +1,25 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted } from 'vue'
import { useSessionStore } from '@/stores/session'
import DashboardShell from '@/components/DashboardShell.vue'
import AuthShell from '@/components/AuthShell.vue'
import ErrorView from '@/components/ErrorView.vue'
const session = useSessionStore()
const resolving = computed(() => session.status === 'idle' || session.status === 'checking')
onMounted(() => {
if (session.status === 'idle') {
session.check()
}
})
</script>
<template>
<DashboardShell v-if="session.session">
<template v-if="resolving" />
<DashboardShell v-else-if="session.session">
<ErrorView kind="not-found" :breadcrumb="t('Not found')" />
</DashboardShell>
<AuthShell v-else :title="t('Page not found')">
+3 -4
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref, watchEffect } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import AuthShell from '@/components/AuthShell.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, useApi } from '@/composables/useApi'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
@@ -27,9 +28,7 @@ onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
})
watchEffect(() => {
document.title = t('Check your email · RenewCMS')
})
usePageTitleOverride(() => t('Check your email'))
const description = computed(() =>
pendingEmail.value
+5 -5
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
@@ -26,9 +27,9 @@ const passwordTouched = ref(false)
const submitting = ref(false)
const passwordShown = ref(false)
const registrationClosed = ref(false)
watchEffect(() => {
if (registrationClosed.value) document.title = t('This site already has an owner · RenewCMS')
})
usePageTitleOverride(() =>
registrationClosed.value ? t('This site already has an owner') : undefined,
)
const serverErrorAlert = ref(false)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
@@ -91,7 +92,6 @@ async function onSubmit() {
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
registrationClosed.value = true
document.title = t('This site already has an owner · RenewCMS')
return
}
serverErrorAlert.value = true
@@ -0,0 +1,316 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory } from 'vue-router'
import uiPlugin from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import { createAppRouter } from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
type Handler = () => Response | Promise<Response>
const NEVER = new Promise<Response>(() => undefined)
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function article(title: string) {
return {
id: 42,
title,
body: '<p>Body</p>',
images: [],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-03-12T09:14:00Z',
}
}
let wrapper: ReturnType<typeof mount> | null = null
let calls: string[] = []
function installFetch(handlers: Record<string, Handler> = {}) {
calls = []
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${url}`
calls.push(key)
const handler = handlers[key] ?? handlers[key.split('?')[0]]
return handler ? handler() : NEVER
}) as unknown as typeof fetch
}
async function boot(path: string, signedIn: boolean, handlers: Record<string, Handler> = {}) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = signedIn ? { verified: true, username: 'marta', displayName: '' } : null
session.status = 'ready'
installFetch(handlers)
const router = createAppRouter(createMemoryHistory())
await router.push(path)
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await flushPromises()
return { router, session }
}
beforeEach(() => {
setLocale('en')
document.title = ''
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
setLocale('en')
})
describe('Back-office pages show their name', () => {
it.each([
['/home', 'Home · RenewCMS'],
['/article', 'Articles · RenewCMS'],
['/article/new', 'Articles · RenewCMS'],
['/media', 'Media · RenewCMS'],
['/users', 'Users · RenewCMS'],
['/activity', 'Activity · RenewCMS'],
['/profile', 'Profile · RenewCMS'],
['/integration', 'Integration · RenewCMS'],
['/integration/api-keys', 'Integration · RenewCMS'],
['/settings', 'Settings · RenewCMS'],
['/settings/media/variants', 'Settings · RenewCMS'],
['/settings/media/cache', 'Settings · RenewCMS'],
])('%s', async (path, title) => {
await boot(path, true)
expect(document.title).toBe(title)
})
})
describe('Auth pages show the card heading they display', () => {
it.each([
['/login', 'Log in · RenewCMS'],
['/register', 'Create the owner account · RenewCMS'],
['/register/reset/request', 'Reset your password · RenewCMS'],
['/register/reset/validate?c=CODE&email=EMAIL', 'Choose a new password · RenewCMS'],
])('%s', async (path, title) => {
await boot(path, false)
expect(document.title).toBe(title)
})
it('/invite/accept?c=CODE', async () => {
await boot('/invite/accept?c=CODE', false, {
'GET /v1/auth/invite': () => mockResponse(200, { email: 'a@b.example' }),
})
expect(document.title).toBe('Accept your invite · RenewCMS')
})
it('/register/pending', async () => {
await boot('/register/pending', true)
expect(document.title).toBe('Check your email · RenewCMS')
})
it('/register/validate?c=CODE without a session', async () => {
await boot('/register/validate?c=CODE', false, {
'POST /v1/auth/verify': () => mockResponse(401),
})
expect(document.title).toBe('Log in to finish verifying · RenewCMS')
})
})
describe('Auth card title edge cases', () => {
it('An auth card with no heading uses the product name', async () => {
await boot('/register/validate?c=CODE', false)
expect(document.title).toBe('RenewCMS')
})
it('Auth card heading changes inside one route', async () => {
let reject: (response: Response) => void = () => undefined
await boot('/register/validate?c=CODE', false, {
'POST /v1/auth/verify': () => new Promise<Response>((resolve) => (reject = resolve)),
})
expect(document.title).toBe('RenewCMS')
reject(mockResponse(401))
await flushPromises()
expect(document.title).toBe('Log in to finish verifying · RenewCMS')
})
})
describe('Title follows navigation', () => {
it('Title updates when the page changes', async () => {
const { router } = await boot('/home', true)
expect(document.title).toBe('Home · RenewCMS')
await router.push('/media')
await flushPromises()
expect(document.title).toBe('Media · RenewCMS')
await router.push('/home')
await flushPromises()
expect(document.title).toBe('Home · RenewCMS')
})
it('Leaving the login screen does not leave "Log in" stuck', async () => {
const { router, session } = await boot('/login', false)
expect(document.title).toBe('Log in · RenewCMS')
session.session = { verified: true, username: 'marta', displayName: '' }
await router.push('/home')
await flushPromises()
expect(document.title).toBe('Home · RenewCMS')
})
it('Logging out does not keep the back-office title', async () => {
const { router, session } = await boot('/settings', true)
expect(document.title).toBe('Settings · RenewCMS')
session.session = null
await router.push('/login')
await flushPromises()
expect(document.title).toBe('Log in · RenewCMS')
})
})
describe('Editor tab', () => {
it("Editor tab shows the article's own title once loaded", async () => {
let resolve: (response: Response) => void = () => undefined
await boot('/article/42/edit', true, {
'GET /v1/article/42': () => new Promise<Response>((done) => (resolve = done)),
})
expect(document.title).toBe('Editor · RenewCMS')
resolve(mockResponse(200, article('Hello world')))
await flushPromises()
expect(document.title).toBe('Hello world · RenewCMS')
})
it('Editor tab follows the saved article title', async () => {
let current = article('Hello world')
const { router } = await boot('/article/42/edit', true, {
'GET /v1/article/42': () => mockResponse(200, current),
})
expect(document.title).toBe('Hello world · RenewCMS')
current = article('Hello again')
const editor = wrapper!.findComponent({ name: 'EditorView' })
const exposed = editor.vm as unknown as {
$: { setupState: { article: { title: string } } }
}
exposed.$.setupState.article = { ...exposed.$.setupState.article, title: 'Hello again' }
await flushPromises()
expect(document.title).toBe('Hello again · RenewCMS')
expect(router.currentRoute.value.path).toBe('/article/42/edit')
})
it.each([
['the article fails to load', () => mockResponse(500, { error: 'boom' })],
['the article has an empty title', () => mockResponse(200, article(''))],
])('Editor tab falls back to "Editor" when %s', async (_situation, handler) => {
await boot('/article/42/edit', true, { 'GET /v1/article/42': handler })
expect(document.title).toBe('Editor · RenewCMS')
})
it('Leaving the editor drops the article title', async () => {
const { router } = await boot('/article/42/edit', true, {
'GET /v1/article/42': () => mockResponse(200, article('Hello world')),
})
expect(document.title).toBe('Hello world · RenewCMS')
await router.push('/article')
await flushPromises()
expect(document.title).toBe('Articles · RenewCMS')
})
})
describe('Not-found page', () => {
it('signed in', async () => {
await boot('/nowhere/at/all', true)
expect(document.title).toBe('404 · RenewCMS')
})
it('signed out', async () => {
await boot('/nowhere/at/all', false)
expect(document.title).toBe('404 · RenewCMS')
})
})
describe('Title follows the interface language', () => {
it('re-translates without a navigation', async () => {
setLocale('fr')
const { router } = await boot('/media', true)
expect(document.title).toMatch(/ · RenewCMS$/)
expect(document.title).not.toBe('Media · RenewCMS')
const path = router.currentRoute.value.fullPath
setLocale('en')
await flushPromises()
expect(document.title).toBe('Media · RenewCMS')
expect(router.currentRoute.value.fullPath).toBe(path)
})
})
describe('Not-found layout', () => {
it('renders neither shell while the session check is pending', async () => {
setActivePinia(createPinia())
installFetch()
const router = createAppRouter(createMemoryHistory())
await router.push('/nowhere/at/all')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await flushPromises()
expect(useSessionStore().status).toBe('checking')
expect(wrapper.find('[data-testid="auth-card"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Page not found')
})
it('shows the dashboard 404 for a signed-in user once the check resolves', async () => {
setActivePinia(createPinia())
installFetch({
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: true, username: 'marta', display_name: 'Marta' }),
})
const router = createAppRouter(createMemoryHistory())
await router.push('/nowhere/at/all')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await flushPromises()
expect(wrapper.find('[data-testid="auth-card"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Not found')
expect(document.title).toBe('404 · RenewCMS')
})
})
describe('Closed registration', () => {
it('shows the owner-exists title after a 403 on submit', async () => {
await boot('/register', false, {
'POST /v1/auth/register': () => mockResponse(403),
})
const inputs = wrapper!.findAll('input')
await inputs[0].setValue('marta')
await inputs[1].setValue('marta@renew.example')
await inputs[2].setValue('limewash2026')
await wrapper!.find('form').trigger('submit')
await flushPromises()
expect(wrapper!.find('form').exists()).toBe(false)
expect(document.title).toBe('This site already has an owner · RenewCMS')
})
})
describe('No call is made only to set the title', () => {
it('fetches only the registration status on /login', async () => {
await boot('/login', false)
expect(calls).toEqual(['GET /v1/auth/registration'])
})
})