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

@@ -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)
})
})