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
This commit is contained in:
134
tests/elements/links.test.ts
Normal file
134
tests/elements/links.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user