Files
sm-seo-plugin/tests/elements/links.test.ts
Jakub Zych 49cc241f58 feat: internal links widget with counts and suggestions
- E10 golem15-seo-links in the SEO tab after the graph preview (types
  with a body): inbound and outbound counts from the last finished crawl,
  the orphan warning, up to 10 pages to link to (Copy address) and 10
  that could link here
- classes/linkgraph.go builds the report from the crawl's pages and
  links; the crawler's orphan suggestions share NormalText
- GET /_seo/api/v1/links and seo_get_link_suggestions; widget action
  seo_links mapped in AdminOperations
- element bundle rebuilt
2026-11-04 16:40:16 +01:00

135 lines
5.6 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { labelsFor } from '../../assets/src/elements/labels'
import type { LinksData } from '../../assets/src/elements/links'
import { HostFailure, mountWidget, settle } from './host'
// E10 golem15-seo-links (UI-SPEC C19, D-82).
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
document.body.replaceChildren()
})
const en = labelsFor('en')
const crawled: LinksData = {
crawledAt: '2026-11-04T10:00:00Z',
inbound: 0,
outbound: 3,
orphan: true,
out: [{ phrase: 'shiitake', title: 'Shiitake', url: 'https://example.com/pages/shiitake' }],
in: [{ phrase: 'lwia grzywa', title: 'Oyster', adminUrl: '/backend/acme/pages/pages/update/3' }],
}
function stubClipboard(): { writes: string[] } {
const writes: string[] = []
vi.stubGlobal('navigator', {
...navigator,
clipboard: {
writeText: (text: string) => {
writes.push(text)
return Promise.resolve()
},
},
})
return { writes }
}
describe('E10 golem15-seo-links', () => {
it('asks for the links of the shown locale on connect and renders counters, the orphan callout and both lists', async () => {
stubClipboard()
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
expect(host.posted).toEqual([{ op: 'links', locale: '' }])
const node = host.node
const values = [...node.querySelectorAll('.summer-stat__value')].map((n) => n.textContent)
expect(values).toEqual(['0', '3'])
expect([...node.querySelectorAll('.summer-stat__label')].map((n) => n.textContent)).toEqual([en.linksInbound, en.linksOutbound])
expect(node.textContent).toContain('From the crawl of ')
expect(node.querySelector('.summer-callout--warning')!.textContent).toContain(en.linksOrphanTitle)
const headings = [...node.querySelectorAll('.seo-links__heading')].map((n) => n.textContent)
expect(headings).toEqual([en.linksOut, en.linksIn])
const out = node.querySelector('.seo-links__list--out .seo-links__row')!
expect(out.textContent).toContain('“shiitake” → Shiitake')
const link = out.querySelector('a')!
expect(link.getAttribute('href')).toBe('https://example.com/pages/shiitake')
expect(link.target).toBe('_blank')
expect(link.rel).toBe('noopener')
const inbound = node.querySelector('.seo-links__list--in .seo-links__row a')!
expect(inbound.getAttribute('href')).toBe('/backend/acme/pages/pages/update/3')
expect(node.querySelector('.seo-links__none')).toBeNull()
expect(node.getAttribute('aria-busy')).toBe('false')
})
it('shows notCrawled instead of counters before a crawl and none under empty lists', async () => {
const host = mountWidget('golem15-seo-links', {
attrs: { 'ui-locale': 'en' },
answer: () => ({ crawledAt: null, inbound: null, outbound: null, orphan: false, out: [], in: [] }),
})
await settle()
const node = host.node
expect(node.querySelector('.summer-stats')).toBeNull()
expect(node.textContent).toContain(en.linksNotCrawled)
expect(node.querySelector('.summer-callout')).toBeNull()
expect(node.querySelectorAll('.seo-links__none')).toHaveLength(2)
expect(node.querySelector('.seo-links__none')!.textContent).toBe(en.linksNone)
})
it('shows the checking line with aria-busy while loading and the danger line on failure', async () => {
const host = mountWidget('golem15-seo-links', {
attrs: { 'ui-locale': 'pl' },
answer: () => {
throw new HostFailure('down')
},
})
await Promise.resolve()
expect(host.node.getAttribute('aria-busy')).toBe('true')
expect(host.node.querySelector('.seo-links__checking')!.getAttribute('role')).toBe('status')
await settle()
const error = host.node.querySelector('.seo-links__error')!
expect(error.textContent).toBe(labelsFor('pl').linksFailed)
expect(error.getAttribute('role')).toBe('status')
expect(host.node.getAttribute('aria-busy')).toBe('false')
})
it('copies the address and says so for two seconds', async () => {
const clip = stubClipboard()
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
vi.useFakeTimers()
const button = host.node.querySelector<HTMLButtonElement>('.seo-links__copy')!
expect(button.textContent).toBe(en.linksCopy)
button.click()
await Promise.resolve()
await Promise.resolve()
expect(clip.writes).toEqual(['https://example.com/pages/shiitake'])
expect(host.node.querySelector('.seo-links__status')!.textContent).toBe(en.linksCopied)
vi.advanceTimersByTime(2000)
expect(host.node.querySelector('.seo-links__status')!.textContent).toBe('')
})
it('hides Copy address without a clipboard and keeps the address as text', async () => {
vi.stubGlobal('navigator', { ...navigator, clipboard: undefined })
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
expect(host.node.querySelector('.seo-links__copy')).toBeNull()
expect(host.node.querySelector('.seo-links__url')!.textContent).toBe('https://example.com/pages/shiitake')
})
it('asks again on the ML locale switch', async () => {
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
window.dispatchEvent(new CustomEvent('summer:ml-locale', { detail: 'pl' }))
await settle()
expect(host.posted).toEqual([
{ op: 'links', locale: '' },
{ op: 'links', locale: 'pl' },
])
})
})