test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07), TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06), TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01), TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress (B-IN-04); test seam WrapStore - pact TestFrontendTokenScopes, towel TestClientIP, cabana TestTokenScopeNameParity - cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the Postgres TestAccountTokenRevokeEvent (B-WR-04) and TestResolveBackendTokenRemote (B-IN-04) - vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01) - check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
This commit is contained in:
@@ -141,4 +141,83 @@ describe('My account contributions', () => {
|
||||
expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0)
|
||||
expect(wrapper.find('[data-api-tokens]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
// Plan 07 hardening (B-WR-03, B-WR-06, B-IN-01).
|
||||
|
||||
it('a javascript: URL answer renders no link and never opens a window', async () => {
|
||||
const opened = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const { wrapper } = await openAccount({
|
||||
[`POST ${OPEN}`]: envelope({ message: 'Done.', url: 'javascript:alert(1)' }),
|
||||
})
|
||||
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(opened).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
|
||||
expect(wrapper.html()).not.toContain('javascript:')
|
||||
expect(toasts.value.map((toast) => toast.text)).toEqual(['Done.'])
|
||||
})
|
||||
|
||||
it('a section with error: true shows the section error text', async () => {
|
||||
setBundle({ ...langFixture.data, ...strings, 'backend::lang.myaccount.section_error': { other: 'This section could not be loaded.' } }, 'en')
|
||||
const { wrapper } = await openAccount({
|
||||
[`GET ${CONTRIBUTIONS}`]: envelope([{ ...sections[0], status: '', error: true }, sections[1]]),
|
||||
})
|
||||
const cards = wrapper.findAll('[data-account-contribution]')
|
||||
expect(cards[0]!.find('[data-account-contribution-error]').text()).toBe('This section could not be loaded.')
|
||||
expect(cards[0]!.find('[data-account-contribution-status]').exists()).toBe(false)
|
||||
expect(cards[1]!.find('[data-account-contribution-error]').exists()).toBe(false)
|
||||
expect(cards[1]!.find('[data-account-contribution-status]').text()).toBe('Weekly')
|
||||
})
|
||||
|
||||
it('a 500 on the list reload shows the error toast and keeps the earlier sections', async () => {
|
||||
setBundle({ ...langFixture.data, ...strings, 'backend::lang.form.error_generic': { other: 'Something went wrong.' } }, 'en')
|
||||
let loads = 0
|
||||
const { wrapper } = await openAccount({
|
||||
[`GET ${CONTRIBUTIONS}`]: () => {
|
||||
loads++
|
||||
return loads === 1
|
||||
? envelope(sections)
|
||||
: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } }
|
||||
},
|
||||
[`POST ${OPEN}`]: envelope({ message: 'Done.', url: '' }),
|
||||
})
|
||||
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(loads).toBe(2)
|
||||
expect(wrapper.findAll('[data-account-contribution]').map((card) => card.attributes('data-account-contribution'))).toEqual([
|
||||
'site_link',
|
||||
'newsletter',
|
||||
])
|
||||
// Newest toast first.
|
||||
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([
|
||||
['Something went wrong.', 'danger'],
|
||||
['Done.', 'success'],
|
||||
])
|
||||
})
|
||||
|
||||
it('the confirm and stale texts are the My account ones, not the record form ones', async () => {
|
||||
setBundle(
|
||||
{
|
||||
...langFixture.data,
|
||||
...strings,
|
||||
'backend::lang.myaccount.action_confirm': { other: 'Run “:action”?' },
|
||||
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
|
||||
'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' },
|
||||
},
|
||||
'en',
|
||||
)
|
||||
const STOP = `${CONTRIBUTIONS}/newsletter/actions/stop`
|
||||
const { wrapper } = await openAccount({
|
||||
[`GET ${CONTRIBUTIONS}`]: envelope([sections[0], { ...sections[1], actions: [{ ...sections[1]!.actions[0], confirm: '' }] }]),
|
||||
[`POST ${STOP}`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
|
||||
})
|
||||
await wrapper.find('[data-account-contribution-action="stop"]').trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
expect(dialog?.textContent).toContain('Run “Stop the digest”?')
|
||||
expect(dialog?.textContent).not.toContain('on this record')
|
||||
dialog!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
expect(toasts.value.map((toast) => toast.text)).toEqual(['This action no longer applies.'])
|
||||
})
|
||||
})
|
||||
|
||||
181
admin/tests/app/hotkeysEditing.test.ts
Normal file
181
admin/tests/app/hotkeysEditing.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Shortcuts while editing (phase 19.7 plan 07, B-WR-01 and B-WR-02): plain
|
||||
// keys never fire from an input inside an open shadow root or from a custom
|
||||
// field element inside a form (a closed root hides its input), and the save
|
||||
// shortcuts commit a taglist's typed-but-uncommitted tag before saving, the
|
||||
// way a pointer click on the button does. No application names appear in
|
||||
// framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { closeShortcutHelp, installHotkeys, shortcutHelp } from '../../src/app/hotkeys'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { clearUser } from '../../src/state/useAuth'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { clearToasts } from '../../src/state/useToasts'
|
||||
import lang from '../fixtures/lang.json'
|
||||
import formSchema from '../fixtures/widgets.form-schema.json'
|
||||
import record from '../fixtures/widgets.record.json'
|
||||
import listSchema from '../fixtures/widgets.list-schema.json'
|
||||
import listRows from '../fixtures/widgets.list.json'
|
||||
import { API, mountApp, press, requestsTo, wait, type Route } from '../helpers'
|
||||
|
||||
let uninstalls: Array<() => void> = []
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
clearToasts()
|
||||
setBundle(lang.data, 'pl')
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const uninstall of uninstalls) {
|
||||
uninstall()
|
||||
}
|
||||
uninstalls = []
|
||||
closeShortcutHelp()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function hotkeyButton(hotkey: string): { element: HTMLButtonElement; clicks: () => number } {
|
||||
const element = document.createElement('button')
|
||||
element.setAttribute('data-hotkey', hotkey)
|
||||
let count = 0
|
||||
element.addEventListener('click', () => count++)
|
||||
document.body.appendChild(element)
|
||||
return { element, clicks: () => count }
|
||||
}
|
||||
|
||||
function composedKey(target: EventTarget, key: string): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
class ClosedField extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
const root = this.attachShadow({ mode: 'closed' })
|
||||
root.appendChild(document.createElement('input'))
|
||||
}
|
||||
}
|
||||
if (!customElements.get('acme-closed-field')) {
|
||||
customElements.define('acme-closed-field', ClosedField)
|
||||
}
|
||||
|
||||
describe('plain keys from editable controls hidden in shadow DOM', () => {
|
||||
it('ignores ? and Escape from an input inside an open shadow root', () => {
|
||||
uninstalls.push(installHotkeys())
|
||||
const leave = hotkeyButton('escape')
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const input = document.createElement('input')
|
||||
host.attachShadow({ mode: 'open' }).appendChild(input)
|
||||
|
||||
const escape = composedKey(input, 'Escape')
|
||||
const help = composedKey(input, '?')
|
||||
expect(leave.clicks()).toBe(0)
|
||||
expect(escape.defaultPrevented).toBe(false)
|
||||
expect(help.defaultPrevented).toBe(false)
|
||||
expect(shortcutHelp.value).toBeNull()
|
||||
|
||||
// A browser retargets the event to the shadow host by the time it
|
||||
// reaches window; only composedPath() still starts at the input.
|
||||
for (const key of ['Escape', '?']) {
|
||||
const retargeted = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true })
|
||||
Object.defineProperty(retargeted, 'composedPath', { value: () => [input, host.shadowRoot, host, document.body, document, window] })
|
||||
host.dispatchEvent(retargeted)
|
||||
expect(retargeted.defaultPrevented).toBe(false)
|
||||
}
|
||||
expect(leave.clicks()).toBe(0)
|
||||
expect(shortcutHelp.value).toBeNull()
|
||||
|
||||
// The same keys from the page itself still work.
|
||||
composedKey(document.body, 'Escape')
|
||||
expect(leave.clicks()).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores ? and Escape from a custom field element inside a form (closed shadow root)', () => {
|
||||
uninstalls.push(installHotkeys())
|
||||
const leave = hotkeyButton('escape')
|
||||
const form = document.createElement('form')
|
||||
const field = document.createElement('acme-closed-field')
|
||||
form.appendChild(field)
|
||||
document.body.appendChild(form)
|
||||
|
||||
const escape = composedKey(field, 'Escape')
|
||||
const help = composedKey(field, '?')
|
||||
expect(leave.clicks()).toBe(0)
|
||||
expect(escape.defaultPrevented).toBe(false)
|
||||
expect(help.defaultPrevented).toBe(false)
|
||||
expect(shortcutHelp.value).toBeNull()
|
||||
|
||||
// Outside a form a custom element is not an editable control.
|
||||
const loose = document.createElement('acme-closed-field')
|
||||
document.body.appendChild(loose)
|
||||
composedKey(loose, 'Escape')
|
||||
expect(leave.clicks()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('save shortcuts commit a taglist draft', () => {
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
const schema = structuredClone(formSchema)
|
||||
schema.data.fields.push({ name: 'labels', label: 'Labels', type: 'taglist', span: 'full', mode: 'array' } as never)
|
||||
const loaded = structuredClone(record)
|
||||
;(loaded.data as Record<string, unknown>).labels = ['first']
|
||||
const routes: Record<string, Route> = {
|
||||
[`GET ${BASE}/schema/form`]: { body: schema },
|
||||
[`GET ${RECORD}`]: { body: loaded },
|
||||
[`PUT ${RECORD}`]: { body: loaded },
|
||||
[`GET ${BASE}/schema/list`]: { body: listSchema },
|
||||
[`GET ${BASE}`]: { body: listRows },
|
||||
}
|
||||
|
||||
async function typeDraft(): Promise<HTMLInputElement> {
|
||||
const input = document.querySelector<HTMLInputElement>('#field-labels')
|
||||
expect(input).not.toBeNull()
|
||||
input!.focus()
|
||||
input!.value = 'draft'
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
return input!
|
||||
}
|
||||
|
||||
async function putBody(calls: Request[]): Promise<Record<string, unknown>> {
|
||||
const puts = requestsTo(calls, 'PUT', RECORD)
|
||||
expect(puts).toHaveLength(1)
|
||||
return (await puts[0]!.clone().json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
it('Ctrl+S saves the record with the draft tag', async () => {
|
||||
const { calls } = await mountApp('/acme/demo/widgets/1', routes, { attach: true })
|
||||
const input = await typeDraft()
|
||||
|
||||
expect(press(input, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
const body = await putBody(calls)
|
||||
expect(JSON.stringify(body)).toContain('"draft"')
|
||||
expect(JSON.stringify(body)).toContain('"first"')
|
||||
})
|
||||
|
||||
it('Ctrl+Enter saves the draft tag before closing', async () => {
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets/1', routes, { attach: true })
|
||||
const input = await typeDraft()
|
||||
|
||||
expect(press(input, 'Enter', { ctrlKey: true }).defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
const body = await putBody(calls)
|
||||
expect(JSON.stringify(body)).toContain('"draft"')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user