fix: element tests drive the widget host protocol; the image element gets tests (WR-25)

The transfer element's busy and state=error handling is now tested
through the fake widget host, and golem15-seo-image gets tests for the
upload id matching, the error display, Remove and the disabled state.
The README's test gate runs npm test.
This commit is contained in:
2026-10-27 15:44:22 +01:00
parent 923d0f9c3a
commit 5c2ad6aee1
3 changed files with 135 additions and 3 deletions

View File

@@ -232,7 +232,9 @@ The non-production lock reads the application environment (`SUMMER_ENV`); it has
go vet ./...
go test -short ./... # without Docker
go test ./... # with Docker: the Postgres smoke tests
npm ci && npm run typecheck && bash scripts/check-dist.sh
npm ci && npm run typecheck && npm test && bash scripts/check-dist.sh
```
The element tests (`tests/elements`, vitest on happy-dom) drive the widget elements through a fake of the admin's widget host (`tests/elements/host.ts`), including a host that drops an action.
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). `TestEveryAdminOperationHasTool` and `TestMCPReadToolsAreGET` run without Docker.

View File

@@ -1,7 +1,18 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { safeImageURL } from '../../assets/src/elements/image'
import { labelsFor } from '../../assets/src/elements/labels'
// E7 golem15-seo-image: which stored URLs the preview may load.
// E7 golem15-seo-image: which stored URLs the preview may load, the
// upload round trip through summer-upload / summer-upload-result, errors
// and Remove.
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
document.body.replaceChildren()
})
describe('E7 safeImageURL', () => {
it('accepts a path and an http(s) URL', () => {
@@ -16,3 +27,74 @@ describe('E7 safeImageURL', () => {
}
})
})
describe('E7 element (WR-25)', () => {
type ImageNode = HTMLElement & { value: string }
function mount(value = ''): { node: ImageNode; changes: string[]; uploads: { id: string; file: File }[] } {
const node = document.createElement('golem15-seo-image') as ImageNode
node.setAttribute('ui-locale', 'en')
document.body.append(node)
node.value = value
const changes: string[] = []
const uploads: { id: string; file: File }[] = []
node.addEventListener('summer-change', (ev) => changes.push((ev as CustomEvent).detail.value))
node.addEventListener('summer-upload', (ev) => uploads.push((ev as CustomEvent).detail))
return { node, changes, uploads }
}
function choose(node: HTMLElement): File {
const input = node.querySelector<HTMLInputElement>('input[type="file"]')!
const file = new File(['x'], 'a.png', { type: 'image/png' })
Object.defineProperty(input, 'files', { value: [file] })
input.dispatchEvent(new Event('change'))
return file
}
function status(node: HTMLElement): string {
return node.querySelector('.seo-image__status')?.textContent ?? ''
}
it('shows a safe stored image and hides an unsafe one', () => {
expect(mount('/media/a.png').node.querySelector('img')?.getAttribute('src')).toBe('/media/a.png')
expect(mount('/\\evil.example/x.png').node.querySelector('img')).toBeNull()
})
it('uploads through summer-upload and takes only the answer to its own id', () => {
const { node, changes, uploads } = mount()
const file = choose(node)
expect(uploads).toHaveLength(1)
expect(uploads[0].file).toBe(file)
expect(status(node)).toBe(labelsFor('en').imageUploading)
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: 'other', url: '/media/wrong.png' } }))
expect(changes).toEqual([])
expect(status(node)).toBe(labelsFor('en').imageUploading)
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[0].id, url: '/media/new.png' } }))
expect(changes).toEqual(['/media/new.png'])
expect(node.value).toBe('/media/new.png')
expect(node.querySelector('img')?.getAttribute('src')).toBe('/media/new.png')
expect(status(node)).toBe('')
})
it('shows the upload error, or imageFailed when the answer names none', () => {
const { node, changes, uploads } = mount()
choose(node)
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[0].id, error: 'Too big.' } }))
expect(status(node)).toBe('Too big.')
choose(node)
node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[1].id } }))
expect(status(node)).toBe(labelsFor('en').imageFailed)
expect(node.querySelector('.seo-image__status--error')).not.toBeNull()
expect(changes).toEqual([])
})
it('Remove clears the value; a disabled element offers no actions', () => {
const { node, changes } = mount('/media/a.png')
node.querySelector<HTMLButtonElement>('.seo-image__button--secondary')!.click()
expect(changes).toEqual([''])
expect(node.querySelector('img')).toBeNull()
const off = mount('/media/a.png').node
off.setAttribute('disabled', '')
expect(off.querySelector('.seo-image__actions')).toBeNull()
})
})

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { applyState, ROW_CAP, shownRows, type PreviewData, type PreviewRow } from '../../assets/src/elements/transfer'
import { labelsFor } from '../../assets/src/elements/labels'
import { HostFailure, mountWidget, settle } from './host'
// E6 golem15-seo-transfer (UI-SPEC C13): Apply is enabled only after a
// preview with no refused row and at least one change.
@@ -82,3 +83,50 @@ describe('E6 apply gating', () => {
expect(node.querySelector('.seo-transfer__actions')).toBeNull()
})
})
describe('E6 host protocol (WR-25)', () => {
function downloadButton(node: HTMLElement): HTMLButtonElement {
return node.querySelector<HTMLButtonElement>('.seo-transfer__section button')!
}
it('an export goes through the host and the buttons are busy until it settles', async () => {
const host = mountWidget('golem15-seo-transfer', {
attrs: { 'ui-locale': 'en', 'busy-label': 'Working…' },
answer: () => ({ op: 'export', filename: 'seo-redirects.csv', csv: 'source\n' }),
})
URL.createObjectURL = () => 'blob:x'
URL.revokeObjectURL = () => undefined
downloadButton(host.node).click()
expect(host.posted).toEqual([{ op: 'export', dataset: 'redirects' }])
expect(downloadButton(host.node).disabled).toBe(true)
expect(downloadButton(host.node).textContent).toBe('Working…')
downloadButton(host.node).click()
expect(host.emitted).toHaveLength(1)
await settle()
expect(downloadButton(host.node).disabled).toBe(false)
})
it('state=error unblocks the next request', async () => {
let fail = true
const host = mountWidget('golem15-seo-transfer', {
attrs: { 'ui-locale': 'en' },
answer: () => {
if (fail) {
fail = false
throw new HostFailure('export')
}
return { op: 'export', filename: 'seo-redirects.csv', csv: 'source\n' }
},
})
URL.createObjectURL = () => 'blob:x'
URL.revokeObjectURL = () => undefined
downloadButton(host.node).click()
await settle()
expect(host.node.getAttribute('state')).toBe('error')
expect(downloadButton(host.node).disabled).toBe(false)
downloadButton(host.node).click()
await settle()
expect(host.posted).toHaveLength(2)
})
})