Files
RenewCMS/web/src/views/__tests__/seoTestKit.ts
T

221 lines
6.4 KiB
TypeScript

import { flushPromises } from '@vue/test-utils'
import type { Call } from './directoryTestKit'
import {
createMetaBackend,
mountMeta,
renewSites,
textOf,
type MetaBackend,
type MetaMountOptions,
} from './metaTestKit'
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
import type { Site } from '@/composables/useSites'
export const EDITOR_URL = '/article/12/edit'
export const ARTICLE_TITLE = 'How we price a bathroom renovation'
export const ARTICLE_SLUG = 'how-we-price-a-bathroom-renovation'
export const FIRST_SENTENCE =
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
export const ARTICLE_BODY = `<p>${FIRST_SENTENCE} Every project starts with a visit.</p><h2>What goes into the price</h2><p>Stripping out and making good.</p>`
export const LONG_TITLE =
'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
export const NORTH_WALL = {
id: 1,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/north-wall.jpg',
size: 4089446,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a'.repeat(64),
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 1,
duplicate: false,
variant_count: 0,
variant_size: 0,
}
export const KITCHEN_AFTER = { ...NORTH_WALL, id: 2, filename: 'kitchen-after.webp' }
export const READY = {
status: 'ready',
suggestions: [],
slug: { available: true, suggestion: null },
}
export function suggestion(
code: SuggestionCode,
field: SeoSuggestion['field'],
params: Record<string, number> = {},
): SeoSuggestion {
return { code, field, params }
}
export function checkAnswer(
suggestions: SeoSuggestion[],
status: 'ready' | 'suggestions' | 'hidden' = suggestions.length ? 'suggestions' : 'ready',
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
) {
return { status, suggestions, slug }
}
export const FOUR_SUGGESTIONS = [
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
suggestion('cover_alt', 'cover_image_alt'),
suggestion('no_h', 'body'),
suggestion('short', 'body', { length: 40, min: 300 }),
]
export function renewSite(overrides: Partial<Site> = {}): Site {
return {
...renewSites()[0]!,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: '',
...overrides,
}
}
export function seoBackend(
options: {
article?: Partial<MetaBackend['article']>
sites?: Site[]
check?: unknown
role?: 'owner' | 'admin' | 'verified'
} = {},
) {
const backend = createMetaBackend({
sites: options.sites ?? [renewSite()],
role: options.role,
})
Object.assign(backend.article, {
title: ARTICLE_TITLE,
body: ARTICLE_BODY,
slug: ARTICLE_SLUG,
excerpt: '',
seo_title: '',
seo_description: '',
cover_image_id: null,
cover_image_alt: '',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
...options.article,
})
backend.responders['POST /v1/article/12/seo/check'] = () => options.check ?? READY
backend.responders['GET /v1/media/'] = () => ({
items: [NORTH_WALL, KITCHEN_AFTER],
total: 2,
page: 1,
size: 100,
counts: { all: 2, unused: 0, duplicates: 0 },
})
for (const item of [NORTH_WALL, KITCHEN_AFTER]) {
backend.responders[`GET /v1/media/${item.id}`] = () => item
}
return backend
}
export async function mountSeo(backend: MetaBackend, options: MetaMountOptions = {}) {
await mountMeta('editor', EDITOR_URL, { ...options, backend })
return backend
}
export function checkCalls(backend: MetaBackend) {
return backend.calls.filter(
(call) => call.method === 'POST' && call.path.endsWith('/seo/check'),
)
}
export function checkBodies(backend: MetaBackend) {
return checkCalls(backend).map((call) => call.body)
}
export function patchBodies(backend: MetaBackend) {
return backend.calls
.filter((call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path))
.map((call) => call.body)
}
export function seoCalls(backend: MetaBackend) {
return backend.calls.filter((call) => call.method === 'GET' && call.path.endsWith('/seo'))
}
export function answerChecks(backend: MetaBackend, answer: unknown | ((call: Call) => unknown)) {
backend.responders['POST /v1/article/12/seo/check'] = (call) =>
typeof answer === 'function' ? (answer as (call: Call) => unknown)(call) : answer
}
export function section(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-section"]')
}
export function sectionToggle(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!
}
export function sectionSummary(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-summary"]')
}
export function sectionBody(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-body"]')
}
export async function openSection() {
if (!sectionBody()) {
sectionToggle().click()
await flushPromises()
}
return sectionBody()!
}
export function panelRoot() {
return (
document.body
.querySelector<HTMLElement>('[data-testid="seo-panel-suggestions"]')
?.closest('[role="dialog"]') ?? null
)
}
export async function openPanel() {
await openSection()
const edit = document.body.querySelector<HTMLElement>('[data-testid="seo-edit"]')!
edit.focus()
edit.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
return panelRoot()!
}
export async function openDisclosure(title: string) {
const toggle = Array.from(
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
).find((button) => textOf(button).startsWith(title))!
toggle.click()
await flushPromises()
}
export function fieldByLabel(root: ParentNode, label: string) {
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
textOf(candidate).startsWith(label),
)
const id = element?.getAttribute('for')
return id ? (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement) : null
}
export function counterTexts(root: ParentNode) {
return Array.from(root.querySelectorAll('[data-testid="soft-counter"]')).map((node) =>
textOf(node),
)
}
export function saveChangesButton(root: ParentNode) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(candidate) => textOf(candidate) === 'Save changes' || textOf(candidate) === 'Saving…',
)!
}