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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user