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
+564 -56

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
Feature: Browser tab title names the current page
Background:
Given the SPA is mounted with its router
And the tab title format is "<page> · RenewCMS" for a page that has a name
And the tab title is just "RenewCMS" when the current page has no name at all
And page names are translated to the interface language
And the title is set in one place for the whole SPA, not by each screen
And no /v1 call is made only to set the title
Scenario Outline: Back-office pages show their name
Given a signed-in verified user
When the user navigates to <path>
Then the tab title is "<title>"
Examples:
| path | title |
| /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 |
Scenario Outline: Auth pages show the card heading they display
Given a signed-out visitor
When the visitor navigates to <path>
Then the tab title is "<title>"
Examples:
| path | title |
| /login | Log in · RenewCMS |
| /register | Create the owner account · RenewCMS |
| /register/pending | Check your email · RenewCMS |
| /register/reset/request | Reset your password · RenewCMS |
| /register/reset/validate?c=CODE&email=EMAIL | Choose a new password · RenewCMS |
| /invite/accept?c=CODE | Accept your invite · RenewCMS |
| /register/validate?c=CODE without a session | Log in to finish verifying · RenewCMS |
Scenario: An auth card with no heading uses the product name
Given the verify screen is verifying a code and its card shows no heading
Then the tab title is "RenewCMS"
Scenario: Auth card heading changes inside one route
Given an auth screen whose card heading changes between states
When the card heading changes
Then the tab title follows the new card heading
Scenario: Title updates when the page changes
Given a signed-in verified user on /home with the tab title "Home · RenewCMS"
When the user navigates to /media
Then the tab title is "Media · RenewCMS"
When the user navigates back to /home
Then the tab title is "Home · RenewCMS"
Scenario: Leaving the login screen does not leave "Log in" stuck
Given a signed-out visitor on /login with the tab title "Log in · RenewCMS"
When the visitor logs in successfully and is redirected to /home
Then the tab title is "Home · RenewCMS"
Scenario: Logging out does not keep the back-office title
Given a signed-in verified user on /settings
When the user logs out and is sent to /login
Then the tab title is "Log in · RenewCMS"
Scenario: Editor tab shows the article's own title once loaded
Given a signed-in verified user
When the user opens /article/42/edit and the article is still loading
Then the tab title is "Editor · RenewCMS"
When the article "Hello world" has loaded
Then the tab title is "Hello world · RenewCMS"
Scenario: Editor tab follows the saved article title
Given the user is editing the article "Hello world" at /article/42/edit
When the article title is saved as "Hello again"
Then the tab title is "Hello again · RenewCMS"
Scenario Outline: Editor tab falls back to "Editor"
Given a signed-in verified user
When the user opens /article/42/edit and <situation>
Then the tab title is "Editor · RenewCMS"
Examples:
| situation |
| the article fails to load |
| the article has an empty title |
Scenario: Leaving the editor drops the article title
Given the user is on /article/42/edit with the tab title "Hello world · RenewCMS"
When the user navigates to /article
Then the tab title is "Articles · RenewCMS"
Scenario: Not-found page
Given any user
When the user navigates to an unknown path
Then the tab title is "404 · RenewCMS"
Scenario: Title follows the interface language
Given the interface language is Français
When the user navigates to /media
Then the tab title is the French "Media" name followed by " · RenewCMS"
When the user switches the interface language to English
Then the tab title is "Media · RenewCMS" without a navigation
+23
View File
@@ -0,0 +1,23 @@
# QA: document title (SPA, Vitest only)
## Automated (Vitest + Vue Test Utils, real route table, memory history, mocked /v1)
Each test resets `document.title` to "" in `beforeEach`.
1. Back-office rows: with `/v1` user mocked as signed in and verified, push each path from the first Examples table, await `router.isReady()`, assert `document.title`.
2. Auth rows: with the session mocked as signed out (401), push each path of the auth Examples table and assert the title equals the card heading plus " · RenewCMS". For the verify noSession row, mock a verify attempt with no session.
3. Verify with a code and no heading: assert `document.title === 'RenewCMS'`.
4. Auth heading change: drive an auth screen through two states with different headings (e.g. reset request default to the incomplete/expired variant) and assert the title follows.
5. Navigation sequence: /home, /media, /home, asserting after each push.
6. Login redirect: from /login simulate a successful login so the guard redirects to /home; assert "Home · RenewCMS".
7. Logout: from /settings trigger logout; assert "Log in · RenewCMS".
8. Editor: mock GET article 42 with a pending promise, assert "Editor · RenewCMS"; resolve with title "Hello world", assert "Hello world · RenewCMS". Save with title "Hello again" and assert the update. Repeat with a failing GET and with an empty title, expecting "Editor · RenewCMS". Navigate to /article and assert "Articles · RenewCMS".
9. Not found: push an unknown path, assert "404 · RenewCMS".
10. Locale: set `fr`, push /media, assert the French name plus " · RenewCMS"; switch to `en` and assert "Media · RenewCMS" without a navigation.
11. Update `web/src/components/__tests__/AuthShell.spec.ts` if its title expectations no longer hold once the title is set in one place.
## Visual
None. The tab title is not drawn on any board (skip: not-visual).
## Manual
- In a real browser, log in from /login and confirm the tab reads "Home · RenewCMS", then use the sidebar and the browser Back and Forward buttons and confirm the tab follows.
- Open an article in the editor, edit its title, and confirm the tab and the history entry follow.
+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',
+19 -10
View File
@@ -1,16 +1,17 @@
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: [
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/views/RootView.vue'),
@@ -119,10 +120,9 @@ const router = createRouter({
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
meta: { auth: 'any' },
meta: { auth: 'public', documentTitle: '404' },
},
],
})
]
export function resolveAuthRedirect(
to: RouteLocationNormalized,
@@ -163,7 +163,11 @@ export function resolveAuthRedirect(
return true
}
router.beforeEach((to) => {
export function createAppRouter(history: RouterHistory) {
const router = createRouter({ history, routes })
installDocumentTitle(router)
router.beforeEach((to) => {
if (to.meta.auth === 'public') {
return true
}
@@ -179,9 +183,9 @@ router.beforeEach((to) => {
}
return resolveAuthRedirect(to, session)
})
})
router.afterEach((to) => {
router.afterEach((to) => {
if (to.meta.auth === 'public') {
return
}
@@ -198,6 +202,11 @@ router.afterEach((to) => {
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'])
})
})