127 lines
9.5 KiB
TypeScript
127 lines
9.5 KiB
TypeScript
/** DOM interaction regression, no Electron sandbox bypass or upstream calls.
|
|
* npm install --prefix /tmp/ycode-popconfirm-test --cache /tmp/ycode-popconfirm-test/cache --ignore-scripts jsdom
|
|
* esbuild scripts/task-popconfirm-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/task-popconfirm-regression.cjs
|
|
* YCODE_TEST_DOM_MODULE=/tmp/ycode-popconfirm-test/node_modules/jsdom node .tmp/task-popconfirm-regression.cjs
|
|
* jsdom verifies event/state contracts, NOT browser layout, pixels or WorkBuddy parity.
|
|
*/
|
|
import assert from 'node:assert/strict'
|
|
import { createRequire } from 'node:module'
|
|
const externalRequire = createRequire(process.cwd() + '/package.json')
|
|
const { JSDOM } = externalRequire(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
|
|
const dom = new JSDOM('<!doctype html><html><body><button id="anchor">Task</button><button id="other">Other task</button><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
|
|
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'ShadowRoot', 'MutationObserver', 'MouseEvent', 'KeyboardEvent', 'Event', 'getComputedStyle']) Object.defineProperty(globalThis, key, { value: key === 'getComputedStyle' ? dom.window.getComputedStyle.bind(dom.window) : dom.window[key], configurable: true })
|
|
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
|
|
Object.assign(globalThis, { requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })
|
|
// Synthetic visibility is for focus semantics only, not geometry validation.
|
|
dom.window.HTMLElement.prototype.getClientRects = function () { return [new dom.window.DOMRect(20, 20, 100, 30)] as unknown as DOMRectList }
|
|
dom.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
const React = require('react') as typeof import('react')
|
|
const { act } = React
|
|
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
|
|
const { TaskPopconfirm, taskConfirmationCopy } = require('../src/renderer/src/components/TaskPopconfirm') as typeof import('../src/renderer/src/components/TaskPopconfirm')
|
|
const { Popover } = require('../src/renderer/src/ui/Popover') as typeof import('../src/renderer/src/ui/Popover')
|
|
const root = createRoot(document.getElementById('root')!)
|
|
const anchor = document.getElementById('anchor')!
|
|
const other = document.getElementById('other')!
|
|
let checks = 0
|
|
function check(condition: unknown, message: string) { assert.ok(condition, message); checks++ }
|
|
async function settle() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)) }) }
|
|
function click(element: Element) { element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) }
|
|
async function main() {
|
|
let closes = 0, calls = 0, resolve: (() => void) | undefined, reject: ((error: Error) => void) | undefined
|
|
const close = () => { closes++; root.render(null) }
|
|
const submit = () => { calls++; return new Promise<void>((yes, no) => { resolve = yes; reject = no }) }
|
|
const render = async (kind: 'archive' | 'delete' = 'archive') => {
|
|
await act(async () => root.render(<TaskPopconfirm kind={kind} running={false} anchor={anchor} onConfirm={submit} onClose={close} />)); await settle()
|
|
}
|
|
await render()
|
|
check(!document.querySelector('.wb-modal__overlay'), 'no modal overlay')
|
|
check(document.querySelector('.task-popconfirm-source'), 'optional host class')
|
|
check(document.querySelector('.wb-popover__arrow'), 'arrow rendered')
|
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
|
|
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
|
|
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
|
|
check(!document.querySelector('.task-popconfirm__compatibility'), 'archive preserves selected original copy without extra explanation')
|
|
const confirm = document.querySelector('.task-popconfirm__confirm')!
|
|
await act(async () => { click(confirm); click(confirm) })
|
|
check(calls === 1, 'duplicate confirmation guarded synchronously')
|
|
check(confirm.hasAttribute('disabled'), 'confirmation disabled in flight')
|
|
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })))
|
|
check(closes === 0, 'pending operation not dismissed by Escape')
|
|
await act(async () => reject!(new Error('fixture failure')))
|
|
check(document.querySelector('[role="alert"]')?.textContent === 'fixture failure', 'failure remains visible')
|
|
check(!confirm.hasAttribute('disabled'), 'failure enables retry')
|
|
await act(async () => click(confirm))
|
|
check(calls === 2, 'retry submits once')
|
|
await act(async () => resolve!())
|
|
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
|
|
await render('delete')
|
|
await act(async () => root.render(null))
|
|
await act(async () => root.render(<TaskPopconfirm kind="delete" running anchor={anchor} onConfirm={submit} onClose={close} />)); await settle()
|
|
const runningConfirm = document.querySelector<HTMLButtonElement>('.task-popconfirm__confirm')!
|
|
const runningCancel = document.querySelector<HTMLButtonElement>('.task-popconfirm__cancel')!
|
|
check(runningConfirm.textContent === '停止并删除', 'running delete uses stop-and-delete action')
|
|
const beforeRunning = calls, beforeRunningClose = closes
|
|
await act(async () => { click(runningConfirm); click(runningConfirm); click(runningCancel) })
|
|
check(calls === beforeRunning + 1 && closes === beforeRunningClose, 'running delete is single-flight and cancellation locked')
|
|
check(runningConfirm.disabled && runningCancel.disabled && runningConfirm.textContent === '处理中…', 'running delete busy buttons remain readable and disabled')
|
|
await act(async () => reject!(new Error('stop failed')))
|
|
check(document.querySelector('[role="alert"]')?.textContent === 'stop failed', 'stop-and-delete failure retained')
|
|
check(!runningConfirm.disabled && runningConfirm.textContent === '停止并删除', 'stop-and-delete failure restores action')
|
|
await act(async () => click(runningConfirm))
|
|
await act(async () => resolve!())
|
|
check(calls === beforeRunning + 2 && closes === beforeRunningClose + 1, 'stop-and-delete retry succeeds once')
|
|
await render('delete')
|
|
let underlyingClicks = 0
|
|
other.addEventListener('click', () => underlyingClicks++)
|
|
await act(async () => {
|
|
other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }))
|
|
click(other)
|
|
})
|
|
check(underlyingClicks === 0, 'outside click is consumed before underlying task handler')
|
|
check(!document.querySelector('.task-popconfirm'), 'outside click closes')
|
|
await act(async () => click(other))
|
|
check(underlyingClicks === 1, 'only the dismissal click is consumed')
|
|
await render()
|
|
const otherChild = document.createElement('span')
|
|
otherChild.textContent = 'nested task title'
|
|
other.append(otherChild)
|
|
await act(async () => {
|
|
otherChild.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }))
|
|
// Pointer-up on the row produces a click on the common ancestor, not the title.
|
|
click(other)
|
|
})
|
|
check(underlyingClicks === 1, 'retargeted outside click cannot select underlying task')
|
|
check(!document.querySelector('.task-popconfirm'), 'retargeted dismissal still closes')
|
|
otherChild.remove()
|
|
await render()
|
|
anchor.focus()
|
|
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })))
|
|
check(document.activeElement === anchor, 'Escape restores anchor focus')
|
|
await render()
|
|
await act(async () => click(document.querySelector('.task-popconfirm__confirm')!))
|
|
const closesBeforeRemoval = closes
|
|
await act(async () => anchor.remove())
|
|
await settle()
|
|
check(!document.querySelector('.task-popconfirm'), 'removed anchor closes popup')
|
|
check(closes === closesBeforeRemoval + 1, 'anchor disappearance closes even in flight')
|
|
await act(async () => resolve!())
|
|
check(closes === closesBeforeRemoval + 1, 'late completion after unmount cannot close another popup')
|
|
document.body.prepend(anchor)
|
|
await act(async () => root.render(<Popover trigger={<button id="legacy">Legacy</button>} open={false} onOpenChange={() => {}}><span>hidden</span></Popover>))
|
|
check(document.getElementById('legacy')?.getAttribute('aria-expanded') === 'false', 'legacy trigger API preserved')
|
|
check(!document.querySelector('.wb-popover'), 'closed legacy popover remains unmounted')
|
|
let legacyOutside = 0
|
|
await act(async () => root.render(<Popover trigger={<button id="legacy">Legacy</button>} open onOpenChange={() => legacyOutside++}><button>Choice</button></Popover>))
|
|
await settle()
|
|
await act(async () => { other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); click(other) })
|
|
check(legacyOutside === 1, 'legacy outside pointer still requests close')
|
|
check(underlyingClicks === 2, 'legacy outside click is not consumed by default')
|
|
check(!document.querySelector('.wb-popover__arrow'), 'legacy arrow remains opt-in')
|
|
await act(async () => root.unmount())
|
|
dom.window.close()
|
|
console.log(`task popconfirm DOM regression: ${checks} checks passed (jsdom, not pixel validation)`)
|
|
}
|
|
// React's scheduler MessageChannel can keep Node alive after jsdom cleanup.
|
|
main().then(() => process.exit(0), error => { console.error(error); dom.window.close(); process.exit(1) })
|