feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)

This commit is contained in:
Florian Sylvain committed 2026-10-15 05:25:44 +02:00
1 parent 3d81d2c717
commit 6b8990c55e
239 files changed
+9194 -1354

No files matched your search

+3 -1
View File
@@ -135,7 +135,9 @@ function goHome() {
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-45 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<h1 class="text-xl font-semibold text-highlighted">
{{ title }}
</h1>
<p class="text-muted">{{ subtitle }}</p>
</div>
<ActivityExportMenu
+1 -4
View File
@@ -354,10 +354,7 @@ onMounted(loadKeys)
v-show="!anyDialogOpen"
class="sticky bottom-0 z-10 -mx-4 mt-4 border-t border-default bg-default p-4"
>
<UButton
class="h-12 w-full justify-center text-[0.9375rem]"
@click="openCreateModal"
>
<UButton class="h-12 w-full justify-center text-[0.9375rem]" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</div>
+95 -265
View File
@@ -3,22 +3,21 @@ import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { ApiError, useApi } from '@/composables/useApi'
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
import { useArticleRowActions } from '@/composables/useArticleRowActions'
import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
import { useSites, type Site } from '@/composables/useSites'
import {
ARTICLE_PAGE_SIZES,
effectiveSort,
@@ -43,12 +42,11 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const { listSites } = useSites()
const items = ref<Article[]>([])
const sites = ref<Site[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
@@ -62,13 +60,13 @@ function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const sitesSettled = ref(false)
const hasLoaded = ref(false)
const loading = ref(true)
const loadFailed = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const showSkeleton = ref(false)
const busyIds = ref<Set<number>>(new Set())
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -115,6 +113,11 @@ const tabItems = computed(() => [
},
])
const severalSites = computed(() => sites.value.length > 1)
const siteName = computed(
() => sites.value.find((site) => site.key === shown.value.site)?.name ?? shown.value.site,
)
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const skeletonActive = computed(() => loading.value && showSkeleton.value)
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
@@ -132,6 +135,9 @@ const emptyVariant = computed(() => {
if (shown.value.status === 'online') {
return 'no-online' as const
}
if (shown.value.site) {
return 'no-site' as const
}
return 'no-articles' as const
})
@@ -191,10 +197,15 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
response.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
await load({ ...query, page: lastPage }, 'replace', {
kind: 'range',
})
return
}
const effective = { ...query, page: response.total === 0 ? 1 : query.page }
const effective = {
...query,
page: response.total === 0 ? 1 : query.page,
}
items.value = response.items
total.value = response.total
counts.value = response.counts
@@ -240,8 +251,22 @@ function changeStatus(status: '' | ArticleStatus) {
load({ ...desired.value, status, page: 1 }, 'push')
}
function changeSite(site: string) {
if (site === desired.value.site) {
return
}
load({ ...desired.value, site, page: 1 }, 'push')
}
function changeSort(field: ArticleSortField) {
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
load(
{
...desired.value,
sort: nextSort(desired.value.sort, field),
page: 1,
},
'push',
)
}
function changeSearch(value: string) {
@@ -252,6 +277,19 @@ function changeSearch(value: string) {
load({ ...desired.value, q, page: 1 }, 'replace')
}
async function fetchSites() {
try {
sites.value = await listSites()
} catch {
sites.value = []
}
}
function usableSite(query: ArticleListQuery): ArticleListQuery {
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
return query.site && !known ? { ...query, site: '' } : query
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
@@ -266,7 +304,10 @@ watch(
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const next = ownReplace ? parseArticleQuery(route.query) : parseWithStoredSize(route.query)
const parsed = ownReplace
? parseArticleQuery(route.query)
: parseWithStoredSize(route.query)
const next = sitesSettled.value ? usableSite(parsed) : parsed
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
@@ -279,249 +320,14 @@ watch(
},
)
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
next.add(id)
} else {
next.delete(id)
}
busyIds.value = next
}
function shiftCounts(article: Article, delta: number) {
const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
load({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) => (existing.id === updated.id ? updated : existing))
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
}
function notFoundToast(article: Article) {
return {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
get title() {
return t('That article no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the list.')
},
}
}
async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
replaceArticle(updated, article)
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [article.title])
: t('\u0022{0}\u0022 is now offline.', [article.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [
{
get label() {
return t('Retry')
},
onClick: () => setOnline(article, makeOnline),
},
],
}))
} finally {
setBusy(article.id, false)
}
}
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
}
function handlePublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = findItem(id)
if (!article) {
return
}
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add(() => ({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [article.title])
},
}))
} catch {
toast.add(() => ({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't copy the link")
},
}))
}
}
function deleteDescription(article: Article) {
const count = bodyImageCount(article.body)
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.title,
imagesPhrase,
])
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article)
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.title])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
await nextTick()
const index = idsAtOpen.indexOf(deletedId)
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
if (nextId != null) {
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
return
}
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
}
async function handleDelete(article: Article) {
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription(article)
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action: () => performDelete(article),
},
})
.open()
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
}
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
useArticleRowActions({
items,
total,
counts,
shown,
reload: load,
})
function openNewArticleModal() {
newArticleModalOpen.value = true
@@ -541,6 +347,10 @@ function clearSearch() {
}
function onEmptyAction() {
if (emptyVariant.value === 'no-site') {
changeSite('')
return
}
if (emptyVariant.value === 'no-results') {
clearSearch()
return
@@ -552,7 +362,18 @@ function onEmptyAction() {
openNewArticleModal()
}
onMounted(() => load(shown.value, 'replace'))
onMounted(async () => {
const siteRequest = fetchSites().finally(() => {
sitesSettled.value = true
})
if (shown.value.site) {
await siteRequest
const usable = usableSite(shown.value)
shown.value = usable
desired.value = usable
}
load(shown.value, 'replace')
})
onBeforeUnmount(() => {
requestToken++
@@ -595,14 +416,22 @@ onBeforeUnmount(() => {
:content="false"
:ui="{ list: 'gap-5', trigger: 'px-0' }"
/>
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<ArticleSiteFilter
v-if="severalSites"
:model-value="desired.site"
:sites="sites"
@update:model-value="changeSite"
/>
</div>
</div>
<UAlert
@@ -638,7 +467,7 @@ onBeforeUnmount(() => {
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="shown.q"
:query="emptyVariant === 'no-site' ? siteName : shown.q"
@action="onEmptyAction"
/>
<template v-else>
@@ -647,6 +476,7 @@ onBeforeUnmount(() => {
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
:sort="effectiveSort(shown.sort)"
:sites="severalSites ? sites : []"
:busy-ids="busyIds"
@sort="changeSort"
@publish="handlePublish"
+17 -4
View File
@@ -274,7 +274,10 @@ function replaceItem(author: Author) {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id
? { ...author, user_email: author.user_email ?? item.user_email }
? {
...author,
user_email: author.user_email ?? item.user_email,
}
: item,
),
}
@@ -285,7 +288,11 @@ function removeItem(id: number) {
return
}
const remaining = result.value.items.filter((item) => item.id !== id)
result.value = { ...result.value, items: remaining, total: Math.max(0, result.value.total - 1) }
result.value = {
...result.value,
items: remaining,
total: Math.max(0, result.value.total - 1),
}
if (remaining.length === 0 && result.value.total > 0) {
load(query.value)
}
@@ -463,7 +470,11 @@ function confirmDelete() {
async function creditedCount(target: Author) {
try {
const found = await listAuthors({ q: target.slug, page: 1, size: LOOKUP_SIZE })
const found = await listAuthors({
q: target.slug,
page: 1,
size: LOOKUP_SIZE,
})
return (
found.items.find((item) => item.id === target.id)?.article_count || target.article_count
)
@@ -548,7 +559,9 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Authors') }}</h1>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Authors') }}
</h1>
<p class="text-muted">{{ description }}</p>
</div>
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
+97 -211
View File
@@ -2,6 +2,7 @@
import { t } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -9,6 +10,7 @@ import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ArticleDetails from '@/components/ArticleDetails.vue'
import MetaSection from '@/components/MetaSection.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
@@ -18,14 +20,16 @@ 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 { useArticleActions } from '@/composables/useArticleActions'
import { useArticleDraft } from '@/composables/useArticleDraft'
import type { MediaItem } from '@/composables/useMedia'
import { useEditorImages } from '@/composables/useEditorImages'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site } from '@/composables/useSites'
import { toBylines } from '@/lib/articleMeta'
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
import { bodyCounts } from '@/lib/bodyCounts'
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
import { formatSize } from '@/lib/mediaFormat'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
import { useSeenImagesStore } from '@/stores/seenImages'
import { useVariantCatalogStore } from '@/stores/variantCatalog'
const SKELETON_DELAY_MS = 150
@@ -37,10 +41,24 @@ const router = useRouter()
const { request } = useApi()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const directoryToasts = useDirectoryToasts()
const { listSites } = useSites()
const actions = useArticleActions()
const {
editorRef,
imageUploaderOpen,
imageUploaderFile,
imageIdsInArticle,
onRequestImage,
onEditorDrop,
onImageUploaded,
onImageInserted,
onRequestRemoveImage,
onImageUploaderClosed,
} = useEditorImages(() => draft.body.value)
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
const seenImages = useSeenImagesStore()
const variantCatalog = useVariantCatalogStore()
const canManageVariants = useCanManageVariants()
@@ -50,12 +68,11 @@ const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
const article = ref<Article | null>(null)
const previewOn = ref(false)
const [DefineDetailsPanel, DetailsPanel] = createReusableTemplate()
const detailsOpen = ref(false)
const publishPending = ref<string | undefined>(undefined)
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
const imageUploaderOpen = ref(false)
const imageUploaderFile = ref<File | null>(null)
const imageIdsInArticle = ref<number[]>([])
const sites = ref<Site[]>([])
const remembered = ref<boolean | null>(readMetaSectionOpen())
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
@@ -65,6 +82,19 @@ function clearSkeletonTimer() {
}
}
async function fetchSites() {
try {
return await listSites()
} catch {
return []
}
}
function rememberMetaSection(open: boolean) {
remembered.value = open
storeMetaSectionOpen(open)
}
async function loadArticle() {
loadState.value = 'loading'
showSkeleton.value = false
@@ -74,9 +104,16 @@ async function loadArticle() {
}, SKELETON_DELAY_MS)
try {
const result = await request<Article>(`/v1/article/${articleId}?format=html`)
const [result, siteList] = await Promise.all([
request<Article>(`/v1/article/${articleId}?format=html`),
fetchSites(),
])
article.value = result
draft.load(result.body, new Date(result.updated_at))
sites.value = siteList
draft.load(result.body, new Date(result.updated_at), {
siteId: result.site_id ?? null,
authors: toBylines(result.authors ?? []),
})
loadState.value = 'loaded'
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
variantCatalog.load()
@@ -120,6 +157,13 @@ const publishDisabled = computed(() => draft.state.value === 'offline')
async function runSave() {
const outcome = await draft.save()
if (outcome === 'site-gone') {
directoryToasts.siteGone()
sites.value = await fetchSites()
}
if (outcome === 'author-gone') {
directoryToasts.authorGone()
}
if (outcome === 'not-found') {
toast.add(() => ({
color: 'error',
@@ -155,49 +199,15 @@ async function setOnline(makeOnline: boolean) {
}
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try {
const updated = await request<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
article.value = updated
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
await actions.publish(article.value, makeOnline, {
onUpdated: (updated) => {
article.value = updated
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [updated.title])
: t('\u0022{0}\u0022 is now offline.', [updated.title])
onGone: () => {
loadState.value = 'notFound'
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [
{
get label() {
return t('Retry')
},
onClick: () => setOnline(makeOnline),
},
],
}))
retry: () => setOnline(makeOnline),
})
} finally {
publishPending.value = undefined
}
@@ -211,167 +221,19 @@ function handleUnpublish() {
setOnline(false)
}
function deleteDescription() {
const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.value?.title ?? '',
imagesPhrase,
])
}
async function performDelete() {
try {
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
toast.add(() => ({
id: `article-deleted-${articleId}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.value?.title ?? ''])
},
}))
router.push('/article')
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
async function handleDelete() {
if (!article.value) {
const current = article.value
if (!current) {
return
}
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription()
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action: performDelete,
await actions.confirmDelete(current, () =>
actions.deleteArticle(current, {
onDeleted: () => router.push('/article'),
onGone: () => {
loadState.value = 'notFound'
},
})
.open()
}
function openImageUploader(file: File | null) {
imageUploaderFile.value = file
imageIdsInArticle.value = bodyImageIds(draft.body.value)
imageUploaderOpen.value = true
}
function onRequestImage() {
openImageUploader(null)
}
function onEditorDrop(event: DragEvent) {
const file = event.dataTransfer?.files?.[0]
if (!file) {
return
}
openImageUploader(file)
}
function announceImageAdded() {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image added')
},
get description() {
return t('Save to keep it in the article.')
},
}))
}
function onImageUploaded(item: MediaItem) {
seenImages.remember([item])
editorRef.value?.insertImage(item.url, '', item.id)
announceImageAdded()
}
function onImageInserted(item: MediaItem, alt: string, src: string) {
seenImages.remember([item])
editorRef.value?.insertImage(src, alt, item.id)
announceImageAdded()
}
async function performRemoveImage(imageId: number) {
editorRef.value?.removeImage(imageId)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image removed')
},
get description() {
return t("It's still in your library.")
},
}))
}
function removalPreview(imageId: number) {
const seen = seenImages.find(imageId)
return {
src: editorRef.value?.imageSrc(imageId) ?? '',
filename: seen?.filename,
size: seen ? formatSize(seen.size) : undefined,
}
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Remove this image from the article?')
},
get description() {
return t(
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
},
get confirmLabel() {
return t('Remove image')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Removing…')
},
tone: 'neutral',
preview: removalPreview(imageId),
action: () => performRemoveImage(imageId),
},
})
.open()
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}),
)
}
function onKeydown(event: KeyboardEvent) {
@@ -460,6 +322,23 @@ onBeforeRouteLeave(async () => {
/>
</template>
<DefineDetailsPanel>
<ArticleDetails v-if="article" :article="article" v-model:open="detailsOpen">
<template #meta="{ sheet }">
<MetaSection
:sites="sites"
:site-id="draft.meta.value.siteId"
:authors="draft.meta.value.authors"
:remembered="remembered"
:sheet="sheet"
@update:site-id="draft.editMeta({ siteId: $event })"
@update:authors="draft.editMeta({ authors: $event })"
@update:remembered="rememberMetaSection"
/>
</template>
</ArticleDetails>
</DefineDetailsPanel>
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
@@ -499,6 +378,13 @@ onBeforeRouteLeave(async () => {
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
data-testid="skeleton-details"
>
<div class="flex flex-col gap-3 border-b border-default pb-4">
<div class="flex min-h-6 items-center gap-2 text-highlighted">
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
<span class="text-sm font-semibold">{{ t('Site and authors') }}</span>
</div>
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
</div>
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-3/5 rounded-none" />
@@ -594,7 +480,7 @@ onBeforeRouteLeave(async () => {
<div
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
>
<ArticleDetails :article="article" v-model:open="detailsOpen" />
<DetailsPanel />
</div>
</div>
<div v-else class="min-h-0 flex-1" @dragover.prevent @drop.prevent="onEditorDrop">
@@ -617,7 +503,7 @@ onBeforeRouteLeave(async () => {
</template>
<template #aside>
<div class="h-full border-l border-default bg-default p-5">
<ArticleDetails :article="article" v-model:open="detailsOpen" />
<DetailsPanel />
</div>
</template>
</ArticleEditor>
+16 -5
View File
@@ -35,7 +35,10 @@ const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
...API_GROUPS.map((group) => ({
id: group.key,
label: translate(group.title),
})),
{ id: 'images', label: t('Images and variants') },
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
@@ -204,7 +207,9 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
aria-labelledby="h-auth"
:class="[SECTION_CLASS, 'gap-3.5']"
>
<h2 id="h-auth" :class="SECTION_TITLE">{{ t('Authentication') }}</h2>
<h2 id="h-auth" :class="SECTION_TITLE">
{{ t('Authentication') }}
</h2>
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<div :class="AUTH_CARD">
<div class="flex items-center gap-2">
@@ -302,7 +307,9 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
<h2 id="h-session" :class="SECTION_TITLE">
{{ t('Session only') }}
</h2>
<span class="text-muted">{{
t('{0} routes, made for a browser session', [sessionRouteCount])
}}</span>
@@ -311,7 +318,9 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</section>
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
<h2 id="h-errors" :class="SECTION_TITLE">{{ t('Errors') }}</h2>
<h2 id="h-errors" :class="SECTION_TITLE">
{{ t('Errors') }}
</h2>
<span class="text-pretty text-toned">{{
t(
'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.',
@@ -360,7 +369,9 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
<h2 id="h-notes" :class="SECTION_TITLE">{{ t('Notes') }}</h2>
<h2 id="h-notes" :class="SECTION_TITLE">
{{ t('Notes') }}
</h2>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
+4
View File
@@ -4,6 +4,7 @@ import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import AuthorProfileCard from '@/components/AuthorProfileCard.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -22,6 +23,7 @@ const PASSWORD_MASK = '••••••••••••'
const SKELETON_CARDS = [
{ titleWidth: 'w-30', inputs: 1 },
{ titleWidth: 'w-22.5', inputs: 2 },
{ titleWidth: 'w-28', inputs: 1 },
{ titleWidth: 'w-25', inputs: 1 },
]
@@ -397,6 +399,8 @@ onBeforeRouteLeave(async () => {
</div>
</ProfileCard>
<AuthorProfileCard />
<ProfileCard
:title="t('Password')"
:description="
+1 -5
View File
@@ -10,9 +10,5 @@ function onRetry() {
</script>
<template>
<ErrorView
v-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
</template>
+10 -3
View File
@@ -208,7 +208,10 @@ function upsert(site: Site) {
}
function markDefault(id: number) {
sites.value = sites.value.map((site) => ({ ...site, is_default: site.id === id }))
sites.value = sites.value.map((site) => ({
...site,
is_default: site.id === id,
}))
}
function removeSite(id: number) {
@@ -404,8 +407,12 @@ function backToSettings() {
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Sites') }}</h1>
<p class="text-muted">{{ t('The frontends that display your content.') }}</p>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Sites') }}
</h1>
<p class="text-muted">
{{ t('The frontends that display your content.') }}
</p>
</div>
<DirectoryAddButton v-if="isDesktop" @click="openAdd">
{{ t('Add site') }}
+5 -1
View File
@@ -134,7 +134,11 @@ function openEdit(name: string) {
return
}
rememberOpener(rowMenuButton(name))
formTarget.value = { mode: 'edit', initial: valuesFromVariant(variant), variant }
formTarget.value = {
mode: 'edit',
initial: valuesFromVariant(variant),
variant,
}
}
function closeForm() {
@@ -396,7 +396,9 @@ describe('Failed lookup', () => {
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the alert and a Try again button when the lookup $name', async ({ handler }) => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: handler })
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
[lookupKey]: handler,
})
expectFailedLookup(wrapper)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -413,7 +415,9 @@ describe('Failed lookup', () => {
setTimeout(
() =>
resolve(
mockResponse(200, { email: 'lea.martin@renew.example' }),
mockResponse(200, {
email: 'lea.martin@renew.example',
}),
),
0,
),
@@ -98,7 +98,10 @@ describe('Cards: the toggle cycle keeps card, chip, segment and request in sync'
expect(findButton('Clear all')).toBeDefined()
expect(segmentSelection()).toBe(segment)
expect(callsTo(LIST_PATH)).toHaveLength(listCalls + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...query })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
...query,
})
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
await click(card(index))
@@ -145,7 +145,12 @@ describe('Details: content', () => {
[1, 'Body edited (+1 character)'],
[-48, 'Body edited (-48 characters)'],
])('words a body delta of %i', async (delta, text) => {
await openWith(makeItem({ id: 1, changes: [{ field: 'body', kind: 'body', delta }] }))
await openWith(
makeItem({
id: 1,
changes: [{ field: 'body', kind: 'body', delta }],
}),
)
expect(textOf(details()!.querySelector('[data-testid="activity-body-change"]'))).toBe(text)
})
@@ -153,8 +158,18 @@ describe('Details: content', () => {
it('labels the fields, reads online as Draft and Published, and keeps unknown codes', async () => {
const changes: ActivityChange[] = [
{ field: 'online', kind: 'text', before: 'false', after: 'true' },
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
{ field: 'display_name', kind: 'text', before: 'Sam', after: 'Samuel' },
{
field: 'filename',
kind: 'text',
before: 'a.png',
after: 'b.png',
},
{
field: 'display_name',
kind: 'text',
before: 'Sam',
after: 'Samuel',
},
{ field: 'tagline', kind: 'text', before: 'x', after: 'y' },
]
await openWith(makeItem({ id: 1, changes }))
@@ -196,7 +211,12 @@ describe('Details: content', () => {
before: LONG_VALUE,
after: `${LONG_VALUE}more`,
},
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
{
field: 'filename',
kind: 'text',
before: 'a.png',
after: 'b.png',
},
],
}),
)
@@ -373,7 +393,10 @@ describe('Details: content', () => {
describe('Details: Previous and Next across pages', () => {
function numbered(page: number) {
return Array.from({ length: 25 }, (_, index) =>
makeItem({ id: (page - 1) * 25 + index + 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({
id: (page - 1) * 25 + index + 1,
created_at: '2026-10-03T12:30:00Z',
}),
)
}
@@ -413,7 +436,9 @@ describe('Details: Previous and Next across pages', () => {
).toContain('Spring release')
await click(previous())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({ page: '1' })
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({
page: '1',
})
expect(footer()).toContain('Event 25 of 1,284')
expect(
rows()
@@ -424,7 +449,10 @@ describe('Details: Previous and Next across pages', () => {
it('disables Next on the very last event', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], { total: 2 }),
list: () =>
pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], {
total: 2,
}),
})
await openRow(1)
@@ -480,7 +508,9 @@ describe('Details below md: a bottom sheet', () => {
describe('Details: counts', () => {
it('counts positions against the filtered total', async () => {
await mountActivity({ list: () => pageOf(activityItems, { total: 41 }) })
await mountActivity({
list: () => pageOf(activityItems, { total: 41 }),
})
await openRow(0)
expect(textOf(panel())).toContain('Event 1 of 41')
+26 -7
View File
@@ -148,7 +148,9 @@ describe('Export menu: structure', () => {
})
it('lists both items with their sub-lines and total 1,284, and Escape closes it', async () => {
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1284 }) })
await mountActivity({
list: () => pageOf([makeItem({ id: 1 })], { total: 1284 }),
})
await openExportMenu()
@@ -162,7 +164,11 @@ describe('Export menu: structure', () => {
)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
}),
)
await flushPromises()
await vi.waitFor(() => expect(menuItems()).toHaveLength(0))
@@ -172,7 +178,9 @@ describe('Export menu: structure', () => {
it('states the filtered count and the singular form', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 1 })], { total: query.get('outcome') ? 6 : 1284 }),
pageOf([makeItem({ id: 1 })], {
total: query.get('outcome') ? 6 : 1284,
}),
})
await click(stripCards()[2])
@@ -184,7 +192,9 @@ describe('Export menu: structure', () => {
})
it('uses the singular for one event', async () => {
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1 }) })
await mountActivity({
list: () => pageOf([makeItem({ id: 1 })], { total: 1 }),
})
await openExportMenu()
@@ -199,7 +209,11 @@ describe('Export menu: structure', () => {
button.focus()
button.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
@@ -281,7 +295,10 @@ describe('Export current view', () => {
await vi.waitFor(() => expect(clicked).toHaveLength(1))
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({
category: 'auth',
range: '30d',
})
expect(clicked[0]!.download).toMatch(CURRENT_FILE)
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(toastCall()).toEqual({
@@ -455,7 +472,9 @@ describe('Export all: the dialog', () => {
})
it('aborts the request on Cancel while preparing, with no download and no toast', async () => {
await mountActivity({ exportFile: () => new Promise<Blob>(() => undefined) })
await mountActivity({
exportFile: () => new Promise<Blob>(() => undefined),
})
await openDialog()
await submitDialog()
+76 -18
View File
@@ -93,7 +93,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
list: (query) => pageOf(activityItems, { page: Number(query.get('page')) }),
})
await goToPage(3)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '3' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
page: '3',
})
}
it('types "spring" and waits 300 ms', async () => {
@@ -108,7 +111,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
vi.advanceTimersByTime(1)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
q: 'spring',
})
})
it('types "spring" and presses Enter at once', async () => {
@@ -119,7 +125,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
q: 'spring',
})
vi.advanceTimersByTime(500)
await flushPromises()
@@ -135,7 +144,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
harness.wrapper!.findComponent(ActivityPersonSelect).vm.$emit('select', value, label)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...expected })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
...expected,
})
expect(chipTexts()).toEqual([`Person: ${label}`])
})
@@ -144,13 +156,19 @@ describe('Filter bar: each control sends its query parameter and resets the page
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'users')
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, category: 'users' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
category: 'users',
})
})
it('chooses Outcome "Succeeded", then "All"', async () => {
await onPage3()
await clickIn('Outcome', 'Succeeded')
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'success' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
outcome: 'success',
})
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
await clickIn('Outcome', 'All')
@@ -165,7 +183,9 @@ describe('Filter bar: each control sends its query parameter and resets the page
await clickIn('Dates', label)
expect(queryObject(lastListQuery())).toEqual(expected)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: expected.range })
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({
range: expected.range,
})
})
})
@@ -192,8 +212,16 @@ describe('Filter bar: custom dates', () => {
})
await flushPromises()
const expected = { range: 'custom', from: '2026-09-01', to: '2026-09-30' }
expect(queryObject(lastListQuery())).toEqual({ ...expected, page: '1', size: '25' })
const expected = {
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
}
expect(queryObject(lastListQuery())).toEqual({
...expected,
page: '1',
size: '25',
})
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual(expected)
expect(chipTexts()).toEqual(['Dates: 1 – 30 Sep 2026'])
})
@@ -238,7 +266,9 @@ describe('Chips', () => {
await clickButton('Clear all')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '7d' })
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({
range: '7d',
})
expect(chipTexts()).toEqual([])
expect(findButton('Clear all')).toBeUndefined()
expect(searchInput().value).toBe('')
@@ -272,7 +302,10 @@ describe('Strip cards apply their filter', () => {
await mountActivity()
await click(stripCards()[2])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'failed' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
outcome: 'failed',
})
expect(chipTexts()).toEqual(['Outcome: Failed'])
expect(findButton('Clear all')?.disabled).toBe(false)
expect(stripCards()[2]!.getAttribute('aria-pressed')).toBe('true')
@@ -283,7 +316,10 @@ describe('Strip cards apply their filter', () => {
await mountActivity()
await click(stripCards()[3])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
security: 'true',
})
expect(chipTexts()).toEqual(['Type: Security events'])
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
})
@@ -291,7 +327,9 @@ describe('Strip cards apply their filter', () => {
it('stacks the Failed card on other filters without asking for the summary again', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 3 })], { total: query.get('outcome') ? 6 : 1284 }),
pageOf([makeItem({ id: 3 })], {
total: query.get('outcome') ? 6 : 1284,
}),
})
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
@@ -304,7 +342,9 @@ describe('Strip cards apply their filter', () => {
'Dates: 30 days',
])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '30d' })
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({
range: '30d',
})
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–6 of 6',
)
@@ -322,8 +362,19 @@ describe('The Person menu', () => {
display_name: 'Marta Alves',
role: 'owner',
},
{ kind: 'user', id: 4, username: 'jonas', display_name: '', role: 'admin' },
{ kind: 'invite', id: 9, email: 'x@example.com', status: 'pending' },
{
kind: 'user',
id: 4,
username: 'jonas',
display_name: '',
role: 'admin',
},
{
kind: 'invite',
id: 9,
email: 'x@example.com',
status: 'pending',
},
],
keys: () => [{ id: 7, name: 'Website sync' }],
})
@@ -371,7 +422,10 @@ describe('Pagination', () => {
const pager = () => textOf(document.body.querySelector('[data-testid="activity-pager"]'))
await goToPage(2)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '2' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
page: '2',
})
expect(pager()).toContain('Showing 26–50 of 1,284')
const activePage = document.body.querySelector(
'[data-testid="activity-pager"] [aria-current="page"]',
@@ -380,7 +434,11 @@ describe('Pagination', () => {
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '1', size: '50' })
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
page: '1',
size: '50',
})
expect(pager()).toContain('Showing 1–50 of 1,284')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 25)
@@ -61,7 +61,10 @@ describe('Activity list remembers the chosen page size', () => {
await chooseSize(50)
expect(localStorage.getItem(KEY)).toBe('50')
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '50' })
expect(queryObject(lastListQuery())).toMatchObject({
page: '1',
size: '50',
})
expect(url()).toBe('/activity?size=50')
unmountActivity()
@@ -123,7 +126,10 @@ describe('Activity list remembers the chosen page size', () => {
expect(queryObject(lastListQuery()).size).toBe('25')
await chooseSize(100)
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '100' })
expect(queryObject(lastListQuery())).toMatchObject({
page: '1',
size: '100',
})
expect(url()).toBe('/activity?size=100')
})
})
+19 -4
View File
@@ -67,7 +67,11 @@ describe('My activity: a role that is not owner or admin sees its own events', (
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
expect(callsTo(SUMMARY_PATH)).toHaveLength(0)
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({
range: '7d',
page: '1',
size: '25',
})
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 41',
)
@@ -81,7 +85,10 @@ describe('My activity: a role that is not owner or admin sees its own events', (
})
it('shows its own empty copy', async () => {
await mountActivity({ role: 'editor', list: () => pageOf([], { total: 0 }) })
await mountActivity({
role: 'editor',
list: () => pageOf([], { total: 0 }),
})
expect(textOf(document.body)).toContain('No activity yet')
expect(textOf(document.body)).toContain(
@@ -90,7 +97,10 @@ describe('My activity: a role that is not owner or admin sees its own events', (
})
it('shows the same details panel with the user position', async () => {
await mountActivity({ role: 'editor', list: () => pageOf(activityItems, { total: 41 }) })
await mountActivity({
role: 'editor',
list: () => pageOf(activityItems, { total: 41 }),
})
rows()[0]!.click()
await flushPromises()
await vi.waitFor(() => expect(document.body.querySelector('[role="dialog"]')).toBeTruthy())
@@ -115,7 +125,12 @@ describe('The French interface', () => {
id: 2,
created_at: '2026-10-03T12:20:00Z',
outcome: 'failed',
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
target: {
type: 'article',
id: '8',
name: 'Spring release',
exists: true,
},
}),
]),
})
@@ -79,7 +79,11 @@ describe('The detail line under the sentence', () => {
],
[
'media.variant_generate_all',
{ generated: 3, failed: 2, causes: ['too_large', 'unsupported_format'] },
{
generated: 3,
failed: 2,
causes: ['too_large', 'unsupported_format'],
},
'3 generated · 2 failed (Image too large, Unsupported source format)',
],
[
@@ -102,8 +106,15 @@ describe('The detail line under the sentence', () => {
it('shows the line in the row, under the sentence', async () => {
const events = [
variantEvent({ params: { generated: 4, failed: 1, causes: ['too_large'] } }),
makeItem({ id: 2, action: 'media.rename', category: 'media', target: IMAGE }),
variantEvent({
params: { generated: 4, failed: 1, causes: ['too_large'] },
}),
makeItem({
id: 2,
action: 'media.rename',
category: 'media',
target: IMAGE,
}),
]
await mountActivity({ list: () => pageOf(events) })
@@ -147,11 +158,18 @@ describe('The French interface', () => {
'A généré toutes les variantes de north-wall.jpg',
)
expect(
sentenceText(variantEvent({ action: 'media.variant_purge_image', outcome: 'failed' })),
sentenceText(
variantEvent({
action: 'media.variant_purge_image',
outcome: 'failed',
}),
),
).toBe('Échec de la purge des variantes de north-wall.jpg')
expect(
detailLine(
variantEvent({ params: { generated: 4, failed: 1, causes: ['too_large'] } }),
variantEvent({
params: { generated: 4, failed: 1, causes: ['too_large'] },
}),
),
).toBe('4 générées · 1 en échec (Image trop grande)')
})
+31 -6
View File
@@ -89,7 +89,10 @@ describe('Navigation', () => {
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity' })
expect(router.currentRoute.value.meta).toEqual({
auth: 'verified',
title: 'Activity',
})
})
})
@@ -140,7 +143,11 @@ describe('Default: an owner sees the strip, the filter bar and a day-grouped tab
await mountActivity()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({
range: '7d',
page: '1',
size: '25',
})
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' })
})
@@ -223,7 +230,11 @@ describe('Rows: the Actor cell and the sentence follow the event', () => {
},
{
name: 'failed publish',
item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed' }),
item: makeItem({
id: 5,
actor: user(4, 'Inès Duarte'),
outcome: 'failed',
}),
actor: 'Inès Duarte',
sentence: 'Failed to publish the article Spring release',
badge: 'Failed',
@@ -370,7 +381,14 @@ describe('Rows: targets', () => {
it('renders an unknown action code as it is and still opens the details', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, action: 'article.archive', target: null })]),
list: () =>
pageOf([
makeItem({
id: 1,
action: 'article.archive',
target: null,
}),
]),
})
const row = rows()[0]!
@@ -486,7 +504,11 @@ describe('Strip', () => {
[40, 40, 'No change'],
])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => {
await mountActivity({
summary: () => ({ ...summaryDefault, total, previous_total: previous }),
summary: () => ({
...summaryDefault,
total,
previous_total: previous,
}),
})
expect(stripTexts()[0]).toContain(subline)
@@ -530,7 +552,10 @@ describe('Strip', () => {
describe('Empty states', () => {
it('shows No activity yet with Export disabled and no table or pager', async () => {
await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone })
await mountActivity({
list: () => pageOf([], { total: 0 }),
summary: () => summaryNone,
})
expect(pageText()).toContain('No activity yet')
expect(pageText()).toContain(
+39 -8
View File
@@ -205,11 +205,18 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username, id: 1, role: sessionRole }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username,
id: 1,
role: sessionRole,
}),
request: requestMock,
})
await router.push('/integration/api-keys')
const Host = defineComponent({ render: () => h(UApp, null, () => h(ApiKeysView)) })
const Host = defineComponent({
render: () => h(UApp, null, () => h(ApiKeysView)),
})
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
@@ -378,7 +385,10 @@ function alertText() {
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
@@ -618,7 +628,12 @@ describe('Create key: validation', () => {
it.each([
{ name: '', expiry: '2026-03-12', nameError: true, expiryError: true },
{ name: '', expiry: '', nameError: true, expiryError: false },
{ name: 'Deploy', expiry: '2026-03-12', nameError: false, expiryError: true },
{
name: 'Deploy',
expiry: '2026-03-12',
nameError: false,
expiryError: true,
},
])(
'name "$name" and expiry "$expiry" sends nothing',
async ({ name, expiry, nameError, expiryError }) => {
@@ -1007,8 +1022,16 @@ describe('Not drawn: Create sending', () => {
describe('Not drawn: Create failed', () => {
it.each([
{ name: 'status 500', failure: () => new ApiError(500), copy: CREATE_FAILED_COPY },
{ name: 'a network error', failure: () => new NetworkError(), copy: CONNECTION_COPY },
{
name: 'status 500',
failure: () => new ApiError(500),
copy: CREATE_FAILED_COPY,
},
{
name: 'a network error',
failure: () => new NetworkError(),
copy: CONNECTION_COPY,
},
{
name: 'status 429',
failure: () => new ApiError(429),
@@ -1069,8 +1092,16 @@ describe('Not drawn: Revoke sending', () => {
})
describe.each([
{ name: 'status 500', failure: () => new ApiError(500), copy: REVOKE_FAILED_COPY },
{ name: 'a network error', failure: () => new NetworkError(), copy: CONNECTION_COPY },
{
name: 'status 500',
failure: () => new ApiError(500),
copy: REVOKE_FAILED_COPY,
},
{
name: 'a network error',
failure: () => new NetworkError(),
copy: CONNECTION_COPY,
},
])('Not drawn: Revoke failed: $name', ({ failure, copy }) => {
it('shows the notice in the open dialog, leaves the row and lets a retry succeed', async () => {
let attempts = 0
+11 -2
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
@@ -58,9 +59,13 @@ function routeFor(url: string, init?: RequestInit) {
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
const routes = {
'GET /v1/site/': () => mockResponse(200, oneSite),
...handlers,
}
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
const handler = routes[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
@@ -157,7 +162,11 @@ async function clickTab(label: string) {
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
}
@@ -7,6 +7,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const KEY = 'renewcms-page-size-articles'
@@ -36,6 +37,14 @@ let wrapper: Awaited<ReturnType<typeof mount>> | null = null
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url === '/v1/site/') {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => oneSite,
} as unknown as Response)
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -8,6 +8,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
@@ -81,6 +82,9 @@ function respondFromFixture(params: EnvelopeParams) {
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url === '/v1/site/') {
return Promise.resolve(mockResponse(200, oneSite))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -252,7 +256,10 @@ describe('3 Last page', () => {
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
articles = buildArticles(6).map((article, index) => ({ ...article, is_online: index < 4 }))
articles = buildArticles(6).map((article, index) => ({
...article,
is_online: index < 4,
}))
const wrapper = await mountAt('/article')
expect(summary()).toBe('Showing 1–6 of 6')
@@ -463,14 +470,22 @@ describe('Keyboard, the page-size list', () => {
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
const focused = document.activeElement ?? document.body
focused.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
@@ -487,7 +502,11 @@ describe('Tabs filter on the server', () => {
)
tab?.focus()
tab?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
@@ -623,13 +642,23 @@ describe('8c Sort is kept across paging, page size and search', () => {
await click(control('Next page'))
expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ page: '2', sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({
page: '2',
sort: 'title:asc',
})
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ q: 'plaster', sort: 'title:asc' })
expect(listCalls.at(-1)).toMatchObject({
page: 1,
q: 'plaster',
sort: 'title:asc',
})
expect(router.currentRoute.value.query).toMatchObject({
q: 'plaster',
sort: 'title:asc',
})
expect(router.currentRoute.value.query.page).toBeUndefined()
})
})
@@ -657,7 +686,10 @@ describe('8e No client-side re-sorting', () => {
it('renders the rows in exactly the order the server returned', async () => {
listHandler = (params) => {
const page = articleEnvelope(articles, params)
return mockResponse(200, { ...page, items: [...page.items].reverse() })
return mockResponse(200, {
...page,
items: [...page.items].reverse(),
})
}
await mountAt('/article?sort=title:asc')
@@ -0,0 +1,372 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { Article } from '@/composables/useArticles'
import {
articleOf,
bylines,
callsTo,
createMetaBackend,
failWith,
mountMeta,
renewSites,
textOf,
typeInto,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SEARCH_WAIT_MS = 320
beforeEach(() => {
localStorage.clear()
})
afterEach(unmountMeta)
const FIVE = ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine']
function backendWith(sites: 'one' | 'several', articles: Article[]): MetaBackend {
const backend = createMetaBackend({
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
})
backend.articles = articles
return backend
}
function withAuthors(id: number, names: string[], overrides: Partial<Article> = {}) {
return articleOf(id, `Article ${id}`, {
authors: bylines(...names),
...overrides,
})
}
function bodyRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
}
function cell(row: HTMLElement, index: number) {
return row.children[index] as HTMLElement
}
function headers() {
return Array.from(document.body.querySelectorAll('thead th')).map(textOf)
}
function siteSelect() {
return document.body.querySelector<HTMLElement>('button[aria-label="Site"]')
}
function listCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/')
}
async function chooseSite(label: string) {
siteSelect()!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item) === label)!
.click()
await flushPromises()
}
function currentQuery() {
return { ...router.currentRoute.value.query }
}
describe('Articles: the Authors column on every frame', () => {
it.each([
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
{
authors: 'one author',
names: FIVE.slice(0, 1),
avatars: 1,
more: '',
text: '',
},
{
authors: 'three',
names: FIVE.slice(0, 3),
avatars: 3,
more: '',
text: '',
},
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, names)]),
})
const authorsCell = cell(bodyRows()[0]!, 1)
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
expect(
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
).toBe(more)
if (text) {
expect(textOf(authorsCell)).toBe(text)
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
}
expect(headers()[1]).toBe('Authors')
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
})
it('draws avatars with the first name and no site on the mobile cards', async () => {
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith('several', [
withAuthors(1, FIVE.slice(0, 3), { site_id: 2 }),
withAuthors(2, []),
]),
})
const cards = Array.from(
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
).map((title) => title.closest('li') as HTMLElement)
expect(textOf(cards[0])).toContain('Inès Duarte +2')
expect(
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(3)
expect(textOf(cards[1])).toContain('No authors')
expect(textOf(cards[0])).not.toContain('Journal')
})
})
describe('Articles: the Site column and the Site filter', () => {
it('has neither with one site', async () => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
})
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(siteSelect()).toBeNull()
expect(textOf(document.body)).not.toContain('All sites')
})
it.each(['owner', 'admin', 'verified'] as const)(
'shows a read-only Site column and a Site select for a %s with several sites',
async (role) => {
const backend = backendWith('several', [
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
withAuthors(2, ['Inès Duarte'], { site_id: 1 }),
])
backend.me.role = role === 'verified' ? undefined : role
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual([
'Title',
'Authors',
'Site',
'Status',
'Created',
'Updated',
'',
])
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
siteSelect()!.click()
await flushPromises()
expect(
Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf),
).toEqual(['All sites', 'Renew', 'Journal', 'Projects', 'Archive'])
},
)
it('drops the Site column and filter when the sites cannot be loaded', async () => {
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
backend.responders['GET /v1/site/'] = failWith(500)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(siteSelect()).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe('Articles: choosing a site', () => {
function journalAndRenew(count: number) {
return Array.from({ length: count }, (_, index) =>
withAuthors(index + 1, ['Inès Duarte'], {
site_id: index % 2 === 0 ? 2 : 1,
is_online: index % 3 !== 0,
}),
)
}
it('puts site=journal in the URL without the page, and asks for that site with the same q, status and sort', async () => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
backend,
})
const before = textOf(document.body.querySelector('[role="tablist"]'))
await chooseSite('Journal')
expect(currentQuery()).toEqual({
site: 'journal',
size: '5',
status: 'online',
sort: 'title:asc',
})
const last = listCalls(backend).at(-1)!
expect(last.query.get('site')).toBe('journal')
expect(last.query.get('page')).toBe('1')
expect(last.query.get('status')).toBe('online')
expect(last.query.get('sort')).toBe('title:asc')
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
expect(siteSelect()!.textContent).toContain('Journal')
})
it('goes back to the full list and page 1 with All sites', async () => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
backend,
})
await chooseSite('All sites')
expect(currentQuery()).toEqual({ size: '5' })
const last = listCalls(backend).at(-1)!
expect(last.query.has('site')).toBe(false)
expect(last.query.get('page')).toBe('1')
})
it.each(['__all', 'all'])('filters by a real site whose key is %s', async (key) => {
const backend = backendWith('several', journalAndRenew(4))
backend.sites = backend.sites.map((site) =>
site.key === 'journal' ? { ...site, key } : site,
)
await mountMeta('articles', '/article', { backend })
await chooseSite('Journal')
expect(currentQuery()).toEqual({ site: key })
expect(listCalls(backend).at(-1)!.query.get('site')).toBe(key)
expect(siteSelect()!.textContent).toContain('Journal')
})
it('reads ?site=journal&page=2 on arrival and shows Journal in the filter', async () => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
backend,
})
expect(siteSelect()!.textContent).toContain('Journal')
const first = listCalls(backend)[0]!
expect(first.query.get('site')).toBe('journal')
expect(first.query.get('page')).toBe('2')
})
it.each([
{ query: '?site=nope', sites: 'several' },
{ query: '?site=renew', sites: 'one' },
] as const)('drops $query silently with $sites site(s)', async ({ query, sites }) => {
const backend = backendWith(sites, journalAndRenew(8))
await mountMeta('articles', `/article${query}`, { backend })
expect(currentQuery()).toEqual({})
expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true)
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
expect(bodyRows().length).toBeGreaterThan(0)
})
it.each([
{
action: 'searches for "plaster"',
act: async () => {
await typeInto(
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
'plaster',
)
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
},
},
{
action: 'changes the tab',
act: async () => {
const tab = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
).find((item) => textOf(item).startsWith('Drafts'))!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
},
},
{
action: 'changes the sort',
act: async () => {
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.click()
await flushPromises()
},
},
{
action: 'goes to page 2',
act: async () => {
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
},
},
{
action: 'changes the page size',
act: async () => {
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item) === '50 per page')!
.click()
await flushPromises()
},
},
])('keeps ?site=journal when the user $action', async ({ act }) => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?site=journal&size=5', { backend })
await act()
expect(currentQuery().site).toBe('journal')
expect(listCalls(backend).at(-1)!.query.get('site')).toBe('journal')
})
it('shows the empty state of a site and returns to the full list with Show all sites', async () => {
const backend = backendWith('several', journalAndRenew(6))
await mountMeta('articles', '/article?site=archive', { backend })
const text = textOf(document.body)
expect(text).toContain('No articles on "Archive"')
expect(text).toContain('Move articles here from the editor, or choose another site.')
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
.find((item) => textOf(item) === 'Show all sites')!
.click()
await flushPromises()
expect(currentQuery()).toEqual({})
expect(listCalls(backend).at(-1)!.query.has('site')).toBe(false)
expect(bodyRows().length).toBeGreaterThan(0)
})
it('keeps the existing No results and No drafts states when a search or a tab is active on a site', async () => {
const backend = backendWith('several', journalAndRenew(6))
await mountMeta('articles', '/article?site=archive&q=tiles', {
backend,
})
expect(textOf(document.body)).toContain('No articles match "tiles"')
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
await unmountMeta()
await mountMeta('articles', '/article?site=archive&status=draft', {
backend,
})
expect(textOf(document.body)).toContain('No drafts')
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
})
})
+13 -4
View File
@@ -317,7 +317,10 @@ describe('Authors form: linked user, choose', () => {
it('says so when every user already has an author', async () => {
const backend = createBackend()
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
backend.authors = authorsFixture().map((item, index) => ({
...item,
user_id: index + 1,
}))
backend.users = backend.users.slice(0, 1)
await openAdd({ backend })
userTrigger().click()
@@ -444,7 +447,9 @@ describe('Authors form: avatar picker', () => {
expect(textOf(box())).toContain('face.jpg')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({
avatar_id: 55,
})
})
})
@@ -482,7 +487,9 @@ describe('Authors form: role changed while open', () => {
describe('Authors form: deep link', () => {
it('opens the edit slide-over for an owner or admin', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
await mountDirectory('authors', '/authors?edit=camille-roche', {
role: 'admin',
})
expect(textOf(box())).toContain('Edit Camille Roche')
})
@@ -525,7 +532,9 @@ describe('Authors form: own author, any role, by deep link only', () => {
await typeInto(bioField(), 'Mine')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({
bio: 'Mine',
})
})
it('opens the read-only detail for another author', async () => {
+13 -4
View File
@@ -193,7 +193,9 @@ describe('Authors: search and pagination in the URL', () => {
it('requests the page and size found in the URL', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', {
backend,
})
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
expect(document.body.textContent).toContain('Showing 51–100 of 130')
@@ -259,7 +261,10 @@ describe('Authors: row menu for an owner or admin', () => {
describe('Authors: read-only list for other roles', () => {
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
await mountDirectory('authors', '/authors', {
role: 'member',
width: MOBILE_WIDTH,
})
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
expect(button('Add author')).toBeUndefined()
@@ -271,7 +276,9 @@ describe('Authors: read-only list for other roles', () => {
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
const backend = await mountDirectory('authors', '/authors', {
role: 'member',
})
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
@@ -314,7 +321,9 @@ describe('Authors: read-only detail', () => {
})
it('opens the same panel from /authors?edit=ines-duarte', async () => {
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
await mountDirectory('authors', '/authors?edit=ines-duarte', {
role: 'member',
})
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
+50 -14
View File
@@ -108,7 +108,11 @@ function setViewport(desktop: boolean) {
}
async function mountPage(
options: { desktop?: boolean; role?: string | null; routes?: Record<string, Handler> } = {},
options: {
desktop?: boolean
role?: string | null
routes?: Record<string, Handler>
} = {},
) {
const { desktop = true, role = 'owner', routes = {} } = options
setViewport(desktop)
@@ -134,14 +138,22 @@ async function mountPage(
)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: sessionRole,
}),
request: requestMock,
})
await router.push('/settings/media/cache')
const Host = defineComponent({ render: () => h(UApp, null, () => h(CachePage)) })
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
const Host = defineComponent({
render: () => h(UApp, null, () => h(CachePage)),
})
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
@@ -361,7 +373,14 @@ describe('Rule: Cache screen, loaded', () => {
[STATS]: () =>
Promise.resolve(
stats({
by_variant: [{ name: 'hero', enabled: true, files: 1, bytes: MB }],
by_variant: [
{
name: 'hero',
enabled: true,
files: 1,
bytes: MB,
},
],
top_images: [
{
id: 1,
@@ -417,7 +436,9 @@ describe('Rule: Cache screen, loading, empty and error', () => {
})
it('Empty cache', async () => {
await mountPage({ routes: { [STATS]: () => Promise.resolve(emptyStats()) } })
await mountPage({
routes: { [STATS]: () => Promise.resolve(emptyStats()) },
})
expect(tiles()).toEqual([
'Generated files 0 Nothing cached',
@@ -441,7 +462,9 @@ describe('Rule: Cache screen, loading, empty and error', () => {
])('Error loading: %s', async (_, failure) => {
let attempt = 0
await mountPage({
routes: { [STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())) },
routes: {
[STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())),
},
})
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
@@ -624,7 +647,9 @@ describe('Rule: Cache screen, purging, done and failed', () => {
await click(buttonIn(alertDialog()!, 'Purge 312 files'))
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
expect(toastCalls()[0]).toMatchObject({ title: 'Purged 312 files, freed 41 MB' })
expect(toastCalls()[0]).toMatchObject({
title: 'Purged 312 files, freed 41 MB',
})
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
})
@@ -647,7 +672,10 @@ describe('Rule: Cache screen, purging, done and failed', () => {
description: '640 of 1,284 files were removed. Try again.',
})
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
const [action] = toast.actions as { label: string; onClick: () => void }[]
const [action] = toast.actions as {
label: string
onClick: () => void
}[]
expect(action!.label).toBe('Try again')
action!.onClick()
@@ -687,14 +715,20 @@ describe('Rule: Cache screen, purging, done and failed', () => {
await startPurgeAll({ [PURGE]: () => Promise.reject(failure) })
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
expect(toastCalls()[0]).toMatchObject({ color: 'error', title, description })
expect(toastCalls()[0]).toMatchObject({
color: 'error',
title,
description,
})
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
expect(tiles()).toHaveLength(4)
expect(calls(STATS)).toHaveLength(1)
})
it('A 401 is left to the API client', async () => {
await startPurgeAll({ [PURGE]: () => Promise.reject(new ApiError(401)) })
await startPurgeAll({
[PURGE]: () => Promise.reject(new ApiError(401)),
})
expect(toastCalls()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
@@ -738,7 +772,9 @@ describe('Rule: Cache screen, purging the variants of one image', () => {
})
it('The image no longer exists', async () => {
await mountPage({ routes: { [PURGE]: () => Promise.reject(new ApiError(404)) } })
await mountPage({
routes: { [PURGE]: () => Promise.reject(new ApiError(404)) },
})
await click(buttonByLabel('Purge north-wall.jpg'))
@@ -13,7 +13,10 @@ import SitesView from '../SitesView.vue'
let wrapper: VueWrapper | null = null
function reply(status: number, body = '') {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
return new Response(body, {
status,
headers: { 'Content-Type': 'text/plain' },
})
}
async function mountWith(
@@ -24,13 +27,24 @@ async function mountWith(
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.session = {
verified,
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
}
session.status = 'ready'
await router.push(path)
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
wrapper = mount(
defineComponent({
render: () => h(UApp, null, { default: () => h(view) }),
}),
{
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
},
)
await flushPromises()
}
@@ -60,7 +74,10 @@ describe('Session problems use the app-wide handling', () => {
expect(calls.some((call) => call.startsWith(api))).toBe(true)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
expect(router.currentRoute.value.query).toEqual({
redirect: path,
reason: 'expired',
})
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
+30 -7
View File
@@ -7,6 +7,7 @@ import App from '@/App.vue'
import { createAppRouter } from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import { oneSite } from './editorMetaKit'
type Handler = () => Response | Promise<Response>
@@ -38,10 +39,14 @@ let calls: string[] = []
function installFetch(handlers: Record<string, Handler> = {}) {
calls = []
const routes: Record<string, Handler> = {
'GET /v1/site/': () => mockResponse(200, oneSite),
...handlers,
}
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]]
const handler = routes[key] ?? routes[key.split('?')[0]]
return handler ? handler() : NEVER
}) as unknown as typeof fetch
}
@@ -54,7 +59,10 @@ async function boot(path: string, signedIn: boolean, handlers: Record<string, Ha
installFetch(handlers)
const router = createAppRouter(createMemoryHistory())
await router.push(path)
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return { router, session }
}
@@ -204,7 +212,10 @@ describe('Editor tab', () => {
const exposed = editor.vm as unknown as {
$: { setupState: { article: { title: string } } }
}
exposed.$.setupState.article = { ...exposed.$.setupState.article, title: 'Hello again' }
exposed.$.setupState.article = {
...exposed.$.setupState.article,
title: 'Hello again',
}
await flushPromises()
expect(document.title).toBe('Hello again · RenewCMS')
@@ -215,7 +226,9 @@ describe('Editor tab', () => {
['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 })
await boot('/article/42/edit', true, {
'GET /v1/article/42': handler,
})
expect(document.title).toBe('Editor · RenewCMS')
})
@@ -266,7 +279,10 @@ describe('Not-found layout', () => {
installFetch()
const router = createAppRouter(createMemoryHistory())
await router.push('/nowhere/at/all')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
expect(useSessionStore().status).toBe('checking')
@@ -278,11 +294,18 @@ describe('Not-found layout', () => {
setActivePinia(createPinia())
installFetch({
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: true, username: 'marta', display_name: 'Marta' }),
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] } })
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
expect(wrapper.find('[data-testid="auth-card"]').exists()).toBe(false)
@@ -0,0 +1,493 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { META_SECTION_OPEN_KEY, hintDismissedKey } from '@/lib/metaSectionMemory'
import {
MARTA_EMAIL,
authorRows,
bylines,
callsTo,
click,
createMetaBackend,
dialog,
failWith,
mountMeta,
networkFailure,
patchCalls,
renewSites,
rowNames,
textOf,
toasts,
typeInto,
unmountMeta,
type MetaBackend,
type MetaRole,
} from './metaTestKit'
import { useSessionStore } from '@/stores/session'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const EDITOR = '/article/12/edit'
const WAIT_FOR_SEARCH_MS = 320
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
})
afterEach(unmountMeta)
function backendWith(role: MetaRole, ...names: string[]): MetaBackend {
const backend = createMetaBackend({ sites: renewSites().slice(0, 1), role })
backend.article.authors = bylines(...names)
return backend
}
function trigger() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
).find((element) => textOf(element) === 'Add an author')!
}
async function openCombobox() {
trigger().click()
await flushPromises()
}
function options() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
}
function option(name: string) {
return options().find((item) => textOf(item).includes(name))!
}
function searchInput() {
return document.body.querySelector<HTMLInputElement>('input[placeholder="Search authors"]')!
}
function createRow() {
return document.body.querySelector<HTMLElement>('[data-testid="create-author-row"]')
}
async function search(term: string) {
await typeInto(searchInput(), term)
await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_SEARCH_MS))
await flushPromises()
}
function authorListCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/author/')
}
describe('Add author: the combobox', () => {
it('opens with a Search authors field, requests the first 100 authors and dims the ones already on the article', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
expect(searchInput()).not.toBeNull()
expect(authorListCalls(backend)).toHaveLength(1)
expect(authorListCalls(backend)[0]!.query.get('size')).toBe('100')
expect(option('Inès Duarte').className).toContain('opacity-60')
expect(option('Camille Roche').className).not.toContain('opacity-60')
})
it('does nothing when a checked author is activated, and appends a new author on pick', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
option('Inès Duarte').click()
await flushPromises()
expect(rowNames()).toEqual(['Inès Duarte'])
expect(textOf(document.body)).not.toContain('Unsaved changes')
option('Camille Roche').click()
await flushPromises()
expect(rowNames()).toEqual(['Inès Duarte', 'Camille Roche'])
expect(textOf(document.body)).toContain('Unsaved changes')
expect(patchCalls(backend)).toHaveLength(0)
})
it('keeps the section open when the first author is added to an article that had none', async () => {
localStorage.clear()
const backend = backendWith('owner')
await mountMeta('editor', EDITOR, { backend })
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
await openCombobox()
option('Camille Roche').click()
await flushPromises()
expect(rowNames()).toEqual(['Camille Roche'])
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
expect(trigger()).toBeDefined()
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
})
it('searches the server with q when the install has more than 100 authors', async () => {
const backend = backendWith('owner', 'Inès Duarte')
const crowd = Array.from({ length: 120 }, (_, index) => ({
...backend.authors[0]!,
id: 1000 + index,
name: `Writer ${String(index).padStart(3, '0')}`,
slug: `writer-${index}`,
}))
backend.authors = [
...crowd,
{
...backend.authors[0]!,
id: 2000,
name: 'Zoe Adams',
slug: 'zoe-adams',
},
]
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
expect(options()).toHaveLength(100)
expect(textOf(document.body)).not.toContain('Zoe Adams')
await search('Zoe')
const last = authorListCalls(backend).at(-1)!
expect(last.query.get('q')).toBe('Zoe')
expect(last.query.get('size')).toBe('100')
expect(options().map(textOf)).toEqual(['ZA Zoe Adams zoe-adams'])
})
})
describe('Add author: "Create author…" only for roles with author rights', () => {
it.each([
{ role: 'owner', shown: true },
{ role: 'admin', shown: true },
{ role: 'verified', shown: false },
] as const)('the $role sees the row: $shown', async ({ role, shown }) => {
await mountMeta('editor', EDITOR, {
backend: backendWith(role, 'Inès Duarte'),
})
await openCombobox()
expect(createRow() !== null).toBe(shown)
if (shown) {
expect(textOf(createRow())).toBe('Create author…')
}
})
})
describe('Add author: no match', () => {
it.each([
{ role: 'owner', create: true },
{ role: 'verified', create: false },
] as const)(
'shows the message for a $role, with the create row: $create',
async ({ role, create }) => {
await mountMeta('editor', EDITOR, {
backend: backendWith(role, 'Inès Duarte'),
})
await openCombobox()
await search('Zoe Lambert')
expect(textOf(document.body)).toContain('No author matches "Zoe Lambert".')
expect(createRow() !== null).toBe(create)
if (create) {
expect(textOf(createRow())).toBe('Create author "Zoe Lambert"…')
}
},
)
})
describe('Add author: authors could not be loaded', () => {
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows the error and retries when the load fails with $failure', async ({ responder }) => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['GET /v1/author/'] = responder
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
const text = textOf(document.body)
expect(text).toContain("Couldn't load authors")
expect(text).toContain('Check your connection and try again.')
expect(createRow()).toBeNull()
expect(rowNames()).toEqual(['Inès Duarte'])
delete backend.responders['GET /v1/author/']
await click('Try again')
expect(authorListCalls(backend)).toHaveLength(2)
expect(options().length).toBeGreaterThan(0)
expect(rowNames()).toEqual(['Inès Duarte'])
})
})
describe('Create author dialog', () => {
async function openDialog(backend: MetaBackend) {
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
await search('Zoe Lambert')
createRow()!.click()
await flushPromises()
}
function nameField() {
return dialog()!.querySelector<HTMLInputElement>('input[placeholder="e.g. Camille Roche"]')!
}
function slugField() {
return dialog()!.querySelector<HTMLInputElement>(
'input[placeholder="Generated from the name"]',
)!
}
it('opens over the editor with the name, the auto slug and both actions', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await openDialog(backend)
const text = textOf(dialog())
expect(text).toContain('Create author')
expect(text).toContain(
'Adds a byline for this article. You can add a bio and avatar later in Authors.',
)
expect(nameField().value).toBe('Zoe Lambert')
expect(slugField().value).toBe('zoe-lambert')
expect(text).toContain('Auto')
expect(text).toContain('Cancel')
expect(patchCalls(backend)).toHaveLength(0)
})
it('creates with name and slug, appends the author and waits for Save to send the authors', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await openDialog(backend)
await click('Create author', dialog()!)
const posts = callsTo(backend, 'POST', '/v1/author/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
})
expect(dialog()).toBeNull()
expect(rowNames()).toEqual(['Inès Duarte', 'Zoe Lambert'])
expect(textOf(document.body)).toContain('Unsaved changes')
expect(patchCalls(backend)).toHaveLength(0)
})
it('offers "This is me" only without an author profile, unchecked, and sends user_id when checked', async () => {
const backend = backendWith('owner', 'Inès Duarte')
await openDialog(backend)
const checkbox = dialog()!.querySelector<HTMLElement>('button[role="checkbox"]')!
expect(textOf(dialog())).toContain('This is me')
expect(textOf(dialog())).toContain(`Links the author to ${MARTA_EMAIL}.`)
expect(checkbox.getAttribute('aria-checked')).toBe('false')
checkbox.click()
await flushPromises()
await click('Create author', dialog()!)
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
user_id: 1,
})
expect(useSessionStore().session?.author).toMatchObject({
name: 'Zoe Lambert',
})
})
it('has no "This is me" and never sends a user_id when the user already has an author', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
})
await openDialog(backend)
expect(textOf(dialog())).not.toContain('This is me')
await click('Create author', dialog()!)
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).not.toHaveProperty('user_id')
})
it('shows the taken slug with a suggestion that keeps the name', async () => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await openDialog(backend)
await click('Create author', dialog()!)
expect(textOf(dialog())).toContain(
'The slug "zoe-lambert" is already used by another author.',
)
await click('Use zoe-lambert-2', dialog()!)
expect(slugField().value).toBe('zoe-lambert-2')
expect(nameField().value).toBe('Zoe Lambert')
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])(
'keeps the dialog and the values open when creating fails with $failure',
async ({ responder }) => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['POST /v1/author/'] = responder
await openDialog(backend)
await click('Create author', dialog()!)
expect(textOf(dialog())).toContain("Couldn't create the author")
expect(textOf(dialog())).toContain('The article is unchanged. Try again in a moment.')
expect(nameField().value).toBe('Zoe Lambert')
expect(rowNames()).toEqual(['Inès Duarte'])
expect(patchCalls(backend)).toHaveLength(0)
},
)
it('shows the rights toast and closes when the role changed while the dialog was open', async () => {
const backend = backendWith('owner', 'Inès Duarte')
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
await openDialog(backend)
await click('Create author', dialog()!)
expect(toasts()).toContain('Only an owner or admin can change authors')
expect(dialog()).toBeNull()
})
})
describe('Create my author profile hint', () => {
function hint() {
return document.body.querySelector<HTMLElement>('[data-testid="author-hint"]') ?? undefined
}
it.each(['owner', 'admin', 'verified'] as const)(
'appears for a %s with no profile, no authors on the article and no dismissal',
async (role) => {
await mountMeta('editor', EDITOR, { backend: backendWith(role) })
expect(textOf(hint())).toContain('Create one to be credited on your articles.')
expect(textOf(hint())).toContain('Create my author profile')
expect(textOf(document.body)).toContain('No authors. This article will have no byline.')
},
)
it('stays away when the user already has an author profile', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
})
backend.article.authors = []
await mountMeta('editor', EDITOR, { backend })
expect(hint()).toBeUndefined()
})
it('stays away when the article has an author', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('owner', 'Inès Duarte'),
})
expect(hint()).toBeUndefined()
})
it('stays away once this user dismissed it', async () => {
localStorage.setItem(hintDismissedKey(1), 'true')
await mountMeta('editor', EDITOR, { backend: backendWith('owner') })
expect(hint()).toBeUndefined()
})
it('creates the profile directly: one POST with no body, no dialog, the author first and a toast', async () => {
const backend = backendWith('verified', 'Inès Duarte')
backend.article.authors = []
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
const posts = callsTo(backend, 'POST', '/v1/author/me')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toBeUndefined()
expect(dialog()).toBeNull()
expect(rowNames()).toEqual(['Marta Alves'])
expect(textOf(document.body)).toContain('Unsaved changes')
expect(hint()).toBeUndefined()
expect(useSessionStore().session?.author).toMatchObject({
name: 'Marta Alves',
})
expect(toasts()).toContain('Author profile created')
expect(toasts()).toContain('Marta Alves is now your byline.')
expect(patchCalls(backend)).toHaveLength(0)
})
it('puts the new author before the others as the primary byline', async () => {
const backend = backendWith('owner')
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
await openCombobox()
option('Camille Roche').click()
await flushPromises()
expect(rowNames()).toEqual(['Marta Alves', 'Camille Roche'])
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])(
'keeps the hint and shows an error toast when it fails with $failure',
async ({ responder }) => {
const backend = backendWith('owner')
backend.responders['POST /v1/author/me'] = responder
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
expect(textOf(hint())).toContain('Create my author profile')
expect(toasts()).toContain("Couldn't create your author profile")
expect(toasts()).toContain('Try again in a moment.')
expect(rowNames()).toEqual([])
expect(textOf(document.body)).not.toContain('Unsaved changes')
},
)
it('reloads the session and drops the hint when the profile already exists (409)', async () => {
const backend = backendWith('owner')
const conflict = failWith(409, 'user_already_linked')
backend.responders['POST /v1/author/me'] = (call) => {
backend.me.author = {
id: 300,
name: 'Marta Alves',
slug: 'marta-alves',
}
return conflict(call)
}
await mountMeta('editor', EDITOR, { backend })
await click('Create my author profile')
expect(callsTo(backend, 'GET', '/v1/auth/me')).toHaveLength(1)
expect(hint()).toBeUndefined()
expect(useSessionStore().session?.author).toMatchObject({ id: 300 })
expect(rowNames()).toEqual([])
})
it('is dismissed without a request, kept per user in localStorage, and does not return', async () => {
const backend = backendWith('owner')
await mountMeta('editor', EDITOR, { backend })
const before = backend.calls.length
document.body.querySelector<HTMLElement>('button[aria-label="Dismiss"]')!.click()
await flushPromises()
expect(hint()).toBeUndefined()
expect(localStorage.getItem(hintDismissedKey(1))).toBe('true')
expect(backend.calls).toHaveLength(before)
await unmountMeta()
const other = backendWith('owner')
await mountMeta('editor', '/article/13/edit', { backend: other })
expect(hint()).toBeUndefined()
expect(authorRows()).toHaveLength(0)
})
})
@@ -0,0 +1,247 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import {
authorRows,
bylines,
createMetaBackend,
dragTo,
handleOf,
mountMeta,
patchCalls,
pressKey,
renewSites,
rowNames,
saveButtonEl,
textOf,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const EDITOR = '/article/12/edit'
const LONG_NAME = 'Dr. Alexandra Konstantinopoulou-Whitfield'
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
})
afterEach(unmountMeta)
function backendWith(...names: string[]): MetaBackend {
const backend = createMetaBackend({ sites: renewSites().slice(0, 1) })
backend.article.authors = bylines(...names)
return backend
}
function live() {
return textOf(document.body.querySelector('[data-testid="author-live"]'))
}
function removeButton(name: string) {
return document.body.querySelector<HTMLElement>(`button[aria-label="Remove ${name}"]`)!
}
describe('Author list: an ordered list with the first author as the primary byline', () => {
it('labels the first row Primary, gives each row a handle and a remove button, and shows the hint', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche', LONG_NAME),
})
const rows = authorRows()
expect(rows).toHaveLength(4)
expect(textOf(rows[0])).toContain('Primary')
rows.slice(1).forEach((row) => expect(textOf(row)).not.toContain('Primary'))
rows.forEach((row) => {
expect(row.querySelector('button[aria-label^="Reorder "]')).not.toBeNull()
expect(row.querySelector('button[aria-label^="Remove "]')).not.toBeNull()
})
expect(textOf(document.body)).toContain(
'Drag to reorder. The first author is the primary byline.',
)
})
it('truncates a long name with an ellipsis and keeps the full name in title and aria-label', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('Inès Duarte', LONG_NAME),
})
const name = authorRows()[1]!.querySelector<HTMLElement>('[title]')!
expect(name.className).toContain('truncate')
expect(name.getAttribute('title')).toBe(LONG_NAME)
expect(name.getAttribute('aria-label')).toBe(LONG_NAME)
})
})
describe('Author list: reorder with the keyboard', () => {
async function mountThree() {
const backend = backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche')
await mountMeta('editor', EDITOR, { backend })
return backend
}
it('grabs with Space, moves with ArrowUp, drops with Space and marks the draft unsaved', async () => {
await mountThree()
const handle = handleOf('Jonas Pereira')
await pressKey(handle, ' ')
expect(live()).toBe(
'Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel.',
)
expect(textOf(document.body)).toContain('Saved')
await pressKey(handle, 'ArrowUp')
expect(rowNames()).toEqual(['Jonas Pereira', 'Inès Duarte', 'Camille Roche'])
expect(live()).toBe('Jonas Pereira moved to position 1 of 3.')
await pressKey(handle, ' ')
expect(rowNames()).toEqual(['Jonas Pereira', 'Inès Duarte', 'Camille Roche'])
expect(live()).toBe('Jonas Pereira dropped. Position 1 of 3.')
expect(textOf(document.body)).toContain('Unsaved changes')
})
it('grabs with Enter and moves down with ArrowDown', async () => {
await mountThree()
const handle = handleOf('Jonas Pereira')
await pressKey(handle, 'Enter')
await pressKey(handle, 'ArrowDown')
await pressKey(handle, 'Enter')
expect(rowNames()).toEqual(['Inès Duarte', 'Camille Roche', 'Jonas Pereira'])
})
it('returns the row to its original position on Escape and leaves the status alone', async () => {
await mountThree()
const handle = handleOf('Jonas Pereira')
await pressKey(handle, ' ')
await pressKey(handle, 'ArrowUp')
expect(rowNames()[0]).toBe('Jonas Pereira')
await pressKey(handle, 'Escape')
expect(rowNames()).toEqual(['Inès Duarte', 'Jonas Pereira', 'Camille Roche'])
expect(live()).toBe('Reorder cancelled. Jonas Pereira is back at position 2 of 3.')
expect(textOf(document.body)).not.toContain('Unsaved changes')
})
it('does not move past either end of the list', async () => {
await mountThree()
const handle = handleOf('Inès Duarte')
await pressKey(handle, ' ')
await pressKey(handle, 'ArrowUp')
await pressKey(handle, ' ')
expect(rowNames()).toEqual(['Inès Duarte', 'Jonas Pereira', 'Camille Roche'])
expect(textOf(document.body)).not.toContain('Unsaved changes')
})
})
describe('Author list: reorder with the pointer', () => {
it('lifts the row, draws the drop line and applies the new order on release', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche'),
})
const drag = await dragTo('Inès Duarte', 60)
expect(authorRows()[0]!.className).toContain('shadow-')
expect(document.body.querySelectorAll('[data-testid="drop-line"]')).toHaveLength(1)
await drag.release()
expect(rowNames()).toEqual(['Jonas Pereira', 'Inès Duarte', 'Camille Roche'])
expect(document.body.querySelector('[data-testid="drop-line"]')).toBeNull()
expect(textOf(document.body)).toContain('Unsaved changes')
})
it('drops below the last row and keeps the order when cancelled', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche'),
})
const cancelled = await dragTo('Inès Duarte', 200)
await cancelled.cancel()
expect(rowNames()).toEqual(['Inès Duarte', 'Jonas Pereira', 'Camille Roche'])
expect(textOf(document.body)).not.toContain('Unsaved changes')
const drag = await dragTo('Inès Duarte', 200)
await drag.release()
expect(rowNames()).toEqual(['Jonas Pereira', 'Camille Roche', 'Inès Duarte'])
})
})
describe('Author list: remove', () => {
it('promotes the next author to primary and shows the no-byline text when the list empties', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('Inès Duarte', 'Jonas Pereira'),
})
removeButton('Inès Duarte').click()
await flushPromises()
expect(rowNames()).toEqual(['Jonas Pereira'])
expect(textOf(document.body)).toContain('Unsaved changes')
removeButton('Jonas Pereira').click()
await flushPromises()
expect(authorRows()).toHaveLength(0)
expect(textOf(document.body)).toContain('No authors. This article will have no byline.')
})
})
describe('Author list: saving', () => {
it('sends author_ids in the list order and nothing else', async () => {
const backend = backendWith('Inès Duarte', 'Jonas Pereira')
await mountMeta('editor', EDITOR, { backend })
const handle = handleOf('Jonas Pereira')
await pressKey(handle, ' ')
await pressKey(handle, 'ArrowUp')
await pressKey(handle, ' ')
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [3, 2] })
expect(textOf(document.body)).toContain('Saved')
})
it('sends an emptied list as author_ids []', async () => {
const backend = backendWith('Inès Duarte')
await mountMeta('editor', EDITOR, { backend })
removeButton('Inès Duarte').click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [] })
})
it('does not send author_ids when only the order was touched and put back', async () => {
const backend = backendWith('Inès Duarte', 'Jonas Pereira')
await mountMeta('editor', EDITOR, { backend })
const handle = handleOf('Jonas Pereira')
await pressKey(handle, ' ')
await pressKey(handle, 'ArrowUp')
await pressKey(handle, ' ')
const again = handleOf('Jonas Pereira')
await pressKey(again, ' ')
await pressKey(again, 'ArrowDown')
await pressKey(again, ' ')
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)).toHaveLength(0)
expect(textOf(document.body)).toContain('Saved')
})
})
@@ -7,16 +7,22 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite } from './editorMetaKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
vi.mock('@/lib/fileHash', () => ({
hashFile: vi.fn(async () => 'a'.repeat(64)),
}))
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
readNaturalImageSize: vi.fn(async () => ({
width: 4032,
height: 3024,
})),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
@@ -86,7 +92,10 @@ function mib(value: number) {
function fileFixture(name: string, type: string, sizeMB: number) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
Object.defineProperty(file, 'size', {
value: mib(sizeMB),
configurable: true,
})
return file
}
@@ -105,9 +114,13 @@ function routeFor(url: string, init?: RequestInit) {
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
const routes = {
'GET /v1/site/': () => mockResponse(200, oneSite),
...handlers,
}
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
const handler = routes[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
@@ -121,6 +134,7 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
title: 'How we price a bathroom renovation',
body: '<p>a</p>',
is_online: false,
...articleMeta,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
...overrides,
@@ -7,6 +7,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite } from './editorMetaKit'
import { fixture } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -29,6 +30,7 @@ function articleFixture(body = BODY_WITH_TWO_IMAGES): Article {
title: 'How we price a bathroom renovation',
body,
is_online: false,
...articleMeta,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
}
@@ -38,7 +40,13 @@ function libraryPage() {
const items = fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
)
return { items, total: 48, page: 1, size: 25, counts: { all: 48, unused: 2, duplicates: 2 } }
return {
items,
total: 48,
page: 1,
size: 25,
counts: { all: 48, unused: 2, duplicates: 2 },
}
}
let toastAdd: ReturnType<typeof vi.fn>
@@ -60,6 +68,9 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/site/') {
return mockResponse(200, oneSite)
}
if (url === '/v1/media/?page=1&size=25') {
return mockResponse(200, libraryPage())
}
@@ -0,0 +1,468 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import {
allMetaSections,
bylines,
callsTo,
click,
createMetaBackend,
failWith,
metaSection,
mountMeta,
patchCalls,
pending,
renewSites,
saveButtonEl,
textOf,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(unmountMeta)
const EDITOR = '/article/12/edit'
function backendWith(sites: 'one' | 'several', authors: string[], siteId = 1): MetaBackend {
const backend = createMetaBackend({
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
})
backend.article.authors = bylines(...authors)
backend.article.site_id = siteId
return backend
}
function summary() {
return textOf(document.body.querySelector('[data-testid="meta-summary"]'))
}
function toggle() {
return document.body.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!
}
function listboxTriggers() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
)
}
function bodyOpen() {
return document.body.querySelector('[data-testid="meta-body"]') !== null
}
function toasts() {
return Array.from(document.body.querySelectorAll('[data-slot="viewport"]'))
.map(textOf)
.join(' ')
}
describe('Editor section: collapsed by default with one site and an author', () => {
it('shows the one-line summary in the details sidebar, above Created, and no Site select', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('one', ['Inès Duarte']),
})
const details = document.body.querySelector('[data-testid="article-details"]')!
const section = metaSection()!
expect(details.contains(section)).toBe(true)
expect(textOf(section)).toContain('Site and authors')
expect(summary()).toBe('Inès Duarte')
expect(toggle().getAttribute('aria-expanded')).toBe('false')
expect(bodyOpen()).toBe(false)
expect(listboxTriggers()).toHaveLength(0)
const text = textOf(details)
expect(text.indexOf('Site and authors')).toBeLessThan(text.indexOf('Created'))
expect(text.indexOf('Details')).toBeLessThan(text.indexOf('Site and authors'))
})
})
describe('Editor section: loading and not found', () => {
it('shows the header with a skeleton summary while the article is pending', async () => {
const backend = backendWith('one', ['Inès Duarte'])
const gate = pending<unknown>()
backend.responders['GET /v1/article/12'] = () => gate.promise
await mountMeta('editor', EDITOR, { backend })
await new Promise((resolve) => setTimeout(resolve, 200))
await flushPromises()
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
expect(textOf(skeleton)).toContain('Site and authors')
expect(skeleton.querySelector('[data-testid="skeleton-meta"]')).not.toBeNull()
expect(metaSection()).toBeNull()
})
it('shows no section in the Not found state', async () => {
const backend = backendWith('one', ['Inès Duarte'])
backend.responders['GET /v1/article/12'] = failWith(404)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain("This article doesn't exist")
expect(metaSection()).toBeNull()
expect(document.body.textContent).not.toContain('Site and authors')
})
})
describe('Editor section: it opens by itself when it needs attention', () => {
it.each([
{ sites: 'one', authors: ['Inès Duarte'], state: 'collapsed' },
{ sites: 'several', authors: ['Inès Duarte'], state: 'open' },
{ sites: 'one', authors: [], state: 'open' },
{ sites: 'several', authors: [], state: 'open' },
] as const)(
'$sites site(s) and $authors.length author(s): $state',
async ({ sites, authors, state }) => {
await mountMeta('editor', EDITOR, {
backend: backendWith(sites, [...authors]),
})
expect(bodyOpen()).toBe(state === 'open')
expect(toggle().getAttribute('aria-expanded')).toBe(String(state === 'open'))
},
)
it('stays open while the user adds the first author, instead of collapsing under them', async () => {
await mountMeta('editor', EDITOR, { backend: backendWith('one', []) })
expect(bodyOpen()).toBe(true)
document.body.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
const option = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).find((item) => textOf(item).includes('Camille Roche'))!
option.click()
await flushPromises()
expect(bodyOpen()).toBe(true)
expect(textOf(metaSection())).toContain('Camille Roche')
})
})
describe('Editor section: the choice is remembered per browser', () => {
it('keeps an opening for the next article, and a collapse over every auto-open, without any request', async () => {
const backend = backendWith('one', ['Inès Duarte'])
await mountMeta('editor', EDITOR, { backend })
toggle().click()
await flushPromises()
expect(bodyOpen()).toBe(true)
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
await unmountMeta()
await mountMeta('editor', '/article/13/edit', {
backend: backendWith('one', ['Inès Duarte']),
})
expect(bodyOpen()).toBe(true)
toggle().click()
await flushPromises()
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('false')
await unmountMeta()
const crowded = backendWith('several', [])
await mountMeta('editor', EDITOR, { backend: crowded })
expect(bodyOpen()).toBe(false)
expect(summary()).toContain('No authors')
expect(
crowded.calls.filter(
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
),
).toEqual([])
})
})
describe('Editor section: mobile, inside the Details sheet', () => {
function openSheet() {
return click('Details')
}
function sheet() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
it('hides the desktop sidebar, starts closed in the sheet and shares the choice with it', async () => {
await mountMeta('editor', EDITOR, {
width: MOBILE_WIDTH,
backend: backendWith('several', ['Inès Duarte'], 2),
})
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
expect(sidebar.className).toContain('hidden')
expect(sheet()).toBeNull()
await openSheet()
const inSheet = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
expect(inSheet.querySelector('[data-testid="meta-body"]')).toBeNull()
expect(textOf(inSheet)).toContain('Journal · Inès Duarte')
inSheet.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!.click()
await flushPromises()
expect(inSheet.querySelector('[data-testid="meta-body"]')).not.toBeNull()
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
sheet()!.querySelector<HTMLElement>('button[aria-label="Close"]')!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 400))
await openSheet()
const again = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
expect(again.querySelector('[data-testid="meta-body"]')).not.toBeNull()
expect(allMetaSections().length).toBeGreaterThanOrEqual(2)
})
})
describe('Editor section: collapsed summary variants', () => {
it.each([
{
sites: 'one',
authors: ['Inès Duarte'],
siteId: 1,
line: 'Inès Duarte',
},
{
sites: 'one',
authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'],
siteId: 1,
line: 'Inès Duarte +2',
},
{ sites: 'one', authors: [], siteId: 1, line: 'No authors' },
{
sites: 'several',
authors: ['Inès Duarte'],
siteId: 2,
line: 'Journal · Inès Duarte',
},
{
sites: 'several',
authors: [],
siteId: 2,
line: 'Journal · No authors',
},
] as const)(
'$sites site(s), $authors.length author(s): "$line"',
async ({ sites, authors, siteId, line }) => {
localStorage.setItem(META_SECTION_OPEN_KEY, 'false')
await mountMeta('editor', EDITOR, {
backend: backendWith(sites, [...authors], siteId),
})
expect(summary()).toBe(line)
},
)
})
describe('Editor section: the Site select', () => {
it('is absent with one site and the Authors list is the first control', async () => {
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
await mountMeta('editor', EDITOR, {
backend: backendWith('one', ['Inès Duarte']),
})
const body = document.body.querySelector('[data-testid="meta-body"]')!
expect(textOf(body)).not.toContain('Canonical host')
expect(textOf(body.firstElementChild)).toContain('Authors')
expect(listboxTriggers().map(textOf)).toEqual(['Add an author'])
})
it.each(['owner', 'admin', 'verified'] as const)(
'is above the authors with the canonical host as helper text for a %s',
async (role) => {
const backend = backendWith('several', ['Inès Duarte'], 2)
backend.me.role = role === 'verified' ? undefined : role
await mountMeta('editor', EDITOR, { backend })
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example')
expect(body).toContain('Canonical host: journal.renew.example')
expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors'))
},
)
it('lists every site with its host, the default first', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte'], 2),
})
listboxTriggers()[0]!.click()
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).map(textOf)
expect(options).toEqual([
'Renew renew.example',
'Journal journal.renew.example',
'Projects projects.renew.example',
'Archive archive.renew.example',
])
})
it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain('Saved')
expect(saveButtonEl().disabled).toBe(true)
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
expect(textOf(document.body)).toContain('Unsaved changes')
expect(saveButtonEl().disabled).toBe(false)
expect(patchCalls(backend)).toHaveLength(0)
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
expect(textOf(document.body)).toContain('Saved')
expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain(
'Canonical host: journal.renew.example',
)
})
it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => {
const backend = backendWith('several', ['Inès Duarte'])
backend.responders['GET /v1/site/'] = failWith(500)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain('How we price a bathroom renovation')
expect(summary()).toBe('Inès Duarte')
expect(listboxTriggers()).toHaveLength(0)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(toasts()).toBe('')
})
})
describe('Editor section: a site or author that no longer exists', () => {
it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found')
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(textOf(document.body)).toContain('Save failed')
expect(toasts()).toContain('That site no longer exists')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
})
it('keeps the added author and shows the author toast on author_not_found', async () => {
const backend = backendWith('one', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found')
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).includes('Camille Roche'))!
.click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(textOf(document.body)).toContain('Save failed')
expect(toasts()).toContain('That author no longer exists')
expect(textOf(metaSection())).toContain('Camille Roche')
})
})
describe('Editor section: the existing save behaviour treats the change like any other edit', () => {
async function changeSite(backend: MetaBackend) {
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
}
it('offers Save and publish and saves with Ctrl+S', async () => {
const backend = backendWith('several', ['Inès Duarte'])
await changeSite(backend)
expect(textOf(document.body)).toContain('Save and publish')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
})
it('asks before leaving with an unsaved site change', async () => {
await changeSite(backendWith('several', ['Inès Duarte']))
const leaving = router.push('/article')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
'Leave without saving?',
)
await click('Keep editing')
await leaving
expect(router.currentRoute.value.path).toBe(EDITOR)
})
it('still loads the article when sessionStorage throws', async () => {
const spies = ['getItem', 'setItem', 'removeItem'].map((method) =>
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(() => {
throw new Error('storage blocked')
}),
)
try {
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte']),
})
expect(metaSection()).not.toBeNull()
expect(textOf(document.body)).not.toContain('Unsaved changes')
} finally {
spies.forEach((spy) => spy.mockRestore())
}
})
it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => {
const backend = backendWith('several', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(401)
await changeSite(backend)
saveButtonEl().click()
await flushPromises()
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject(
{
site_id: 2,
},
)
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull()
await unmountMeta()
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte']),
})
expect(textOf(document.body)).toContain('Unsaved changes')
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
})
})
+27 -5
View File
@@ -8,6 +8,7 @@ import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { messages } from '@/i18n/messages'
import { articleMeta, oneSite } from './editorMetaKit'
import { fixture, textOf } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -67,6 +68,7 @@ function article(body: string): Article {
title: 'How we price a bathroom renovation',
body,
is_online: false,
...articleMeta,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
}
@@ -78,7 +80,10 @@ function libraryPage() {
2: '/static/uploadedImages/2.webp',
4: '/static/uploadedImages/4.svg',
}
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url }))
const items = fixture().map((item) => ({
...item,
url: urls[item.id] ?? item.url,
}))
return {
items,
total: items.length,
@@ -119,6 +124,9 @@ async function mountEditor(options: EditorOptions = {}) {
if (url === '/v1/article/12?format=html') {
return reply(200, article(options.body ?? '<p>Intro</p>'))
}
if (url === '/v1/site/') {
return reply(200, oneSite)
}
if (url === '/v1/media/?page=1&size=25') {
return reply(200, libraryPage())
}
@@ -322,7 +330,12 @@ describe('The Variant select', () => {
'Only disabled variants count as none',
{
status: 200,
body: { items: CATALOG.map((entry) => ({ ...entry, enabled: false })) },
body: {
items: CATALOG.map((entry) => ({
...entry,
enabled: false,
})),
},
},
],
])('%s', async (_label, result) => {
@@ -412,7 +425,10 @@ describe('Inserting a variant', () => {
expect(editorBody(wrapper)).toContain(`<img src="${src}" alt="A wall" data-image-id="1">`)
expect(wrapper.text()).toContain('Unsaved changes')
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Image added', icon: 'i-lucide-circle-check' }),
expect.objectContaining({
title: 'Image added',
icon: 'i-lucide-circle-check',
}),
)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(writeRequests()).toHaveLength(0)
@@ -486,7 +502,10 @@ describe('A block whose variant was deleted or disabled', () => {
const items = CATALOG.map((entry) =>
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
)
await mountEditor({ body: HERO_BODY, variants: () => ({ status: 200, body: { items } }) })
await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items } }),
})
expect(textOf(block())).toContain("Variant 'hero' is disabled.")
expect(textOf(block())).toContain('This image returns an error on the public site.')
@@ -546,7 +565,10 @@ describe('A block whose variant was deleted or disabled', () => {
it('keeps the Pick another variant button 44 px high on touch screens', async () => {
const items = CATALOG.filter((entry) => entry.name !== 'hero')
await mountEditor({ body: HERO_BODY, variants: () => ({ status: 200, body: { items } }) })
await mountEditor({
body: HERO_BODY,
variants: () => ({ status: 200, body: { items } }),
})
const button = findButton('Pick another variant', block()!)!
expect(button.className).toContain('h-11')
+16 -6
View File
@@ -8,6 +8,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite } from './editorMetaKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -26,9 +27,13 @@ function routeFor(url: string, init?: RequestInit) {
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
const routes = {
'GET /v1/site/': () => mockResponse(200, oneSite),
...handlers,
}
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
const handler = routes[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
@@ -47,6 +52,7 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
{ id: 3, url: '/images/3.png' },
],
is_online: false,
...articleMeta,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
...overrides,
@@ -684,7 +690,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.tone).toBe('neutral')
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
})
@@ -696,7 +702,9 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { preview: Record<string, string> }
const props = overlayMock.getProps() as {
preview: Record<string, string>
}
expect(props.preview).toEqual({
src: '/static/uploadedImages/before.png',
filename: 'before.png',
@@ -711,7 +719,9 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { preview: Record<string, string> }
const props = overlayMock.getProps() as {
preview: Record<string, string>
}
expect(props.preview).toEqual({
src: '/static/uploadedImages/before.png',
filename: undefined,
@@ -733,7 +743,7 @@ describe('Cancelling the confirm changes nothing', () => {
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
})
})
@@ -757,7 +767,7 @@ describe('Confirming removes the image from the body only, marks the draft dirty
title: 'Image removed',
description: "It's still in your library.",
})
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
})
})
@@ -200,7 +200,12 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
})
it('truncates a long sentence on one line', async () => {
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
const items = [
makeItem({
id: 1,
target: target('article', '8', 'T'.repeat(150)),
}),
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
@@ -126,7 +126,10 @@ describe('Home banner for the owner, update available', () => {
it('shows again when latest changes to a newer version', async () => {
localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG)
await mountHome({ role: 'owner', status: newerAvailable({ latest: 'v2.4.0-beta' }) })
await mountHome({
role: 'owner',
status: newerAvailable({ latest: 'v2.4.0-beta' }),
})
expect(banner()).not.toBeNull()
expect(banner()!.textContent).toContain('Version 2.4.0-beta is available')
@@ -163,7 +166,11 @@ describe('Settings sidebar pill and mobile dot', () => {
})
it('shows the dot on the mobile menu icon', async () => {
await mountHome({ role: 'owner', status: newerAvailable(), width: MOBILE_WIDTH })
await mountHome({
role: 'owner',
status: newerAvailable(),
width: MOBILE_WIDTH,
})
expect(mobileDot()).not.toBeNull()
expect(banner()).not.toBeNull()
@@ -171,7 +178,11 @@ describe('Settings sidebar pill and mobile dot', () => {
it.each([
{ name: 'an admin', role: 'admin' as const, status: newerAvailable() },
{ name: 'an owner who is up to date', role: 'owner' as const, status: upToDate() },
{
name: 'an owner who is up to date',
role: 'owner' as const,
status: upToDate(),
},
])('shows no pill and no dot for $name', async ({ role, status }) => {
await mountHome({ role, status })
@@ -156,7 +156,9 @@ describe('Home banner, ready to install (owner)', () => {
describe('Home banner while restarting', () => {
it('shows no update banner and does not poll', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
})
await mountHome('owner', restarting())
expect(banner()).toBeNull()
+4 -1
View File
@@ -43,7 +43,10 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
})
}
const HOME_ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
const HOME_ARTICLES_ROUTE = articleListRoute({
size: 5,
sort: 'updated_at:desc',
})
const sixArticles: Article[] = [
{
@@ -88,7 +88,10 @@ function listing(items: ImageVariant[]): ListHandler {
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
@@ -117,11 +120,18 @@ async function mountPage(options: MountOptions = {}) {
throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role,
}),
request: requestMock,
})
await router.push('/integration')
const Host = defineComponent({ render: () => h(UApp, null, () => h(IntegrationView)) })
const Host = defineComponent({
render: () => h(UApp, null, () => h(IntegrationView)),
})
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
@@ -313,7 +323,9 @@ describe('I1. Many variants', () => {
describe('I2. One variant', () => {
it('uses the single variant everywhere and draws no scroll limit and no disabled note', async () => {
await mountPage({ list: listing([variant({ name: 'thumb', width: 400, height: 300 })]) })
await mountPage({
list: listing([variant({ name: 'thumb', width: 400, height: 300 })]),
})
expect(normalized(headingBlock('Variants'))).toContain('1 enabled variant')
expect(normalized(headingBlock('Variants'))).not.toContain('1 enabled variants')
@@ -23,6 +23,7 @@ const SECTION_LABELS = [
'Users and invites',
'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only',
'Errors',
@@ -366,16 +367,16 @@ describe('Authentication', () => {
})
describe('Session only', () => {
it('renders the banner, 21 rows without expand controls and the 403 chips', async () => {
it('renders the banner, 25 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('21 routes')
expect(session.text()).toContain('25 routes')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(21)
expect(rows).toHaveLength(25)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
@@ -391,6 +392,10 @@ describe('Session only', () => {
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',
+36 -91
View File
@@ -5,11 +5,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import LoginView from '../LoginView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -23,9 +19,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -65,9 +59,7 @@ async function fillAndSubmit(
password: string,
) {
await wrapper.find('input[placeholder="Your username"]').setValue(username)
await wrapper
.find('input[autocomplete="current-password"]')
.setValue(password)
await wrapper.find('input[autocomplete="current-password"]').setValue(password)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
@@ -81,21 +73,16 @@ afterEach(async () => {
describe('Default — registration is closed', () => {
it('renders the default state with no create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () =>
mockResponse(200, { open: false }),
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
@@ -106,8 +93,7 @@ describe('Default — registration is closed', () => {
describe('Fresh install — registration is open', () => {
it('renders the same fields plus the create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () =>
mockResponse(200, { open: true }),
'GET /v1/auth/registration': () => mockResponse(200, { open: true }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
@@ -162,26 +148,18 @@ describe('Submitting — fields and button are disabled while the request is in
}),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(true)
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).disabled,
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Logging in…')
@@ -200,19 +178,13 @@ describe('Wrong credentials — the password is cleared and focused', () => {
await fillAndSubmit(wrapper, 'marta', 'wrong-password')
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
@@ -226,28 +198,20 @@ describe('Too many attempts — the button is disabled with a live countdown', (
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '42' }),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '42' }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash')
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash')
const button = wrapper.find('button[type="submit"]')
@@ -255,10 +219,8 @@ describe('Too many attempts — the button is disabled with a live countdown', (
expect((button.element as HTMLButtonElement).disabled).toBe(true)
const fields = () => [
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement,
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
]
expect(fields().map((field) => field.disabled)).toEqual([true, true])
@@ -266,10 +228,9 @@ describe('Too many attempts — the button is disabled with a live countdown', (
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
expect(
(wrapper.find('button[type="submit"]').element as HTMLButtonElement)
.disabled,
).toBe(false)
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(fields().map((field) => field.disabled)).toEqual([false, false])
vi.useRealTimers()
})
@@ -277,19 +238,14 @@ describe('Too many attempts — the button is disabled with a live countdown', (
describe('Session expired — redirected from a 401 elsewhere', () => {
it('shows the session-expired alert and focuses the password field', async () => {
const wrapper = await mountLogin(
'/login?redirect=/article/12/edit&reason=expired',
{},
)
const wrapper = await mountLogin('/login?redirect=/article/12/edit&reason=expired', {})
expect(wrapper.text()).toContain('Your session expired')
expect(wrapper.text()).toContain(
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
)
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(passwordInput.element).toBe(document.activeElement)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
})
@@ -337,28 +293,20 @@ describe('Server or network error — the fields keep their values', () => {
expect(login).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe(password)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Log in')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
},
)
})
@@ -377,15 +325,12 @@ describe('A successful log in navigates to the right destination', () => {
async ({ path, verified, destination }) => {
const wrapper = await mountLogin(path, {
'POST /v1/auth/login': () => mockResponse(200),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: verified }),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: verified }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash2026')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe(destination),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
},
)
})
@@ -89,7 +89,9 @@ describe('The Duplicates tab', () => {
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await settle()
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(router.currentRoute.value.query).toEqual({
filter: 'duplicates',
})
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
@@ -296,13 +298,20 @@ describe('Merge', () => {
const [call] = callsTo('POST', /merge/)
expect(call![0]).toBe('/v1/media/merge')
expect(JSON.parse(call![1].body)).toEqual({ keep_id: 12, merge_ids: [5, 6] })
expect(JSON.parse(call![1].body)).toEqual({
keep_id: 12,
merge_ids: [5, 6],
})
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
const confirm = findButton('Merging…', dialog()!)!
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialog()).not.toBeNull()
@@ -399,7 +408,9 @@ describe('Merge', () => {
)
it('a 409 keeps the dialog open with the conflict alert and reloads the list', async () => {
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(409)) })
await openMerge('floorboards.jpg', {
merge: () => Promise.reject(new ApiError(409)),
})
const before = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
@@ -412,7 +423,9 @@ describe('Merge', () => {
})
it('a 404 closes the dialog, shows the info toast and reloads the list', async () => {
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(404)) })
await openMerge('floorboards.jpg', {
merge: () => Promise.reject(new ApiError(404)),
})
const before = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
@@ -11,7 +11,10 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
return {
...actual,
useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }),
}
})
const KEY = 'renewcms-page-size-media'
@@ -28,7 +28,10 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
return {
...actual,
useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }),
}
})
const FIRST_PAGE_REQUEST = '/v1/media/?filter=all&sort=newest&page=1&size=25'
@@ -138,7 +141,13 @@ describe('46 Last page', () => {
describe('Single page', () => {
it('keeps the summary and the page-size select and hides the paginator', async () => {
await mountPage({ list: pagedHandler(library(12), { all: 12, unused: 2, duplicates: 2 }) })
await mountPage({
list: pagedHandler(library(12), {
all: 12,
unused: 2,
duplicates: 2,
}),
})
expect(summary()).toBe('Showing 1–12 of 12')
expect(sizeTrigger()).not.toBeNull()
@@ -329,7 +338,10 @@ describe('51 Page size menu', () => {
await choose(label)
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({ page: 1, size })
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({
page: 1,
size,
})
expect(path()).toBe(url)
})
})
@@ -378,12 +390,20 @@ describe('52 Focus-visible and keyboard', () => {
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
;(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
@@ -411,7 +431,13 @@ describe('The Duplicates tab', () => {
it('loads the whole set at size 100, groups it, shows no paginator and drops page and size', async () => {
const all = library(130, true)
await mountPage(
{ list: pagedHandler(all, { all: 312, unused: 2, duplicates: 65 }) },
{
list: pagedHandler(all, {
all: 312,
unused: 2,
duplicates: 65,
}),
},
'/media?filter=duplicates&page=4&size=5',
)
@@ -446,7 +472,11 @@ describe('Deleting the last image of the last page', () => {
{
list: (requested) => {
const live = removed ? all.slice(0, 25) : all
const counts = { all: live.length, unused: 2, duplicates: 2 }
const counts = {
all: live.length,
unused: 2,
duplicates: 2,
}
return pagedBody(live, requested, counts)
},
remove: () => {
@@ -38,7 +38,10 @@ const COUNTS = { all: 12, unused: 2, duplicates: 2 }
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
return (path) =>
path.includes('filter=duplicates')
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
? listBody(fixture(ids), {
...COUNTS,
duplicates: ids.length ? 2 : 0,
})
: listBody(fixture())
}
@@ -62,7 +65,9 @@ afterEach(unmountPage)
describe('The Duplicates toolbar button', () => {
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, {
role,
})
expect(purgeButton()).not.toBeNull()
expect(textOf(purgeButton())).toBe('Purge duplicates')
+19 -4
View File
@@ -34,7 +34,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
const toasts = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
@@ -237,7 +241,11 @@ describe('Escape and Clear', () => {
expect(panel()).not.toBeNull()
panel()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
@@ -390,7 +398,11 @@ describe('Bulk delete', () => {
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialog()).not.toBeNull()
@@ -471,7 +483,10 @@ describe('Bulk delete', () => {
duration: 0,
})
expect(toast.actions).toHaveLength(1)
expect(toast.actions[0]).toMatchObject({ label: 'Retry', icon: 'i-lucide-rotate-cw' })
expect(toast.actions[0]).toMatchObject({
label: 'Retry',
icon: 'i-lucide-rotate-cw',
})
})
it('Retry sends only the failed DELETE with no dialog and replaces the toast on success', async () => {
@@ -29,7 +29,10 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
return { ...actual, useToast: () => toasts }
})
const LOGIN = { path: '/login', query: { redirect: '/media', reason: 'expired' } }
const LOGIN = {
path: '/login',
query: { redirect: '/media', reason: 'expired' },
}
const DUPLICATES_LOGIN = {
path: '/login',
query: { redirect: '/media?filter=duplicates', reason: 'expired' },
@@ -95,7 +98,9 @@ describe('Session errors are global for the new calls', () => {
['DELETE /v1/media/{id}', 403, PENDING],
['DELETE /v1/media/{id}', 401, LOGIN],
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
await mountUnverified({ remove: () => Promise.reject(new ApiError(status)) })
await mountUnverified({
remove: () => Promise.reject(new ApiError(status)),
})
checkboxFor('north-wall.jpg').click()
await flushPromises()
findButton('Delete', bulkBar()!)!.click()
+23 -6
View File
@@ -104,7 +104,9 @@ describe('Upload entry points', () => {
)
it('and the library has no image: "Upload an image" opens the file chooser', async () => {
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
await mountPage({
list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }),
})
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
findButton('Upload an image')!.click()
@@ -398,7 +400,11 @@ describe('The upload panel and its rows', () => {
list: () =>
++calls === 1
? listBody(fixture())
: listBody(fixture(), { all: 13, unused: 2, duplicates: 2 }),
: listBody(fixture(), {
all: 13,
unused: 2,
duplicates: 2,
}),
})
expect(listCalls()).toHaveLength(1)
@@ -418,7 +424,10 @@ describe('A row whose content already exists', () => {
const existingText = 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.'
async function mountWithDuplicate(handlers: Handlers = {}) {
await mountPage({ hash: hashHandlerFor('floorboards-2.jpg'), ...handlers })
await mountPage({
hash: hashHandlerFor('floorboards-2.jpg'),
...handlers,
})
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
}
@@ -441,7 +450,9 @@ describe('A row whose content already exists', () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
const answers = [first, second]
await mountPage({ hash: () => (answers.shift() ?? deferred<unknown>()).promise })
await mountPage({
hash: () => (answers.shift() ?? deferred<unknown>()).promise,
})
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
@@ -530,7 +541,11 @@ describe('A row whose content already exists', () => {
list: () =>
++calls === 1
? listBody(fixture())
: listBody(fixture(), { all: 13, unused: 2, duplicates: 3 }),
: listBody(fixture(), {
all: 13,
unused: 2,
duplicates: 3,
}),
})
findButton('Upload anyway')!.click()
@@ -810,7 +825,9 @@ describe('Offline', () => {
stubDigest()
stubXhr()
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
await mountPage({
list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }),
})
await goOffline()
expect(findButton('Upload an image')!.disabled).toBe(true)
})
@@ -27,7 +27,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
const toasts = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
@@ -31,7 +31,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
const toasts = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
@@ -104,7 +108,10 @@ function variantRequests(method = 'GET') {
beforeEach(() => {
toasts.add.mockClear()
writeText.mockReset().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
@@ -151,7 +158,9 @@ describe('The panel tabs', () => {
})
it('gives an admin the tabs too', async () => {
await mountTab({ variants: () => variantList() }, '/media', undefined, { role: 'admin' })
await mountTab({ variants: () => variantList() }, '/media', undefined, {
role: 'admin',
})
cardFor('north-wall.jpg').click()
await flushPromises()
@@ -431,7 +440,12 @@ describe('Generating', () => {
).toEqual(['card', 'hero', 'og', 'square'])
expect(textOf(rowOf('thumb'))).toContain('Generated')
pending.resolve({ generated: 4, failed: 0, failures: [], items: variantItems() })
pending.resolve({
generated: 4,
failed: 0,
failures: [],
items: variantItems(),
})
await flushPromises()
expect(rows().every((row) => textOf(row).includes('Generated'))).toBe(true)
@@ -443,7 +457,12 @@ describe('Generating', () => {
it('A single generated variant reads in the singular', async () => {
await mountTab({
variants: () => variantList(PARTLY),
generateAll: () => ({ generated: 1, failed: 0, failures: [], items: variantItems() }),
generateAll: () => ({
generated: 1,
failed: 0,
failures: [],
items: variantItems(),
}),
})
await openVariants()
@@ -617,7 +636,10 @@ describe('Generating', () => {
it('announces the progress in a polite live region', async () => {
const pending = deferred<unknown>()
await mountTab({ variants: () => variantList(PARTLY), generate: () => pending.promise })
await mountTab({
variants: () => variantList(PARTLY),
generate: () => pending.promise,
})
await openVariants()
rowAction('og', 'Generate now')!.click()
+66 -16
View File
@@ -281,7 +281,9 @@ function dispatcher(handlers: Handlers) {
}
const single = /^\/v1\/media\/(\d+)$/.exec(path)
if (method === 'PATCH' && single) {
const body = JSON.parse(String(options?.body)) as { filename: string }
const body = JSON.parse(String(options?.body)) as {
filename: string
}
const id = Number(single[1])
return (
handlers.rename ??
@@ -302,7 +304,13 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
}
session.status = 'ready'
const dispatch = dispatcher(handlers)
requestMock = vi.fn(async (path: string, options?: RequestInit) => dispatch(path, options))
@@ -515,7 +523,12 @@ describe('Badges and accessible names', () => {
describe('Grid, few images', () => {
it('keeps three cards at the grid size, a checkerboard behind the SVG and no Duplicates badge', async () => {
await mountPage({
list: () => listBody(fixture([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }),
list: () =>
listBody(fixture([1, 4, 8]), {
all: 3,
unused: 1,
duplicates: 0,
}),
})
expect(cards()).toHaveLength(3)
@@ -663,7 +676,10 @@ describe('The query string is the source of truth', () => {
tab('Unused').focus()
press(tab('Unused'), 'Enter')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ q: 'wall', filter: 'unused' })
expect(router.currentRoute.value.query).toEqual({
q: 'wall',
filter: 'unused',
})
expect(listCalls()).toHaveLength(3)
ariaLabel('Sort').dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
@@ -684,7 +700,10 @@ describe('The query string is the source of truth', () => {
tab('All').focus()
press(tab('All'), 'Enter')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ q: 'wall', sort: 'largest' })
expect(router.currentRoute.value.query).toEqual({
q: 'wall',
sort: 'largest',
})
})
it('removes q from the URL when the search is emptied', async () => {
@@ -712,7 +731,9 @@ describe('The Duplicates tab', () => {
await flushPromises()
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(router.currentRoute.value.query).toEqual({
filter: 'duplicates',
})
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
})
})
@@ -765,7 +786,9 @@ describe('Loading', () => {
const second = deferred<unknown>()
let calls = 0
await mountPage(
{ list: () => (++calls === 1 ? listBody(fixture()) : second.promise) },
{
list: () => (++calls === 1 ? listBody(fixture()) : second.promise),
},
'/media?view=list',
)
expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12)
@@ -785,7 +808,9 @@ describe('Loading', () => {
describe('Empty library and no results', () => {
it('shows one call to action and hides the toolbar and tabs', async () => {
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
await mountPage({
list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }),
})
expect(tabs()).toHaveLength(0)
expect(searchInput()).toBeNull()
@@ -885,7 +910,11 @@ describe('Hover, focus and keyboard', () => {
cards()[1]!.focus()
press(cards()[1]!, ' ')
cards()[1]!.dispatchEvent(
new KeyboardEvent('keyup', { key: ' ', bubbles: true, cancelable: true }),
new KeyboardEvent('keyup', {
key: ' ',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(panel()).toBeNull()
@@ -906,7 +935,11 @@ describe('Hover, focus and keyboard', () => {
expect(panel()).not.toBeNull()
panel()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settleFocus()
@@ -1012,7 +1045,9 @@ describe('Detail panel', () => {
review.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/media')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(router.currentRoute.value.query).toEqual({
filter: 'duplicates',
})
expect(panel()).toBeNull()
})
@@ -1042,7 +1077,10 @@ describe('Detail panel', () => {
describe('Copy URL', () => {
it('writes the absolute address to the clipboard and toasts', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
await mountPage()
await clickCard('north-wall.jpg')
@@ -1147,7 +1185,11 @@ describe('Rename', () => {
expect(confirm.querySelector('svg')).toBeTruthy()
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialog()).not.toBeNull()
@@ -1270,7 +1312,11 @@ describe('Delete', () => {
expect(confirm.disabled).toBe(true)
expect(dialog()!.getAttribute('aria-busy')).toBe('true')
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialog()).not.toBeNull()
@@ -1415,7 +1461,9 @@ describe('List failed to load', () => {
])('shows the error alert after %s and Try again recovers', async (_name, failure) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let calls = 0
await mountPage({ list: () => (++calls === 1 ? failure() : listBody(fixture())) })
await mountPage({
list: () => (++calls === 1 ? failure() : listBody(fixture())),
})
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toBe(
@@ -1486,7 +1534,9 @@ describe('Offline', () => {
describe('Session errors are global', () => {
it.each([401, 403])('a %i from the list shows no local media copy', async (status) => {
await mountPage({ list: () => Promise.reject(new ApiError(status)) })
await mountPage({
list: () => Promise.reject(new ApiError(status)),
})
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(cards()).toHaveLength(0)
+4 -1
View File
@@ -257,7 +257,10 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(session.session).toMatchObject({ verified: true, displayName: '' })
expect(session.session).toMatchObject({
verified: true,
displayName: '',
})
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
@@ -0,0 +1,282 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import type { Author } from '@/composables/useAuthors'
import {
MARTA_EMAIL,
boardAuthors,
callsTo,
click,
createMetaBackend,
dialog,
failWith,
mountMeta,
networkFailure,
textOf,
toasts,
typeInto,
unmountMeta,
type MetaBackend,
type MetaRole,
} from './metaTestKit'
import { useSessionStore } from '@/stores/session'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
})
afterEach(unmountMeta)
const PROFILE = '/profile'
const MARTA_AUTHOR = { id: 300, name: 'Marta Alves', slug: 'marta-alves' }
function martaRecord(): Author {
return {
...boardAuthors()[0]!,
...MARTA_AUTHOR,
bio: '',
website: null,
user_id: 1,
is_linked: true,
user_email: MARTA_EMAIL,
}
}
function backendWith(role: MetaRole, withAuthor = false): MetaBackend {
const backend = createMetaBackend({
role,
author: withAuthor ? MARTA_AUTHOR : undefined,
})
if (withAuthor) {
backend.authors.push(martaRecord())
}
return backend
}
function card() {
return Array.from(document.body.querySelectorAll<HTMLElement>('section')).find(
(section) => section.querySelector('h2')?.textContent?.trim() === 'Author profile',
)!
}
function cardTitles() {
return Array.from(document.body.querySelectorAll('section h2')).map(textOf)
}
describe('Profile: the Author profile card sits between Account and Password', () => {
it.each(['owner', 'admin', 'verified'] as const)('for a %s', async (role) => {
await mountMeta('profile', PROFILE, { backend: backendWith(role) })
expect(cardTitles()).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
})
})
describe('Profile: no author profile', () => {
it('shows the invitation, the button and the line with the display name', async () => {
await mountMeta('profile', PROFILE, { backend: backendWith('owner') })
const text = textOf(card())
expect(text).toContain("Your public byline on articles. It's separate from your account.")
expect(text).toContain(
"You don't have an author profile yet. Create one to be credited on your own articles.",
)
expect(text).toContain('Create my author profile')
expect(text).toContain('Uses your display name, Marta Alves. You can change it afterwards.')
})
it('falls back to the username when there is no display name', async () => {
const backend = backendWith('owner')
backend.me.displayName = ''
await mountMeta('profile', PROFILE, { backend })
expect(textOf(card())).toContain('Uses your username, marta. You can change it afterwards.')
})
})
describe('Profile: creating the author profile', () => {
it('sends one POST, shows the author with Edit author and keeps the author in the session', async () => {
const backend = backendWith('verified')
await mountMeta('profile', PROFILE, { backend })
await click('Create my author profile', card())
expect(callsTo(backend, 'POST', '/v1/author/me')).toHaveLength(1)
expect(callsTo(backend, 'POST', '/v1/author/me')[0]!.body).toBeUndefined()
const text = textOf(card())
expect(text).toContain('Marta Alves')
expect(text).toContain('marta-alves')
expect(text).toContain('Edit author')
expect(text).toContain('Linked to your account. New articles start with this author.')
expect(text).not.toContain('Your slug is')
expect(toasts()).toContain('Author profile created')
expect(toasts()).toContain('Marta Alves is now your byline.')
expect(useSessionStore().session?.author).toEqual(MARTA_AUTHOR)
})
it('explains an adjusted slug without any error', async () => {
const backend = backendWith('owner')
backend.meAuthorSlug = 'marta-alves-2'
backend.meSlugAdjusted = true
await mountMeta('profile', PROFILE, { backend })
await click('Create my author profile', card())
const text = textOf(card())
expect(text).toContain('Your slug is marta-alves-2')
expect(text).toContain(
'marta-alves was already used by another author. You can change it in Edit author.',
)
expect(card().querySelector('[role="alert"]')).toBeNull()
})
it.each([
{ name: 'Marta Alves', returned: 'marta-alves', adjusted: false },
{ name: 'Marta Alves', returned: 'marta-alves-2', adjusted: true },
{ name: 'Inès Duarte', returned: 'ines-duarte', adjusted: false },
{ name: 'Inès Duarte', returned: 'ines-duarte-2', adjusted: true },
{
name: "Jean-Luc O'Neil",
returned: 'jean-luc-o-neil',
adjusted: false,
},
{
name: "Jean-Luc O'Neil",
returned: 'jean-luc-o-neil-2',
adjusted: true,
},
{ name: 'Zoë Müller', returned: 'zoe-muller', adjusted: false },
{ name: 'Agent 007', returned: 'agent-007', adjusted: false },
{ name: 'Agent 007', returned: 'agent-007-2', adjusted: true },
])('$name returned as $returned: notice $adjusted', async ({ name, returned, adjusted }) => {
const backend = backendWith('owner')
backend.me.displayName = name
backend.meAuthorSlug = returned
backend.meSlugAdjusted = adjusted
await mountMeta('profile', PROFILE, { backend })
await click('Create my author profile', card())
expect(textOf(card()).includes('Your slug is')).toBe(adjusted)
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows the error and keeps the button after $failure', async ({ responder }) => {
const backend = backendWith('owner')
backend.responders['POST /v1/author/me'] = responder
await mountMeta('profile', PROFILE, { backend })
await click('Create my author profile', card())
const text = textOf(card())
expect(text).toContain("Couldn't create your author profile")
expect(text).toContain('Try again in a moment.')
expect(text).toContain('Create my author profile')
expect(card().querySelector('button[disabled]')).toBeNull()
})
it('asks the session again and shows the existing author on a 409', async () => {
const backend = backendWith('owner')
backend.responders['POST /v1/author/me'] = (call) => {
backend.me.author = MARTA_AUTHOR
return failWith(409, 'user_already_linked')(call)
}
await mountMeta('profile', PROFILE, { backend })
await click('Create my author profile', card())
expect(callsTo(backend, 'GET', '/v1/auth/me')).toHaveLength(1)
expect(textOf(card())).toContain('Edit author')
expect(textOf(card())).toContain('marta-alves')
})
})
describe('Profile: an existing author on load', () => {
it('shows the author, Edit author and the link line without any created notice', async () => {
const backend = backendWith('owner', true)
await mountMeta('profile', PROFILE, { backend })
expect(callsTo(backend, 'POST', '/v1/author/me')).toHaveLength(0)
const text = textOf(card())
expect(text).toContain('Marta Alves')
expect(text).toContain('Edit author')
expect(text).toContain('Linked to your account. New articles start with this author.')
expect(text).not.toContain('Create my author profile')
})
})
describe('Profile: editing the own author', () => {
function panel() {
return dialog()!
}
it('opens the form for a role without author rights with the link locked and no Unlink or delete', async () => {
const backend = backendWith('verified', true)
await mountMeta('profile', PROFILE, { backend })
await click('Edit author', card())
const text = textOf(panel())
expect(text).toContain('Edit Marta Alves')
expect(text).toContain('Linked user')
expect(text).toContain(MARTA_EMAIL)
expect(text).toContain(
'This is your author profile. Only an owner or admin can change the link.',
)
expect(text).toContain('Save changes')
expect(text).not.toContain('Unlink')
expect(textOf(document.body)).not.toContain('Delete')
})
it('sends one PATCH with only the changed fields', async () => {
const backend = backendWith('verified', true)
await mountMeta('profile', PROFILE, { backend })
await click('Edit author', card())
const bio = panel().querySelector<HTMLTextAreaElement>(
'textarea[placeholder="A sentence or two about the author."]',
)!
await typeInto(bio, 'Writes about lime.')
await click('Save changes', panel())
const patches = callsTo(backend, 'PATCH', '/v1/author/300')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ bio: 'Writes about lime.' })
})
it.each(['owner', 'admin'] as const)(
'opens the full form in place for an %s, with Unlink allowed',
async (role) => {
const backend = backendWith(role, true)
await mountMeta('profile', PROFILE, { backend })
await click('Edit author', card())
expect(textOf(panel())).toContain('Edit Marta Alves')
expect(textOf(panel())).toContain('Unlink')
expect(textOf(panel())).not.toContain(
'This is your author profile. Only an owner or admin can change the link.',
)
},
)
it('shows the no-profile card again after an owner unlinks the author', async () => {
const backend = backendWith('owner', true)
await mountMeta('profile', PROFILE, { backend })
await click('Edit author', card())
await click('Unlink', panel())
await flushPromises()
await click('Unlink user')
expect(callsTo(backend, 'PATCH', '/v1/author/300')[0]!.body).toEqual({
user_id: null,
})
expect(useSessionStore().session?.author).toBeUndefined()
})
})
+45 -16
View File
@@ -106,7 +106,10 @@ async function mountApp(options: MountOptions = {}): Promise<Mounted> {
return Promise.resolve([])
})
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
vi.mocked(useApi).mockReturnValue({
getMe,
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(path)
const wrapper = mount(App, {
attachTo: document.body,
@@ -130,7 +133,11 @@ async function typeInto(value: string) {
function pressEnter() {
displayNameField().dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
}
@@ -198,7 +205,11 @@ function activate(element: HTMLElement) {
}
function savedResponse(displayName: string) {
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
return Promise.resolve({
id: 1,
username: 'marta',
display_name: displayName,
})
}
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
@@ -287,18 +298,18 @@ describe('Default: the usual first visit with no display name', () => {
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the three cards in order Display name, Account, Password', async () => {
it('places the four cards in order Display name, Account, Author profile, Password', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
el.textContent?.trim(),
)
expect(titles).toEqual(['Display name', 'Account', 'Password'])
expect(titles).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
})
})
describe('Loading: opened by URL before /auth/me has returned', () => {
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
it('shows the header and four skeleton cards without AppBoot, then the Default state', async () => {
const pending = deferred<Session>()
await mountApp({
session: null,
@@ -311,7 +322,7 @@ describe('Loading: opened by URL before /auth/me has returned', () => {
)
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(4)
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
expect(displayNameField()).toBeNull()
@@ -430,7 +441,9 @@ describe('Dirty is measured on the trimmed draft against the saved name', () =>
describe('Enter saves when Save is enabled', () => {
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
},
})
await typeInto('Marta Alves')
@@ -477,7 +490,9 @@ describe('Saving: the field is disabled and a second submit is ignored', () => {
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
it('replaces the session name, syncs the field, and raises the success toast', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
},
})
await typeInto('Marta Alves')
@@ -503,7 +518,9 @@ describe('Saved: a 200 updates the session, the chip, the footer and raises a to
it('opens no leave confirmation when navigating away afterwards', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
},
})
await typeInto('Marta Alves')
await clickSave()
@@ -519,7 +536,9 @@ describe('Saved: a 200 updates the session, the chip, the footer and raises a to
describe("The server's value wins over what was typed", () => {
it('uses the response display_name in the field, the chip and the toast', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
},
})
await typeInto(' Marta Alves ')
@@ -535,7 +554,9 @@ describe('Clearing the display name goes back to the username', () => {
it('sends an empty display_name and shows the username with the removed toast', async () => {
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse(''),
},
})
await typeInto('')
@@ -565,7 +586,9 @@ describe('Save failed: a 500 or a network failure keeps the text and offers a re
async (_label, failure) => {
const attempts = [failure, () => savedResponse('Marta Alves')]
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!(),
},
})
await typeInto('Marta Alves')
@@ -594,7 +617,9 @@ describe('Save failed: a 500 or a network failure keeps the text and offers a re
const retry = deferred()
const attempts = [SERVER_ERROR, () => retry.promise]
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!(),
},
})
await typeInto('Marta Alves')
await clickSave()
@@ -727,7 +752,9 @@ describe('The Home greeting follows a display name saved on /profile', () => {
it('shows the saved name on /home without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
},
})
await typeInto('Marta Alves')
await clickSave()
@@ -851,7 +878,9 @@ describe('Reset: failed leaves the button pressable', () => {
async (_label, failure) => {
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
handlers: {
[`POST ${RESET_PATH}`]: () => responses.shift()!(),
},
})
await clickReset()
@@ -41,7 +41,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
const toasts = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
@@ -284,7 +288,9 @@ describe('Purge dialog: leaving', () => {
expect(dialogEl()).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(router.currentRoute.value.query).toEqual({
filter: 'duplicates',
})
})
})
@@ -313,9 +319,12 @@ describe('Purge dialog: purging', () => {
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
pressEscape(dialogEl())
document.body
.querySelector('[data-slot="overlay"]')
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
document.body.querySelector('[data-slot="overlay"]')?.dispatchEvent(
new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialogEl()).not.toBeNull()
@@ -325,7 +334,9 @@ describe('Purge dialog: purging', () => {
})
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
const opener = await opened({
settings: afterRefresh(purgedSettings()),
})
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
@@ -351,7 +362,10 @@ describe('Purge dialog: purging', () => {
it('shows no Next purge when automatic purge is off', async () => {
await opened({
settings: afterRefresh(
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
purgedSettings({
auto_purge_enabled: false,
next_purge_at: null,
}),
),
})
@@ -363,13 +377,17 @@ describe('Purge dialog: purging', () => {
})
it('pluralises the toast', async () => {
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
await opened({
purge: () => purgeResult({ merged: 1, articles_updated: 1 }),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
expect.objectContaining({
description: '1 copy merged. 1 article updated.',
}),
)
})
@@ -402,7 +420,10 @@ describe('Purge dialog: failures', () => {
])(
'keeps the dialog open after %s and retries with the same fingerprint',
async (_name, purge) => {
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
await opened({
preview: () => previewOf({ fingerprint: 'ab12' }),
purge,
})
confirmButton('Purge 4 groups').click()
await flushPromises()
@@ -513,11 +534,15 @@ describe('Purge dialog: failures', () => {
confirmButton('Purge 3 groups').click()
await flushPromises()
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({
fingerprint: 'cd34',
})
})
it('shows the server text when a purge is already running', async () => {
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
await opened({
purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
@@ -40,7 +40,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
const toasts = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
}))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
@@ -151,7 +155,10 @@ describe('Media cleanup card: the switch', () => {
expect(switchEl().disabled).toBe(false)
patch.resolve(
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
enabledSettings({
last_purge_at: null,
next_purge_at: '2026-11-04T09:00:00Z',
}),
)
await flushPromises()
@@ -169,7 +176,10 @@ describe('Media cleanup card: the switch', () => {
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
await mountSettings({
settings: patching(() =>
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
enabledSettings({
auto_purge_enabled: false,
next_purge_at: null,
}),
),
})
@@ -290,7 +300,9 @@ describe('Media cleanup card: save failures', () => {
})
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
await mountSettings({
settings: patching(() => Promise.reject(new ApiError(500))),
})
switchEl().click()
await flushPromises()
@@ -321,7 +333,9 @@ describe('Media cleanup card: save failures', () => {
})
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
await mountSettings({
settings: patching(() => Promise.reject(new ApiError(403))),
})
const refresh = vi.spyOn(useSessionStore(), 'refresh')
purgeKit.meRole = 'editor'
@@ -400,7 +414,9 @@ describe('Media cleanup card: loading and failure to load', () => {
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
let failing = true
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
await mountSettings({
settings: () => (failing ? failure() : purgeSettings()),
})
const alert = cardEl().querySelector('[role="alert"]')!
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
+94 -21
View File
@@ -74,7 +74,11 @@ let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }), useOverlay: () => overlayMock }
return {
...actual,
useToast: () => ({ add: toastAdd }),
useOverlay: () => overlayMock,
}
})
type Outcome = UpdateStatus | Error
@@ -121,7 +125,12 @@ async function mountSettings(options: MountOptions) {
toastAdd = vi.fn()
overlayMock = createOverlayMock()
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
session.session = {
verified: true,
username: 'marta',
id: 1,
role: options.role,
}
session.status = 'ready'
const statusHandler = asHandler(options.status)
const restartHandler = asHandler(options.restart ?? new ApiError(500))
@@ -171,7 +180,9 @@ async function mountSettings(options: MountOptions) {
}
function useTimers() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
})
}
async function tick(milliseconds: number) {
@@ -257,7 +268,11 @@ async function press(label: string) {
async function pressEscape() {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
}),
)
await flushPromises()
}
@@ -314,7 +329,10 @@ describe('Settings, owner, Restart buttons', () => {
})
it('idle (Up to date): Restart is an enabled secondary button next to Check for updates', async () => {
await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }) })
await mountSettings({
role: 'owner',
status: upToDate({ can_apply: true }),
})
expect(cardButtons()).toEqual(['Check for updates', 'Restart'])
const restart = findButton('Restart') as HTMLButtonElement
@@ -348,8 +366,16 @@ describe('Settings, owner, Restart buttons', () => {
})
it.each([
{ name: 'Not prepared', role: 'owner' as const, status: newerAvailable() },
{ name: 'an admin, up to date', role: 'admin' as const, status: upToDate() },
{
name: 'Not prepared',
role: 'owner' as const,
status: newerAvailable(),
},
{
name: 'an admin, up to date',
role: 'admin' as const,
status: upToDate(),
},
{
name: 'an admin, helper installed',
role: 'admin' as const,
@@ -418,14 +444,21 @@ describe('Settings, Restart: confirm (Install dialog)', () => {
it('Restart now sends {"kind":"update"}', async () => {
useTimers()
await mountSettings({ role: 'owner', status: ready(), restart: restarting() })
await mountSettings({
role: 'owner',
status: ready(),
restart: restarting(),
})
await press('Restart')
await confirmRestart()
expect(requestMock).toHaveBeenCalledWith(
RESTART_PATH,
expect.objectContaining({ method: 'POST', body: JSON.stringify({ kind: 'update' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ kind: 'update' }),
}),
)
})
})
@@ -454,12 +487,18 @@ describe('Settings, Plain restart: confirm', () => {
expect(requestMock).toHaveBeenCalledWith(
RESTART_PATH,
expect.objectContaining({ method: 'POST', body: JSON.stringify({ kind: 'restart' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ kind: 'restart' }),
}),
)
})
it('renders with focus on Cancel and closing returns focus to Restart', async () => {
await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }) })
await mountSettings({
role: 'owner',
status: upToDate({ can_apply: true }),
})
await press('Restart')
const dialog = mountRealDialog(dialogProps())
@@ -487,9 +526,16 @@ describe('Settings, Restart: request failed', () => {
async ({ error }) => {
for (const scenario of [
{ status: ready(), confirm: 'Restart now' },
{ status: upToDate({ can_apply: true }), confirm: 'Restart site' },
{
status: upToDate({ can_apply: true }),
confirm: 'Restart site',
},
]) {
await mountSettings({ role: 'owner', status: scenario.status, restart: error })
await mountSettings({
role: 'owner',
status: scenario.status,
restart: error,
})
await press('Restart')
const dialog = mountRealDialog(dialogProps())
await flushPromises()
@@ -531,7 +577,10 @@ describe('Settings, this tab remembers that it started the restart', () => {
await mountSettings({
role: 'owner',
status: ready(),
restart: startable({ state: 'restarting', started_at: '2026-09-30T09:51:07Z' }),
restart: startable({
state: 'restarting',
started_at: '2026-09-30T09:51:07Z',
}),
})
const storageBefore = JSON.stringify({ ...localStorage })
await press('Restart')
@@ -539,7 +588,10 @@ describe('Settings, this tab remembers that it started the restart', () => {
await confirmRestart()
expect(sessionStorage.length).toBe(1)
expect(remembered()).toEqual({ kind: 'update', started_at: '2026-09-30T09:51:07Z' })
expect(remembered()).toEqual({
kind: 'update',
started_at: '2026-09-30T09:51:07Z',
})
expect(JSON.stringify({ ...localStorage })).toBe(storageBefore)
expect(overlayElement()).not.toBeNull()
})
@@ -548,7 +600,11 @@ describe('Settings, this tab remembers that it started the restart', () => {
describe('Settings, Restarting to install', () => {
async function startInstallRestart() {
useTimers()
await mountSettings({ role: 'owner', status: ready(), restart: restarting() })
await mountSettings({
role: 'owner',
status: ready(),
restart: restarting(),
})
await press('Restart')
await confirmRestart()
}
@@ -617,7 +673,11 @@ describe('Settings, Plain restart: restarting', () => {
describe('Settings, Restarting, taking longer', () => {
it('after 60 seconds without an answer shows Still restarting and a Reload page button', async () => {
useTimers()
await mountSettings({ role: 'owner', status: ready(), restart: restarting() })
await mountSettings({
role: 'owner',
status: ready(),
restart: restarting(),
})
await press('Restart')
await confirmRestart()
silenceStatusReads()
@@ -834,7 +894,10 @@ describe('A later visit shows the card with no toast and no focus move', () => {
expect(toastAdd).not.toHaveBeenCalled()
expect(document.activeElement?.id).not.toBe('updates-h')
expect(remembered()).toEqual({ kind: 'update', started_at: RESTART_FINISHED_AT })
expect(remembered()).toEqual({
kind: 'update',
started_at: RESTART_FINISHED_AT,
})
})
})
@@ -907,12 +970,18 @@ describe('Settings, a stale sessionStorage entry', () => {
await mountSettings({
role: 'owner',
status: upToDate({ can_apply: true }),
restart: startable({ state: 'restarting', started_at: '2026-09-30T10:00:00Z' }),
restart: startable({
state: 'restarting',
started_at: '2026-09-30T10:00:00Z',
}),
})
await press('Restart')
await confirmRestart()
expect(remembered()).toEqual({ kind: 'restart', started_at: '2026-09-30T10:00:00Z' })
expect(remembered()).toEqual({
kind: 'restart',
started_at: '2026-09-30T10:00:00Z',
})
})
})
@@ -1027,7 +1096,11 @@ describe('Settings, 403 and 401 on Restart', () => {
})
it('401: the dialog closes and no local copy shows', async () => {
await mountSettings({ role: 'owner', status: ready(), restart: new ApiError(401) })
await mountSettings({
role: 'owner',
status: ready(),
restart: new ApiError(401),
})
await press('Restart')
await confirmRestart()
@@ -75,7 +75,10 @@ async function mountPage(
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: getMeMock,
request: requestMock,
})
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
@@ -353,7 +356,10 @@ describe('Settings retention, shortening asks first', () => {
it('describes a shortening from Forever without a date', async () => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
settings({
activity_retention_days: 0,
activity_next_cleanup_at: null,
}),
),
)
@@ -440,7 +446,12 @@ describe('Settings retention, save error reverts the control and Try again retri
() => Promise.resolve(settings()),
() => Promise.reject(new ApiError(403)),
)
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
getMeMock.mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: 'admin',
})
await choose(365)
@@ -535,7 +546,10 @@ describe('Settings retention size limit, states', () => {
it('Forever with no size limit', async () => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
settings({
activity_retention_days: 0,
activity_next_cleanup_at: null,
}),
),
)
@@ -551,7 +565,10 @@ describe('Settings retention size limit, states', () => {
])('formats %s bytes as "%s"', async (bytes, shown) => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_max_gb: 5, activity_log_size_bytes: bytes }),
settings({
activity_retention_max_gb: 5,
activity_log_size_bytes: bytes,
}),
),
)
@@ -673,7 +690,10 @@ describe('Settings retention size limit, one warning at a time', () => {
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() =>
Promise.resolve(
settings({ activity_retention_days: 365, activity_retention_max_gb: 10 }),
settings({
activity_retention_days: 365,
activity_retention_max_gb: 10,
}),
),
)
await chooseSize(5)
@@ -776,7 +796,12 @@ describe('Settings retention size limit, save errors revert only the size select
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => Promise.reject(new ApiError(403)),
)
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
getMeMock.mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: 'admin',
})
await chooseSize(10)
@@ -77,7 +77,12 @@ async function mountSettings(options: MountOptions) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
session.session = {
verified: true,
username: 'marta',
id: 1,
role: options.role,
}
session.status = 'ready'
const statusHandler = asHandler(options.status)
const downloadHandler = asHandler(options.download ?? new ApiError(500))
@@ -103,7 +108,10 @@ async function mountSettings(options: MountOptions) {
displayName: '',
role: options.meAfterRefresh ?? options.role,
})
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: getMeMock,
request: requestMock,
})
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
@@ -197,12 +205,18 @@ describe('Settings, owner, Update available with the helper detected', () => {
describe('Settings, owner, Downloading', () => {
it('shows the card copy, the progressbar, the text and a disabled Updating button', async () => {
useTimers()
await mountSettings({ role: 'owner', status: startable(), download: downloading() })
await mountSettings({
role: 'owner',
status: startable(),
download: downloading(),
})
await pressUpdate()
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1)
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, {
method: 'POST',
})
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
expect(cardText()).toContain(
'You can leave this page. The download continues and your site stays online.',
@@ -349,7 +363,11 @@ describe('Settings, Download failed', () => {
download: downloading(),
},
{ name: 'a 500', status: startable(), download: new ApiError(500) },
{ name: 'a network error', status: startable(), download: new NetworkError() },
{
name: 'a network error',
status: startable(),
download: new NetworkError(),
},
])('shows the error card and focuses the heading after $name', async (scenario) => {
useTimers()
await mountSettings({
@@ -411,7 +429,11 @@ describe('Settings, Verification failed', () => {
})
it('shows the warning card after a 422', async () => {
await mountSettings({ role: 'owner', status: startable(), download: new ApiError(422) })
await mountSettings({
role: 'owner',
status: startable(),
download: new ApiError(422),
})
await pressUpdate()
+77 -17
View File
@@ -65,7 +65,12 @@ async function mountSettings(options: MountOptions) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
session.session = {
verified: true,
username: 'marta',
id: 1,
role: options.role,
}
session.status = 'ready'
const statusHandler = asHandler(options.status, () => Promise.resolve(upToDate()))
const checkHandler = asHandler(options.check, () => Promise.resolve(upToDate()))
@@ -91,7 +96,10 @@ async function mountSettings(options: MountOptions) {
displayName: '',
role: options.meAfterRefresh ?? options.role,
})
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: getMeMock,
request: requestMock,
})
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
@@ -161,7 +169,11 @@ describe('Settings, owner, Up to date', () => {
describe('Settings, owner, Checking', () => {
it('shows the polite block, a disabled Checking button and sends one POST', async () => {
await mountSettings({ role: 'owner', status: upToDate(), check: pendingForever() })
await mountSettings({
role: 'owner',
status: upToDate(),
check: pendingForever(),
})
findButton('Check for updates')!.click()
await flushPromises()
@@ -178,7 +190,11 @@ describe('Settings, owner, Checking', () => {
})
it('shows the result once the check resolves', async () => {
await mountSettings({ role: 'owner', status: upToDate(), check: newerAvailable() })
await mountSettings({
role: 'owner',
status: upToDate(),
check: newerAvailable(),
})
findButton('Check for updates')!.click()
await flushPromises()
@@ -190,9 +206,16 @@ describe('Settings, owner, Checking', () => {
describe('Settings, owner, Check failed', () => {
it.each([
{ name: 'a 500', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
{
name: 'a network failure',
failure: () => Promise.reject(new NetworkError()),
},
])('keeps the last result and offers only Try again after $name', async ({ failure }) => {
await mountSettings({ role: 'owner', status: upToDate(), check: failure })
await mountSettings({
role: 'owner',
status: upToDate(),
check: failure,
})
findButton('Check for updates')!.click()
await flushPromises()
@@ -233,7 +256,10 @@ describe('Settings, owner, Check failed', () => {
expect(cardButtons()).toEqual(['Try again'])
remount()
await mountSettings({ role: 'admin', status: () => Promise.reject(new ApiError(500)) })
await mountSettings({
role: 'admin',
status: () => Promise.reject(new ApiError(500)),
})
expect(document.body.querySelector('[data-testid="updates-card"]')).toBeNull()
})
})
@@ -243,7 +269,11 @@ describe('Settings, owner, newer version, not prepared', () => {
await mountSettings({
role: 'owner',
status: newerAvailable({
notes: { version: NEW_TAG, released_at: '2026-09-24T08:00:00Z', items: [] },
notes: {
version: NEW_TAG,
released_at: '2026-09-24T08:00:00Z',
items: [],
},
}),
})
@@ -341,7 +371,10 @@ describe('Settings, admin view', () => {
})
it('shows up to date with the owner-only note and no button, and never checks', async () => {
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
await mountSettings({
role: 'admin',
status: upToDate({ checked_at: null }),
})
expect(cardText()).toContain("You're up to date")
expect(cardText()).toContain('Only the owner can update or restart the site.')
@@ -410,13 +443,19 @@ describe('Settings, Too many requests (429)', () => {
describe('A check runs on the first open after 24 hours, for the owner only', () => {
it('checks once when checked_at is null', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: null }) })
await mountSettings({
role: 'owner',
status: upToDate({ checked_at: null }),
})
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('checks once when checked_at is older than 24 hours', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(25) }) })
await mountSettings({
role: 'owner',
status: upToDate({ checked_at: hoursAgo(25) }),
})
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
@@ -432,17 +471,26 @@ describe('A check runs on the first open after 24 hours, for the owner only', ()
})
it('sends no check when checked_at is under 24 hours old', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(23) }) })
await mountSettings({
role: 'owner',
status: upToDate({ checked_at: hoursAgo(23) }),
})
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('never sends one for an admin', async () => {
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
await mountSettings({
role: 'admin',
status: upToDate({ checked_at: null }),
})
expect(callsTo(CHECK_PATH)).toHaveLength(0)
remount()
await mountSettings({ role: 'admin', status: upToDate({ checked_at: hoursAgo(48) }) })
await mountSettings({
role: 'admin',
status: upToDate({ checked_at: hoursAgo(48) }),
})
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
})
@@ -450,9 +498,21 @@ describe('A check runs on the first open after 24 hours, for the owner only', ()
describe('No Update, Restart or progress element exists when the server is not prepared or for admins', () => {
it.each([
{ name: 'up to date', role: 'owner' as const, status: upToDate() },
{ name: 'not prepared', role: 'owner' as const, status: newerAvailable() },
{ name: 'admin available', role: 'admin' as const, status: newerAvailable() },
{ name: 'admin up to date', role: 'admin' as const, status: upToDate() },
{
name: 'not prepared',
role: 'owner' as const,
status: newerAvailable(),
},
{
name: 'admin available',
role: 'admin' as const,
status: newerAvailable(),
},
{
name: 'admin up to date',
role: 'admin' as const,
status: upToDate(),
},
])('renders none for $name', async ({ role, status }) => {
await mountSettings({ role, status })
+14 -3
View File
@@ -33,7 +33,12 @@ function deferred<T>() {
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
session.session = {
verified: true,
username: 'marta',
id: 1,
...(role ? { role } : {}),
}
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === SETTINGS_PATH) {
@@ -154,8 +159,14 @@ describe('Loading: skeleton for the value after 150 ms', () => {
describe('Error: a 500 or a network failure replaces the row with an alert and a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
{
name: 'a 500 response',
failure: () => Promise.reject(new ApiError(500)),
},
{
name: 'a network failure',
failure: () => Promise.reject(new NetworkError()),
},
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
await mountPage(failure)
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { initializeLocale, setLocale } from '@/i18n'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import {
authorRows,
bylines,
callsTo,
click,
createMetaBackend,
dialog,
mountMeta,
patchCalls,
renewSites,
rowNames,
saveButtonEl,
textOf,
typeInto,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
import { useSessionStore } from '@/stores/session'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SEARCH_WAIT_MS = 320
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
initializeLocale()
})
afterEach(async () => {
await unmountMeta()
setLocale('en')
localStorage.clear()
initializeLocale()
})
function trigger() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
).find((element) => /^(Add an author|Ajouter un auteur)$/.test(textOf(element)))!
}
function stackRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
}
async function navigate(path: string) {
await router.push(path)
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
}
describe('Sites and authors across Editor, Articles and Profile', () => {
it('an owner creates an author inline as "This is me", saves, and finds the profile and the byline everywhere', async () => {
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
role: 'owner',
})
backend.article.authors = []
backend.articles = [backend.article]
await mountMeta('editor', '/article/12/edit', { backend })
trigger().click()
await flushPromises()
const search = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search authors"]',
)!
await typeInto(search, 'Zoe Lambert')
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
document.body.querySelector<HTMLElement>('[data-testid="create-author-row"]')!.click()
await flushPromises()
dialog()!.querySelector<HTMLElement>('button[role="checkbox"]')!.click()
await flushPromises()
await click('Create author', dialog()!)
expect(rowNames()).toEqual(['Zoe Lambert'])
expect(patchCalls(backend)).toHaveLength(0)
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [200] })
await navigate('/profile')
expect(textOf(document.body)).toContain('Zoe Lambert')
expect(textOf(document.body)).toContain('Edit author')
expect(useSessionStore().session?.author).toMatchObject({ id: 200 })
await navigate('/article')
expect(
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(1)
})
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
role: 'verified',
})
backend.article.authors = []
backend.articles = [backend.article]
await mountMeta('editor', '/article/12/edit', { backend })
await click('Create my author profile')
expect(rowNames()).toEqual(['Marta Alves'])
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [300] })
await navigate('/article')
expect(
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(1)
await navigate('/profile')
expect(textOf(document.body)).toContain('marta-alves')
})
it('filters the articles by site and keeps the shared URL working', async () => {
const backend = createMetaBackend({
sites: renewSites(),
role: 'owner',
})
backend.articles = [
{ ...backend.article, id: 1, title: 'On Renew', site_id: 1 },
{ ...backend.article, id: 2, title: 'On Journal', site_id: 2 },
]
await mountMeta('articles', '/article', { backend })
document.body.querySelector<HTMLElement>('button[aria-label="Site"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item) === 'Journal')!
.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/article?site=journal')
expect(stackRows().map((row) => textOf(row.children[0] as HTMLElement))).toEqual([
'On Journal',
])
await unmountMeta()
await mountMeta('articles', '/article?site=journal', { backend })
expect(document.body.querySelector('button[aria-label="Site"]')!.textContent).toContain(
'Journal',
)
expect(stackRows().map((row) => textOf(row.children[0] as HTMLElement))).toEqual([
'On Journal',
])
})
})
describe('Sites and authors in French', () => {
it('translates the editor section, the Articles columns and the Profile card', async () => {
setLocale('fr')
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
const backend: MetaBackend = createMetaBackend({
sites: renewSites(),
role: 'owner',
})
backend.article.site_id = 2
backend.article.authors = bylines('Inès Duarte', 'Jonas Pereira')
backend.articles = [backend.article]
await mountMeta('editor', '/article/12/edit', { backend })
const editor = textOf(document.body)
expect(editor).toContain('Site et auteurs')
expect(editor).toContain('Hôte canonique : journal.renew.example')
expect(editor).toContain('Principal')
expect(editor).toContain(
'Faites glisser pour réorganiser. Le premier auteur est la signature principale.',
)
expect(textOf(trigger())).toBe('Ajouter un auteur')
expect(authorRows()).toHaveLength(2)
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(1)
await navigate('/article')
const articles = textOf(document.body)
expect(articles).toContain('Auteurs')
expect(articles).toContain('Tous les sites')
await navigate('/profile')
const profile = textOf(document.body)
expect(profile).toContain('Profil d’auteur')
expect(profile).toContain('Créer mon profil d’auteur')
expect(profile).toContain(
'Utilise votre nom affiché, Marta Alves. Vous pourrez le modifier ensuite.',
)
})
})
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import { useSessionStore } from '@/stores/session'
import { articleMeta, oneSite } from './editorMetaKit'
type Handler = () => Response
function reply(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
text: async () => '',
} as unknown as Response
}
const article = {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>Intro</p>',
is_online: false,
...articleMeta,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-26T11:02:00Z',
}
let wrapper: ReturnType<typeof mount> | null = null
function stubFetch(handlers: Record<string, Handler>) {
const routes: Record<string, Handler> = {
'GET /v1/site/': () => reply(200, oneSite),
'GET /v1/article/12?format=html': () => reply(200, article),
...handlers,
}
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const handler = routes[`${init?.method ?? 'GET'} ${url}`]
if (!handler) {
throw new Error(`Unexpected fetch call: ${init?.method ?? 'GET'} ${url}`)
}
return Promise.resolve(handler())
}) as unknown as typeof fetch
}
async function mountAt(path: string) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
}
session.status = 'ready'
await router.push(path)
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function buttonNamed(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('button')).find(
(button) => button.textContent?.trim() === label,
)!
}
beforeEach(() => {
setViewportWidth(DESKTOP_WIDTH)
localStorage.clear()
sessionStorage.clear()
})
afterEach(async () => {
vi.restoreAllMocks()
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('A 401 or a 403 on a new call', () => {
it('sends a 401 on POST /v1/author/me from Profile to /login with the path and reason=expired', async () => {
stubFetch({ 'POST /v1/author/me': () => reply(401) })
await mountAt('/profile')
buttonNamed('Create my author profile').click()
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query).toMatchObject({
redirect: '/profile',
reason: 'expired',
})
})
it('sends a 403 from an unverified account on POST /v1/author/me to /register/pending', async () => {
stubFetch({ 'POST /v1/author/me': () => reply(403) })
await mountAt('/profile')
useSessionStore().session = {
verified: false,
username: 'marta',
displayName: '',
}
const push = vi.spyOn(router, 'push')
buttonNamed('Create my author profile').click()
await flushPromises()
await vi.waitFor(() => expect(push).toHaveBeenCalledWith('/register/pending'))
})
it('sends a 401 on GET /v1/author/ from the editor combobox to /login with the editor path', async () => {
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
stubFetch({ 'GET /v1/author/?page=1&size=100': () => reply(401) })
await mountAt('/article/12/edit')
document.body.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query).toMatchObject({
redirect: '/article/12/edit',
reason: 'expired',
})
})
it('sends a 401 on GET /v1/site/ from Articles to /login with the list path', async () => {
stubFetch({ 'GET /v1/site/': () => reply(401) })
await mountAt('/article?site=journal')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query).toMatchObject({
reason: 'expired',
})
})
})
+9 -3
View File
@@ -51,7 +51,9 @@ describe('Sites: default (owner)', () => {
})
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await mountDirectory('sites', '/settings/sites', {
width: MOBILE_WIDTH,
})
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
@@ -132,7 +134,9 @@ describe('Sites: row menu', () => {
})
it('opens a bottom sheet titled with the site name below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await mountDirectory('sites', '/settings/sites', {
width: MOBILE_WIDTH,
})
await openRowMenu('Journal')
const sheet = dialog()!
@@ -164,7 +168,9 @@ describe('Sites: delete unavailable on the default site', () => {
})
it('writes the explanation in the sheet below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await mountDirectory('sites', '/settings/sites', {
width: MOBILE_WIDTH,
})
await openRowMenu('Renew')
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
@@ -176,13 +176,18 @@ async function mountPage(
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: sessionRole,
}),
request: requestMock,
})
await router.push('/users')
const Host = defineComponent({ render: () => h(UApp, null, () => h(UsersView)) })
const Host = defineComponent({
render: () => h(UApp, null, () => h(UsersView)),
})
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
@@ -424,7 +429,9 @@ describe('Cancel invite: done', () => {
describe('401 and 403 are left to the API client', () => {
it.each([401, 403])('cancel with %i adds no notice and no toast', async (status) => {
await mountPage({ cancel: () => Promise.reject(new ApiError(status)) })
await mountPage({
cancel: () => Promise.reject(new ApiError(status)),
})
await openCancelDialog()
await confirmCancel()
@@ -464,7 +471,10 @@ describe('Renew invite: sending', () => {
it('leaves another expired row usable', async () => {
const pending = deferred<unknown>()
await mountPage({ fixture: twoExpiredFixture(), renew: () => pending.promise })
await mountPage({
fixture: twoExpiredFixture(),
renew: () => pending.promise,
})
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
const other = buttonIn(rowOf('old@renew.example'), 'Renew invite')!
@@ -504,7 +514,10 @@ describe('Renew invite: done', () => {
describe('Renew invite: failed', () => {
it.each([
{ name: 'a 500 response', failure: () => new ApiError(500) },
{ name: 'a 500 with other text', failure: () => new ApiError(500, undefined, 'smtp down') },
{
name: 'a 500 with other text',
failure: () => new ApiError(500, undefined, 'smtp down'),
},
{ name: 'a 404 response', failure: () => new ApiError(404) },
{ name: 'a network failure', failure: () => new NetworkError() },
])('raises the error toast and keeps the row on $name', async ({ failure }) => {
@@ -554,7 +567,10 @@ describe('Cancelling a just-renewed row', () => {
expect(methodCalls('DELETE')[0][0]).toBe('/v1/user/invite/1')
expect(rows()).toHaveLength(4)
expect(toastAdd).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'invite-cancelled-1', title: 'Invite cancelled' }),
expect.objectContaining({
id: 'invite-cancelled-1',
title: 'Invite cancelled',
}),
)
})
})
+22 -6
View File
@@ -475,8 +475,14 @@ describe('Non-owners cannot invite', () => {
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
{
name: 'a 500 response',
failure: () => Promise.reject(new ApiError(500)),
},
{
name: 'a network failure',
failure: () => Promise.reject(new NetworkError()),
},
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
await mountPage({ list: failure })
@@ -633,7 +639,9 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
describe('Invite sent to an address whose invite had expired replaces the old row', () => {
it('keeps exactly one Pending row for the address, first', async () => {
await mountPage({ invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')) })
await mountPage({
invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')),
})
await openModal()
await submitWith('p.huber@gmail.com')
@@ -676,7 +684,9 @@ describe('Invite: already a user (409) — the field error depends on the respon
error: 'This address already has an account.',
},
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, body)) })
await mountPage({
invite: () => Promise.reject(new ApiError(409, undefined, body)),
})
await openModal()
await submitWith(email)
@@ -733,12 +743,18 @@ describe('Invite: rate limited — a 429 shows a dialog alert and keeps the addr
describe('Invite: failed — a 500 or a network failure keeps the address and offers a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{
name: 'a 500 response',
failure: () => Promise.reject(new ApiError(500)),
},
{
name: 'a 500 with the API text',
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)),
},
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
{
name: 'a network failure',
failure: () => Promise.reject(new NetworkError()),
},
])('shows the alert once on $name', async ({ failure }) => {
await mountPage({ invite: failure })
await openModal()
@@ -153,7 +153,10 @@ describe('Rule: Dismissing the mobile sheet', () => {
it('Nothing closes while saving', async () => {
const pending = deferred<unknown>()
await mountPage({ desktop: false, routes: { [CREATE]: () => pending.promise } })
await mountPage({
desktop: false,
routes: { [CREATE]: () => pending.promise },
})
await openAddForm()
await typeInto(field('name'), 'banner')
await typeInto(field('width'), '400')
+47 -11
View File
@@ -143,7 +143,12 @@ async function submit(label = 'Add variant') {
}
function saved(overrides: Record<string, unknown> = {}) {
return { ...variant({ name: 'hero' }), generated_files: 0, cache_size: 0, ...overrides }
return {
...variant({ name: 'hero' }),
generated_files: 0,
cache_size: 0,
...overrides,
}
}
beforeEach(() => {
@@ -539,7 +544,11 @@ describe('Rule: Form states', () => {
})
it('PNG hides Quality', async () => {
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'logo' })) } })
await mountPage({
routes: {
[CREATE]: () => Promise.resolve(saved({ name: 'logo' })),
},
})
await openAddForm()
await typeInto(field('name'), 'logo')
await typeInto(field('width'), '300')
@@ -585,7 +594,9 @@ describe('Rule: Form states', () => {
describe('Rule: Saving a new variant', () => {
it('Add sends POST with the form values', async () => {
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved()) } })
await mountPage({
routes: { [CREATE]: () => Promise.resolve(saved()) },
})
await openAddForm()
await fillHero()
@@ -604,7 +615,11 @@ describe('Rule: Saving a new variant', () => {
})
it('An empty Width or Height is sent as null', async () => {
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'card' })) } })
await mountPage({
routes: {
[CREATE]: () => Promise.resolve(saved({ name: 'card' })),
},
})
await openAddForm()
await typeInto(field('name'), 'card')
await typeInto(field('width'), '800')
@@ -651,7 +666,9 @@ describe('Rule: Saving a new variant', () => {
it('A successful create', async () => {
await mountPage({
routes: { [CREATE]: () => Promise.resolve(saved({ name: 'banner' })) },
routes: {
[CREATE]: () => Promise.resolve(saved({ name: 'banner' })),
},
})
await openAddForm()
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
@@ -676,7 +693,11 @@ describe('Rule: Saving a new variant', () => {
).toBe('7 variants · 1,284 generated files · 212 MB')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “banner” added', description: undefined, color: 'success' },
{
title: 'Variant “banner” added',
description: undefined,
color: 'success',
},
])
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
})
@@ -799,7 +820,9 @@ describe('Rule: Saving a new variant', () => {
[403, "You don't have access", 'Only admins can manage image variants.'],
[404, 'Not found', 'This variant or image no longer exists. Refresh the page.'],
])('Other statuses on save: %s', async (status, title, text) => {
await mountPage({ routes: { [CREATE]: () => Promise.reject(new ApiError(status)) } })
await mountPage({
routes: { [CREATE]: () => Promise.reject(new ApiError(status)) },
})
await openAddForm()
await fillHero()
@@ -820,7 +843,9 @@ describe('Rule: Saving a new variant', () => {
['Quality must be between 1 and 100.', 'quality', QUALITY_ERROR],
])('A 422 from the server is shown inline: %s', async (serverText, fieldName, inline) => {
await mountPage({
routes: { [CREATE]: () => Promise.reject(new ApiError(422, undefined, serverText)) },
routes: {
[CREATE]: () => Promise.reject(new ApiError(422, undefined, serverText)),
},
})
await openAddForm()
await fillHero()
@@ -869,7 +894,11 @@ describe('Rule: Saving an edit', () => {
routes: {
[PUT_HERO]: () =>
Promise.resolve({
...variant({ quality: 70, generated_files: 0, cache_size: 0 }),
...variant({
quality: 70,
generated_files: 0,
cache_size: 0,
}),
purged_files: 244,
}),
},
@@ -905,7 +934,10 @@ describe('Rule: Saving an edit', () => {
await mountPage({
routes: {
[PUT_HERO]: () =>
Promise.resolve({ ...variant({ generate_on_upload: true }), purged_files: 0 }),
Promise.resolve({
...variant({ generate_on_upload: true }),
purged_files: 0,
}),
},
})
await openEditForm('hero')
@@ -915,7 +947,11 @@ describe('Rule: Saving an edit', () => {
expect(bodyOf(PUT_HERO).generate_on_upload).toBe(true)
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “hero” saved.', description: undefined, color: 'success' },
{
title: 'Variant “hero” saved.',
description: undefined,
color: 'success',
},
])
})
+56 -13
View File
@@ -123,7 +123,9 @@ describe('Rule: Only admins and owners see the page', () => {
})
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
await mountPage({ routes: { [LIST]: () => Promise.reject(new ApiError(403)) } })
await mountPage({
routes: { [LIST]: () => Promise.reject(new ApiError(403)) },
})
expectForbidden()
expect(calls(LIST)).toHaveLength(1)
@@ -275,7 +277,11 @@ describe('Rule: Loaded, table on desktop and cards on mobile', () => {
[LIST]: () =>
Promise.resolve({
items: [
variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 }),
variant({
name: 'thumb',
generated_files: 1,
cache_size: 18 * 1024,
}),
],
}),
},
@@ -294,7 +300,9 @@ describe('Rule: Loaded, table on desktop and cards on mobile', () => {
describe('Rule: Loading', () => {
it('Skeletons appear only after 150 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes: { [LIST]: () => new Promise(() => undefined) } })
await mountPage({
routes: { [LIST]: () => new Promise(() => undefined) },
})
vi.advanceTimersByTime(149)
await flushPromises()
@@ -314,7 +322,10 @@ describe('Rule: Loading', () => {
it('Skeleton cards on mobile hold three bars', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ desktop: false, routes: { [LIST]: () => new Promise(() => undefined) } })
await mountPage({
desktop: false,
routes: { [LIST]: () => new Promise(() => undefined) },
})
vi.advanceTimersByTime(150)
await flushPromises()
@@ -490,7 +501,11 @@ describe('Rule: Status switch', () => {
routes: {
[PATCH_HERO]: () =>
Promise.resolve(
variant({ enabled: false, generated_files: 5, cache_size: 2 * MB }),
variant({
enabled: false,
generated_files: 5,
cache_size: 2 * MB,
}),
),
},
})
@@ -505,7 +520,11 @@ describe('Rule: Status switch', () => {
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('A failed toggle reverts and reports: %s', async (status, title, text) => {
await mountPage({ routes: { [PATCH_HERO]: () => Promise.reject(new ApiError(status)) } })
await mountPage({
routes: {
[PATCH_HERO]: () => Promise.reject(new ApiError(status)),
},
})
await click(switchOf('hero'))
@@ -650,7 +669,11 @@ describe('Rule: Purge cache', () => {
it('Purge succeeds', async () => {
await mountPage({
routes: {
[PURGE_HERO]: () => Promise.resolve({ purged_files: 244, purged_bytes: 88080384 }),
[PURGE_HERO]: () =>
Promise.resolve({
purged_files: 244,
purged_bytes: 88080384,
}),
},
})
await openPurge()
@@ -663,7 +686,11 @@ describe('Rule: Purge cache', () => {
expect(summary()).toBe('6 variants · 1,040 generated files · 128 MB')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Purged 244 files from “hero”', description: undefined, color: 'success' },
{
title: 'Purged 244 files from “hero”',
description: undefined,
color: 'success',
},
])
})
@@ -671,7 +698,11 @@ describe('Rule: Purge cache', () => {
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('Purge fails: %s', async (status, title, text) => {
await mountPage({ routes: { [PURGE_HERO]: () => Promise.reject(new ApiError(status)) } })
await mountPage({
routes: {
[PURGE_HERO]: () => Promise.reject(new ApiError(status)),
},
})
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
@@ -726,7 +757,9 @@ describe('Rule: Delete variant', () => {
})
it('Delete succeeds', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.resolve(undefined) } })
await mountPage({
routes: { [DELETE_HERO]: () => Promise.resolve(undefined) },
})
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
@@ -741,7 +774,11 @@ describe('Rule: Delete variant', () => {
])
expect(summary()).toBe('5 variants · 1,040 generated files · 128 MB')
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “hero” deleted', description: undefined, color: 'success' },
{
title: 'Variant “hero” deleted',
description: undefined,
color: 'success',
},
])
})
@@ -788,7 +825,9 @@ describe('Rule: Delete variant', () => {
})
it('Delete fails: 500 keeps the row and reports', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) } })
await mountPage({
routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) },
})
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
@@ -797,7 +836,11 @@ describe('Rule: Delete variant', () => {
expect(rows()).toHaveLength(6)
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Something went wrong', description: SERVER_TEXT, color: 'error' },
{
title: 'Something went wrong',
description: SERVER_TEXT,
color: 'error',
},
])
})
})
+4 -1
View File
@@ -114,7 +114,10 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
expect(session.session).toMatchObject({ verified: true, displayName: '' })
expect(session.session).toMatchObject({
verified: true,
displayName: '',
})
})
})
+21 -4
View File
@@ -13,14 +13,26 @@ const LONG_TITLE =
const WEB_IP = '203.0.113.24'
const WEB_AGENT = 'Firefox 131 on macOS'
export const user = (id: number, name: string): ActivityActor => ({ type: 'user', id, name })
export const user = (id: number, name: string): ActivityActor => ({
type: 'user',
id,
name,
})
export const apiKey = (id: number, name: string): ActivityActor => ({
type: 'api_key',
id,
name,
})
export const system: ActivityActor = { type: 'system', id: null, name: 'System' }
export const anonymous = (name: string): ActivityActor => ({ type: 'anonymous', id: null, name })
export const system: ActivityActor = {
type: 'system',
id: null,
name: 'System',
}
export const anonymous = (name: string): ActivityActor => ({
type: 'anonymous',
id: null,
name,
})
export const target = (type: string, id: string, name: string, exists = true): ActivityTarget => ({
type,
@@ -148,7 +160,12 @@ export const activityItems: ActivityItem[] = [
actor: user(4, 'Inès Duarte'),
target: target('user', '8', 'Sam Rivera'),
changes: [
{ field: 'display_name', kind: 'text', before: 'Sam Rivera', after: 'Samuel Rivera' },
{
field: 'display_name',
kind: 'text',
before: 'Sam Rivera',
after: 'Samuel Rivera',
},
],
}),
]
+16 -4
View File
@@ -99,7 +99,12 @@ export function authorsFixture(withEmails = true): Author[] {
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter.',
website: 'https://ines-duarte.example',
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
social_links: [
{
type: 'linkedin',
url: 'https://www.linkedin.com/in/ines-duarte',
},
],
user_id: 3,
is_linked: true,
article_count: 9,
@@ -166,7 +171,7 @@ function pageOf(backend: Backend, query: URLSearchParams) {
}
}
function defaultRoute(backend: Backend, call: Call): unknown {
export function defaultRoute(backend: Backend, call: Call): unknown {
const { method, path, query, body } = call
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
@@ -219,7 +224,12 @@ function defaultRoute(backend: Backend, call: Call): unknown {
return pageOf(backend, query)
}
if (method === 'POST' && path === '/v1/author/') {
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
const created = {
id: 200,
is_linked: body?.user_id != null,
article_count: 0,
...body,
}
backend.authors.push(created as unknown as Author)
return created
}
@@ -340,7 +350,9 @@ export async function mountDirectory(
await router.push(url)
const component = view === 'sites' ? SitesView : AuthorsView
kit.wrapper = mount(
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
defineComponent({
render: () => h(UApp, null, { default: () => h(component) }),
}),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
+67
View File
@@ -0,0 +1,67 @@
import type { Byline } from '@/composables/useArticles'
import type { Site } from '@/composables/useSites'
export const defaultSite: Site = {
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: null,
default_robots: 'index',
locale: null,
is_default: true,
article_count: 268,
}
export const journalSite: Site = {
...defaultSite,
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.renew.example',
is_default: false,
article_count: 38,
}
export const projectsSite: Site = {
...defaultSite,
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.renew.example',
is_default: false,
article_count: 6,
}
export const archiveSite: Site = {
...defaultSite,
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.renew.example',
is_default: false,
article_count: 0,
}
export const oneSite: Site[] = [defaultSite]
export const severalSites: Site[] = [defaultSite, journalSite, projectsSite, archiveSite]
export const ines: Byline = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
export const jonas: Byline = {
id: 3,
name: 'Jonas Pereira',
slug: 'jonas-pereira',
}
export const camille: Byline = {
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
}
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
export const leaFontaine: Byline = {
id: 10,
name: 'Léa Fontaine',
slug: 'lea-fontaine',
}
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
+8 -2
View File
@@ -412,13 +412,19 @@ export async function mountPage(
} else {
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
}),
request: kit.requestMock,
})
}
await router.push(url)
const root = options.app
? defineComponent({ render: () => h(UApp, null, { default: () => h(MediaView) }) })
? defineComponent({
render: () => h(UApp, null, { default: () => h(MediaView) }),
})
: MediaView
kit.wrapper = mount(root, {
attachTo: document.body,
+388
View File
@@ -0,0 +1,388 @@
import { vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import router from '@/router'
import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi'
import type { Article, Byline } from '@/composables/useArticles'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { slugify } from '@/lib/directory'
import { useSessionStore } from '@/stores/session'
import {
authorsFixture,
defaultRoute,
sitesFixture,
usersFixture,
type Backend,
type Call,
} from './directoryTestKit'
export {
callsTo,
textOf,
button,
click,
typeInto,
dialog,
toasts,
pending,
} from './directoryTestKit'
export { failWith, networkFailure } from './directoryTestKit'
export type MetaRole = 'owner' | 'admin' | 'verified'
export interface MetaBackend extends Backend {
article: Article
articles: Article[]
me: MeResult
meAuthorSlug?: string
meSlugAdjusted?: boolean
}
export const MARTA_EMAIL = 'marta@renew.example'
const ARTICLE_ID = 12
export function renewSites(): Site[] {
return sitesFixture().map((site) => ({
...site,
base_url: `https://${site.key === 'renew' ? 'renew' : `${site.key}.renew`}.example`,
}))
}
export function boardAuthors(): Author[] {
const extra = (id: number, name: string): Author => ({
id,
name,
slug: slugify(name, 100),
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
})
return [...authorsFixture(), extra(3, 'Jonas Pereira'), extra(8, 'Paul Huber')]
}
export function bylines(...names: string[]): Byline[] {
const all = boardAuthors()
return names.map((name) => {
const found = all.find((author) => author.name === name)
if (found) {
return { id: found.id, name: found.name, slug: found.slug }
}
return { id: 100 + name.length, name, slug: slugify(name, 100) }
})
}
export function articleOf(id: number, title: string, overrides: Partial<Article> = {}): Article {
return {
id,
title,
body: '<p>Intro</p>',
is_online: false,
site_id: 1,
authors: bylines('Inès Duarte'),
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-26T11:02:00Z',
...overrides,
}
}
export function createMetaBackend(
options: {
sites?: Site[]
authors?: Author[]
role?: MetaRole
author?: MeAuthor
} = {},
): MetaBackend {
return {
sites: options.sites ?? sitesFixture().slice(0, 1),
authors: options.authors ?? boardAuthors(),
users: usersFixture(),
calls: [],
responders: {},
article: articleOf(ARTICLE_ID, 'How we price a bathroom renovation'),
articles: [],
me: {
id: 1,
verified: true,
username: 'marta',
email: MARTA_EMAIL,
displayName: 'Marta Alves',
role: options.role === 'verified' ? undefined : (options.role ?? 'owner'),
...(options.author ? { author: options.author } : {}),
},
}
}
function fail(status: number, code?: string): never {
throw new ApiError(status, undefined, code)
}
function toByline(backend: MetaBackend, id: number): Byline {
const author = backend.authors.find((candidate) => candidate.id === id)
if (!author) {
fail(404, 'author_not_found')
}
return { id: author.id, name: author.name, slug: author.slug }
}
function patchArticle(backend: MetaBackend, body: Record<string, unknown>) {
if (typeof body.site_id === 'number') {
if (!backend.sites.some((site) => site.id === body.site_id)) {
fail(404, 'site_not_found')
}
backend.article.site_id = body.site_id
}
if (Array.isArray(body.author_ids)) {
backend.article.authors = (body.author_ids as number[]).map((id) => toByline(backend, id))
}
if (typeof body.body === 'string') {
backend.article.body = body.body
}
return structuredClone(backend.article)
}
function listArticles(backend: MetaBackend, query: URLSearchParams) {
const size = Number(query.get('size') ?? 25)
const page = Number(query.get('page') ?? 1)
const siteKey = query.get('site')
const status = query.get('status')
const q = (query.get('q') ?? '').toLowerCase()
const siteId = backend.sites.find((site) => site.key === siteKey)?.id
const matching = backend.articles
.filter((article) => !siteKey || article.site_id === siteId)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || article.title.toLowerCase().includes(q))
const online = backend.articles.filter((article) => article.is_online).length
return {
items: structuredClone(matching.slice((page - 1) * size, page * size)),
total: matching.length,
page,
size,
counts: {
all: backend.articles.length,
online,
draft: backend.articles.length - online,
},
}
}
function createMyAuthor(backend: MetaBackend) {
if (backend.me.author) {
fail(409, 'user_already_linked')
}
const name = backend.me.displayName || backend.me.username || ''
const created = {
id: 300,
name,
slug: backend.meAuthorSlug ?? slugify(name, 100),
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: backend.me.id ?? null,
is_linked: true,
article_count: 0,
} as Author
backend.authors.push(created)
backend.me.author = {
id: created.id,
name: created.name,
slug: created.slug,
}
return {
...created,
slug_adjusted: backend.meSlugAdjusted ?? false,
} as Author
}
function route(backend: MetaBackend, call: Call): unknown {
const { method, path, query, body } = call
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
if (articleMatch && method === 'GET') {
return structuredClone(backend.article)
}
if (articleMatch && method === 'PATCH') {
return patchArticle(backend, body ?? {})
}
if (path === '/v1/article/' && method === 'GET') {
return listArticles(backend, query)
}
if (path === '/v1/author/me' && method === 'POST') {
return createMyAuthor(backend)
}
if (path === '/v1/author/' && method === 'POST' && body?.user_id != null) {
const created = defaultRoute(backend, call) as Author
backend.me.author = {
id: created.id,
name: created.name,
slug: created.slug,
}
return created
}
return defaultRoute(backend, call)
}
export interface MetaMountOptions {
role?: MetaRole
width?: number
backend?: MetaBackend
author?: MeAuthor
}
export const metaKit: {
wrapper: ReturnType<typeof mount> | null
backend: MetaBackend
} = {
wrapper: null,
backend: createMetaBackend(),
}
export async function mountMeta(
_view: 'editor' | 'articles' | 'profile',
url: string,
options: MetaMountOptions = {},
) {
setViewportWidth(options.width ?? DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
setActivePinia(createPinia())
const backend =
options.backend ?? createMetaBackend({ role: options.role, author: options.author })
metaKit.backend = backend
const session = useSessionStore()
session.session = {
verified: true,
username: backend.me.username,
id: backend.me.id,
email: backend.me.email,
displayName: backend.me.displayName,
role: backend.me.role,
...(backend.me.author ? { author: backend.me.author } : {}),
}
session.status = 'ready'
const request = vi.fn(async (path: string, init?: RequestInit) => {
const [pathname = '', search = ''] = path.split('?')
const call: Call = {
method: init?.method ?? 'GET',
path: pathname,
raw: path,
query: new URLSearchParams(search),
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
}
backend.calls.push(call)
const responder =
backend.responders[`${call.method} ${path}`] ??
backend.responders[`${call.method} ${pathname}`]
return responder ? responder(call) : route(backend, call)
})
const getMe = vi.fn(async () => {
backend.calls.push({
method: 'GET',
path: '/v1/auth/me',
raw: '/v1/auth/me',
query: new URLSearchParams(),
body: undefined,
})
return structuredClone(backend.me)
})
vi.mocked(useApi).mockReturnValue({
getMe,
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
metaKit.wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return backend
}
export function metaSection() {
return document.body.querySelector<HTMLElement>('[data-testid="meta-section"]')
}
export function allMetaSections() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="meta-section"]'))
}
export function authorRows(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="author-row"]'))
}
export function rowNames(root: ParentNode = document.body) {
return authorRows(root).map((row) => row.querySelector('[title]')?.getAttribute('title') ?? '')
}
export function handleOf(name: string) {
return document.body.querySelector<HTMLElement>(`button[aria-label="Reorder ${name}"]`)!
}
export async function pressKey(element: HTMLElement, key: string) {
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
await flushPromises()
}
export function mockRowRects(rows: HTMLElement[], rowHeight = 32) {
rows.forEach((row, index) => {
row.getBoundingClientRect = () =>
({
top: index * rowHeight,
bottom: (index + 1) * rowHeight,
height: rowHeight,
left: 0,
right: 200,
width: 200,
x: 0,
y: index * rowHeight,
toJSON: () => ({}),
}) as DOMRect
})
}
export async function pointer(element: HTMLElement, type: string, clientY: number) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientY,
button: 0,
})
element.dispatchEvent(event)
await flushPromises()
}
export async function dragTo(name: string, clientY: number) {
const handle = handleOf(name)
mockRowRects(authorRows())
await pointer(handle, 'pointerdown', 0)
await pointer(handle, 'pointermove', clientY)
return {
release: () => pointer(handle, 'pointerup', clientY),
cancel: () => pointer(handle, 'pointercancel', clientY),
}
}
export function saveButtonEl() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!
}
export function patchCalls(backend: MetaBackend) {
return backend.calls.filter(
(call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path),
)
}
export async function unmountMeta() {
metaKit.wrapper?.unmount()
metaKit.wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
+41 -7
View File
@@ -55,7 +55,13 @@ const GROUPS = [
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 7,
filename: 'IMG_4471.jpg',
url: '/media/IMG_4471.jpg',
used_in: 2,
merged: 1,
},
{
keep_id: 2,
filename: 'kitchen-after.webp',
@@ -63,7 +69,13 @@ const GROUPS = [
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 4,
filename: 'logo-renew.svg',
url: '/media/logo-renew.svg',
used_in: 5,
merged: 3,
},
{
keep_id: 11,
filename: 'lime-plaster.jpg',
@@ -86,11 +98,23 @@ export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
}
export function manyPreview() {
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
return previewOf({
groups: 8,
copies: 15,
articles: 24,
list: GROUPS,
more: 3,
})
}
export function emptyPreview() {
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
return previewOf({
groups: 0,
copies: 0,
articles: 0,
list: [],
fingerprint: 'none',
})
}
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
@@ -172,7 +196,9 @@ function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknow
export async function mountSettings(
mocks: PurgeMocks = {},
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
options: { role?: string | undefined; realApi?: boolean } = {
role: 'owner',
},
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
@@ -307,7 +333,11 @@ export async function commitByBlur(input: HTMLInputElement) {
export async function commitByEnter(input: HTMLInputElement) {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
}
@@ -320,7 +350,11 @@ export async function goOffline() {
export function pressEscape(target: Element | null) {
;(target ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
}
+20 -4
View File
@@ -29,7 +29,11 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
latest: RUNNING_VERSION,
available: false,
release_url: RELEASE_URL,
notes: { version: RUNNING_VERSION, released_at: RELEASED_AT, items: [] },
notes: {
version: RUNNING_VERSION,
released_at: RELEASED_AT,
items: [],
},
checked_at: hoursAgo(1),
can_apply: false,
state: 'idle',
@@ -46,7 +50,11 @@ export function newerAvailable(overrides: Partial<UpdateStatus> = {}): UpdateSta
return upToDate({
latest: NEW_TAG,
available: true,
notes: { version: NEW_TAG, released_at: RELEASED_AT, items: NOTE_ITEMS },
notes: {
version: NEW_TAG,
released_at: RELEASED_AT,
items: NOTE_ITEMS,
},
...overrides,
})
}
@@ -82,7 +90,11 @@ export function downloading(percent = 42): UpdateStatus {
return startable({
state: 'downloading',
started_at: '2026-09-30T09:51:00Z',
progress: { percent, downloaded_bytes: downloaded, total_bytes: BOARD_TOTAL_BYTES },
progress: {
percent,
downloaded_bytes: downloaded,
total_bytes: BOARD_TOTAL_BYTES,
},
})
}
@@ -95,7 +107,11 @@ export function ready(): UpdateStatus {
}
export function failedWith(error: 'download' | 'verification'): UpdateStatus {
return startable({ state: 'failed', error, started_at: '2026-09-30T09:51:00Z' })
return startable({
state: 'failed',
error,
started_at: '2026-09-30T09:51:00Z',
})
}
export const RESTART_STARTED_AT = '2026-09-30T09:51:00Z'
+24 -7
View File
@@ -143,13 +143,18 @@ export async function mountPage(options: MountOptions = {}) {
)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: sessionRole,
}),
request: requestMock,
})
await router.push('/settings/media/variants')
const Host = defineComponent({ render: () => h(UApp, null, () => h(VariantsPage)) })
const Host = defineComponent({
render: () => h(UApp, null, () => h(VariantsPage)),
})
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
@@ -255,7 +260,12 @@ export async function blur(input: HTMLInputElement | null | undefined) {
export async function pressKey(element: Element, key: string, init: KeyboardEventInit = {}) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }),
new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
}),
)
await flushPromises()
}
@@ -263,7 +273,11 @@ export async function pressKey(element: Element, key: string, init: KeyboardEven
export async function pressEscape() {
const target = document.activeElement ?? document.body
target.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
}
@@ -345,6 +359,9 @@ export async function clickBackdrop() {
export function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}