221 lines
6.4 KiB
TypeScript
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…',
|
|
)!
|
|
}
|