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 = `

${FIRST_SENTENCE} Every project starts with a visit.

What goes into the price

Stripping out and making good.

` 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 = {}, ): 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 { return { ...renewSites()[0]!, site_name: 'Renew', title_template: '%title% | %site%', default_description: '', ...overrides, } } export function seoBackend( options: { article?: Partial 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('[data-testid="seo-section"]') } export function sectionToggle(root: ParentNode = document.body) { return root.querySelector('[data-testid="seo-toggle"]')! } export function sectionSummary(root: ParentNode = document.body) { return root.querySelector('[data-testid="seo-summary"]') } export function sectionBody(root: ParentNode = document.body) { return root.querySelector('[data-testid="seo-body"]') } export async function openSection() { if (!sectionBody()) { sectionToggle().click() await flushPromises() } return sectionBody()! } export function panelRoot() { return ( document.body .querySelector('[data-testid="seo-panel-suggestions"]') ?.closest('[role="dialog"]') ?? null ) } export async function openPanel() { await openSection() const edit = document.body.querySelector('[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('[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('button')).find( (candidate) => textOf(candidate) === 'Save changes' || textOf(candidate) === 'Saving…', )! }