Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: identify browser/jsdom differences #1091

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
1 change: 1 addition & 0 deletions .eslintrc.js
Expand Up @@ -15,6 +15,7 @@ module.exports = {
'@typescript-eslint/non-nullable-type-assertion-style': 0,
// ES2022 will be released in June 2022
'prefer-object-has-own': 0,
'import/consistent-type-specifier-style': 0,
},
overrides: [
{
Expand Down
60 changes: 43 additions & 17 deletions src/system/keyboard.ts
Expand Up @@ -70,23 +70,51 @@ export class KeyboardHost {
Symbol: false,
SymbolLock: false,
}
readonly pressed: Record<
string,
{
keyDef: keyboardKey
unpreventedDefault: boolean
private readonly pressed = new (class {
registry: {
[k in string]?: {
keyDef: keyboardKey
unpreventedDefault: boolean
}
} = {}
add(code: string, keyDef: keyboardKey) {
this.registry[code] ??= {
keyDef,
unpreventedDefault: false,
}
}
has(code: string) {
return !!this.registry[code]
}
setUnprevented(code: string) {
const o = this.registry[code]
if (o) {
o.unpreventedDefault = true
}
}
> = {}
isUnprevented(code: string) {
return !!this.registry[code]?.unpreventedDefault
}
delete(code: string) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.registry[code]
}
values() {
return Object.values(this.registry) as NonNullable<
typeof this.registry[string]
>[]
}
})()
carryChar = ''
private lastKeydownTarget: Element | undefined = undefined
private readonly modifierLockStart: Record<string, boolean> = {}

isKeyPressed(keyDef: keyboardKey) {
return !!this.pressed[String(keyDef.code)]
return this.pressed.has(String(keyDef.code))
}

getPressedKeys() {
return Object.values(this.pressed).map(p => p.keyDef)
return this.pressed.values().map(p => p.keyDef)
}

/** Press a key */
Expand All @@ -97,10 +125,7 @@ export class KeyboardHost {
const target = getActiveElementOrBody(instance.config.document)
this.setKeydownTarget(target)

this.pressed[code] ??= {
keyDef,
unpreventedDefault: false,
}
this.pressed.add(code, keyDef)

if (isModifierKey(key)) {
this.modifiers[key] = true
Expand All @@ -116,7 +141,9 @@ export class KeyboardHost {
this.modifierLockStart[key] = true
}

this.pressed[code].unpreventedDefault ||= unprevented
if (unprevented) {
this.pressed.setUnprevented(code)
}

if (unprevented && this.hasKeyPress(key)) {
instance.dispatchUIEvent(
Expand All @@ -137,14 +164,13 @@ export class KeyboardHost {
const key = String(keyDef.key)
const code = String(keyDef.code)

const unprevented = this.pressed[code].unpreventedDefault
const unprevented = this.pressed.isUnprevented(code)

// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.pressed[code]
this.pressed.delete(code)

if (
isModifierKey(key) &&
!Object.values(this.pressed).find(p => p.keyDef.key === key)
!this.pressed.values().find(p => p.keyDef.key === key)
) {
this.modifiers[key] = false
}
Expand Down
5 changes: 2 additions & 3 deletions src/system/pointer/index.ts
Expand Up @@ -20,11 +20,10 @@ export class PointerHost {
private readonly buttons

private readonly devices = new (class {
private registry = {} as Record<string, Device>
private registry: {[k in string]?: Device} = {}

get(k: string) {
this.registry[k] ??= new Device()
return this.registry[k]
return (this.registry[k] ??= new Device())
}
})()

Expand Down
4 changes: 4 additions & 0 deletions tests/_helpers/index.ts
Expand Up @@ -3,3 +3,7 @@

export {render, setup} from './setup'
export {addEventListener, addListeners} from './listeners'

export function isJsdomEnv() {
return window.navigator.userAgent.includes(' jsdom/')
}
1 change: 1 addition & 0 deletions tests/_helpers/listeners.ts
Expand Up @@ -109,6 +109,7 @@ export function addListeners(

function getEventSnapshot() {
const eventCalls = eventHandlerCalls
.filter(({event}) => event.type !== 'select')
.map(({event, elementDisplayName}) => {
const firstLine = [
`${elementDisplayName} - ${event.type}`,
Expand Down
4 changes: 2 additions & 2 deletions tests/clipboard/copy.ts
Expand Up @@ -19,7 +19,7 @@ test('copy selected value', async () => {

test('copy selected text outside of editable', async () => {
const {getEvents, user} = setup(`<div tabindex="-1">foo bar baz</div>`, {
selection: {focusNode: '//text()', anchorOffset: 1, focusOffset: 5},
selection: {focusNode: './/text()', anchorOffset: 1, focusOffset: 5},
})

const dt = await user.copy()
Expand All @@ -32,7 +32,7 @@ test('copy selected text outside of editable', async () => {

test('copy selected text in contenteditable', async () => {
const {getEvents, user} = setup(`<div contenteditable>foo bar baz</div>`, {
selection: {focusNode: '//text()', anchorOffset: 1, focusOffset: 5},
selection: {focusNode: './/text()', anchorOffset: 1, focusOffset: 5},
})

const dt = await user.copy()
Expand Down
4 changes: 2 additions & 2 deletions tests/clipboard/cut.ts
Expand Up @@ -20,7 +20,7 @@ test('cut selected value', async () => {

test('cut selected text outside of editable', async () => {
const {getEvents, user} = setup(`<div tabindex="-1">foo bar baz</div>`, {
selection: {focusNode: '//text()', anchorOffset: 1, focusOffset: 5},
selection: {focusNode: './/text()', anchorOffset: 1, focusOffset: 5},
})

const dt = await user.cut()
Expand All @@ -36,7 +36,7 @@ test('cut selected text in contenteditable', async () => {
const {element, getEvents, user} = setup(
`<div contenteditable>foo bar baz</div>`,
{
selection: {focusNode: '//text()', anchorOffset: 1, focusOffset: 5},
selection: {focusNode: './/text()', anchorOffset: 1, focusOffset: 5},
},
)

Expand Down
17 changes: 17 additions & 0 deletions tests/environment/computedStyle.ts
@@ -0,0 +1,17 @@
import {isJsdomEnv, render} from '#testHelpers'

test('window.getComputedStyle returns resolved inherited style in browser', () => {
const {element, xpathNode} = render(`
<div style='pointer-events: none'>
<button></button>
</div>`)

expect(window.getComputedStyle(element)).toHaveProperty(
'pointer-events',
'none',
)
expect(window.getComputedStyle(xpathNode('//button'))).toHaveProperty(
'pointer-events',
isJsdomEnv() ? '' : 'none',
)
})
48 changes: 48 additions & 0 deletions tests/environment/select.ts
@@ -0,0 +1,48 @@
import {isJsdomEnv, render} from '#testHelpers'
import DTL from '#src/_interop/dtl'

test('`Selection.setBaseAndExtent()` resets input selection in browser', async () => {
const {element} = render<HTMLInputElement>(`<input value="foo"/>`, {
selection: {focusOffset: 3},
})
expect(element.selectionStart).toBe(3)

element.ownerDocument.getSelection()?.setBaseAndExtent(element, 0, element, 0)

expect(element.selectionStart).toBe(isJsdomEnv() ? 3 : 0)
})

test('events are not guaranteed to be dispatched on same microtask in browser', async () => {
const {element} = render<HTMLInputElement>(`<input value="foo"/>`)
const onSelect = mocks.fn()
element.addEventListener('select', onSelect)

element.setSelectionRange(1, 2)

expect(onSelect).toBeCalledTimes(isJsdomEnv() ? 1 : 0)

await DTL.waitFor(() => expect(onSelect).toBeCalledTimes(1))
})

test('`HTMLInputElement.focus()` in contenteditable changes `Selection` in browser', () => {
const {element, xpathNode} = render<HTMLInputElement>(
`<div contenteditable="true"><input/></div><span></span>`,
{
selection: {
focusNode: '//span',
},
},
)

expect(element.ownerDocument.getSelection()).toHaveProperty(
'anchorNode',
xpathNode('//span'),
)

xpathNode('//input').focus()

expect(element.ownerDocument.getSelection()).toHaveProperty(
'anchorNode',
isJsdomEnv() ? xpathNode('//span') : element,
)
})
2 changes: 1 addition & 1 deletion tests/event/input.ts
Expand Up @@ -96,7 +96,7 @@ cases(
`<div contenteditable="true">abcd</div>`,
{
selection: {
focusNode: '//text()',
focusNode: './/text()',
anchorOffset: range[0],
focusOffset: range[1],
},
Expand Down
13 changes: 11 additions & 2 deletions tests/event/selection/index.ts
Expand Up @@ -6,7 +6,7 @@ import {
setSelection,
setSelectionRange,
} from '#src/event/selection'
import {setup} from '#testHelpers'
import {isJsdomEnv, setup} from '#testHelpers'

test('range on input', async () => {
const {element} = setup('<input value="foo"/>')
Expand Down Expand Up @@ -36,7 +36,16 @@ test('range on input', async () => {
test('range on contenteditable', async () => {
const {element} = setup('<div contenteditable="true">foo</div>')

expect(getInputRange(element)).toBe(undefined)
expect(getInputRange(element)).toEqual(
isJsdomEnv()
? undefined
: expect.objectContaining({
startContainer: element.firstChild,
startOffset: 0,
endContainer: element.firstChild,
endOffset: 0,
}),
)

setSelection({
focusNode: element,
Expand Down
19 changes: 12 additions & 7 deletions tests/utility/clear.ts
@@ -1,3 +1,4 @@
import {setUISelectionClean} from '#src/document/UI'
import {setup} from '#testHelpers'

describe('clear elements', () => {
Expand All @@ -12,7 +13,6 @@ describe('clear elements', () => {

input[value="hello"] - focus
input[value="hello"] - focusin
input[value="hello"] - select
input[value="hello"] - beforeinput
input[value=""] - input
`)
Expand All @@ -30,7 +30,6 @@ describe('clear elements', () => {

textarea[value="hello"] - focus
textarea[value="hello"] - focusin
textarea[value="hello"] - select
textarea[value="hello"] - beforeinput
textarea[value=""] - input
`)
Expand Down Expand Up @@ -110,12 +109,18 @@ describe('throw error when clear is impossible', () => {
)
})

test('abort if event handler prevents content being selected', async () => {
test('abort if selecting content is prevented', async () => {
const {element, user} = setup<HTMLInputElement>(`<input value="hello"/>`)
element.addEventListener('select', async () => {
if (element.selectionStart === 0) {
element.selectionStart = 1
}
// In some environments a `select` event handler can reset the selection before we can clear the input.
// In browser the `.clear()` API is done before the event is dispatched.
Object.defineProperty(element, 'setSelectionRange', {
configurable: true,
value(start: number, end: number) {
;(
Object.getPrototypeOf(element) as HTMLInputElement
).setSelectionRange.call(this, 1, end)
setUISelectionClean(element)
},
})

await expect(
Expand Down
1 change: 0 additions & 1 deletion tests/utility/type.ts
Expand Up @@ -19,7 +19,6 @@ test('type into input', async () => {
input[value="foo"] - mousemove
input[value="foo"] - pointerdown
input[value="foo"] - mousedown: primary
input[value="foo"] - select
input[value="foo"] - focus
input[value="foo"] - focusin
input[value="foo"] - pointerup
Expand Down