feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading
This commit is contained in:
1 parent
fe254112a2
commit
0632bf53cb
12 files changed
+560
-52
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+15
-6
@@ -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,6 +163,10 @@ export function resolveAuthRedirect(
|
||||
return true
|
||||
}
|
||||
|
||||
export function createAppRouter(history: RouterHistory) {
|
||||
const router = createRouter({ history, routes })
|
||||
installDocumentTitle(router)
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.auth === 'public') {
|
||||
return true
|
||||
@@ -200,4 +204,9 @@ router.afterEach((to) => {
|
||||
})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
const router = createAppRouter(createWebHistory(import.meta.env.BASE_URL))
|
||||
|
||||
export default router
|
||||
@@ -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)
|
||||
|
||||
@@ -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')">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user