Set up comprehensive frontend testing infrastructure

- Install Jest for unit testing with React Testing Library
- Install Playwright for end-to-end testing
- Configure Jest with proper TypeScript support and module mapping
- Create test setup files and utilities for both unit and e2e tests

Components:
* Jest configuration with coverage thresholds
* Playwright configuration with browser automation
* Unit tests for LoginForm, AuthContext, and useSocketIO hook
* E2E tests for authentication, dashboard, and agents workflows
* GitHub Actions workflow for automated testing
* Mock data and API utilities for consistent testing
* Test documentation with best practices

Testing features:
- Unit tests with 70% coverage threshold
- E2E tests with API mocking and user journey testing
- CI/CD integration for automated test runs
- Cross-browser testing support with Playwright
- Authentication system testing end-to-end

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
anthonyrawlins
2025-07-11 14:06:34 +10:00
parent c6d69695a8
commit aacb45156b
6109 changed files with 777927 additions and 1 deletions

View File

@@ -0,0 +1,18 @@
import { isElementType } from '../misc/isElementType.js';
var clickableInputTypes = /*#__PURE__*/ function(clickableInputTypes) {
clickableInputTypes["button"] = "button";
clickableInputTypes["color"] = "color";
clickableInputTypes["file"] = "file";
clickableInputTypes["image"] = "image";
clickableInputTypes["reset"] = "reset";
clickableInputTypes["submit"] = "submit";
clickableInputTypes["checkbox"] = "checkbox";
clickableInputTypes["radio"] = "radio";
return clickableInputTypes;
}(clickableInputTypes || {});
function isClickableInput(element) {
return isElementType(element, 'button') || isElementType(element, 'input') && element.type in clickableInputTypes;
}
export { isClickableInput };

View File

@@ -0,0 +1,14 @@
// jsdom does not implement Blob.text()
function readBlobText(blob, FileReader) {
return new Promise((res, rej)=>{
const fr = new FileReader();
fr.onerror = rej;
fr.onabort = rej;
fr.onload = ()=>{
res(String(fr.result));
};
fr.readAsText(blob);
});
}
export { readBlobText };

View File

@@ -0,0 +1,162 @@
import { getWindow } from '../misc/getWindow.js';
import { readBlobText } from './Blob.js';
import { createDataTransfer, getBlobFromDataTransferItem } from './DataTransfer.js';
// Clipboard is not available in jsdom
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
// MDN lists string|Blob|Promise<Blob|string> as possible types in ClipboardItemData
// lib.dom.d.ts lists only Promise<Blob|string>
// https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem/ClipboardItem#syntax
function createClipboardItem(window, ...blobs) {
const dataMap = Object.fromEntries(blobs.map((b)=>[
typeof b === 'string' ? 'text/plain' : b.type,
Promise.resolve(b)
]));
// use real ClipboardItem if available
/* istanbul ignore if */ if (typeof window.ClipboardItem !== 'undefined') {
return new window.ClipboardItem(dataMap);
}
return new class ClipboardItem {
get types() {
return Array.from(Object.keys(this.data));
}
async getType(type) {
const value = await this.data[type];
if (!value) {
throw new Error(`${type} is not one of the available MIME types on this item.`);
}
return value instanceof window.Blob ? value : new window.Blob([
value
], {
type
});
}
constructor(d){
_define_property(this, "data", undefined);
this.data = d;
}
}(dataMap);
}
const ClipboardStubControl = Symbol('Manage ClipboardSub');
function createClipboardStub(window, control) {
return Object.assign(new class Clipboard extends window.EventTarget {
async read() {
return Array.from(this.items);
}
async readText() {
let text = '';
for (const item of this.items){
const type = item.types.includes('text/plain') ? 'text/plain' : item.types.find((t)=>t.startsWith('text/'));
if (type) {
text += await item.getType(type).then((b)=>readBlobText(b, window.FileReader));
}
}
return text;
}
async write(data) {
this.items = data;
}
async writeText(text) {
this.items = [
createClipboardItem(window, text)
];
}
constructor(...args){
super(...args), _define_property(this, "items", []);
}
}(), {
[ClipboardStubControl]: control
});
}
function isClipboardStub(clipboard) {
return !!(clipboard === null || clipboard === undefined ? undefined : clipboard[ClipboardStubControl]);
}
function attachClipboardStubToView(window) {
if (isClipboardStub(window.navigator.clipboard)) {
return window.navigator.clipboard[ClipboardStubControl];
}
const realClipboard = Object.getOwnPropertyDescriptor(window.navigator, 'clipboard');
let stub;
const control = {
resetClipboardStub: ()=>{
stub = createClipboardStub(window, control);
},
detachClipboardStub: ()=>{
/* istanbul ignore if */ if (realClipboard) {
Object.defineProperty(window.navigator, 'clipboard', realClipboard);
} else {
Object.defineProperty(window.navigator, 'clipboard', {
value: undefined,
configurable: true
});
}
}
};
stub = createClipboardStub(window, control);
Object.defineProperty(window.navigator, 'clipboard', {
get: ()=>stub,
configurable: true
});
return stub[ClipboardStubControl];
}
function resetClipboardStubOnView(window) {
if (isClipboardStub(window.navigator.clipboard)) {
window.navigator.clipboard[ClipboardStubControl].resetClipboardStub();
}
}
function detachClipboardStubFromView(window) {
if (isClipboardStub(window.navigator.clipboard)) {
window.navigator.clipboard[ClipboardStubControl].detachClipboardStub();
}
}
async function readDataTransferFromClipboard(document) {
const window = document.defaultView;
const clipboard = window === null || window === undefined ? undefined : window.navigator.clipboard;
const items = clipboard && await clipboard.read();
if (!items) {
throw new Error('The Clipboard API is unavailable.');
}
const dt = createDataTransfer(window);
for (const item of items){
for (const type of item.types){
dt.setData(type, await item.getType(type).then((b)=>readBlobText(b, window.FileReader)));
}
}
return dt;
}
async function writeDataTransferToClipboard(document, clipboardData) {
const window = getWindow(document);
const clipboard = window.navigator.clipboard;
const items = [];
for(let i = 0; i < clipboardData.items.length; i++){
const dtItem = clipboardData.items[i];
const blob = await getBlobFromDataTransferItem(window, dtItem);
items.push(createClipboardItem(window, blob));
}
const written = clipboard && await clipboard.write(items).then(()=>true, // Can happen with other implementations that e.g. require permissions
/* istanbul ignore next */ ()=>false);
if (!written) {
throw new Error('The Clipboard API is unavailable.');
}
}
const g = globalThis;
/* istanbul ignore else */ if (typeof g.afterEach === 'function') {
g.afterEach(()=>resetClipboardStubOnView(globalThis.window));
}
/* istanbul ignore else */ if (typeof g.afterAll === 'function') {
g.afterAll(()=>detachClipboardStubFromView(globalThis.window));
}
export { attachClipboardStubToView, createClipboardItem, detachClipboardStubFromView, readDataTransferFromClipboard, resetClipboardStubOnView, writeDataTransferToClipboard };

View File

@@ -0,0 +1,133 @@
import { createFileList } from './FileList.js';
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
// DataTransfer is not implemented in jsdom.
// DataTransfer with FileList is being created by the browser on certain events.
class DataTransferItemStub {
getAsFile() {
return this.file;
}
getAsString(callback) {
if (typeof this.data === 'string') {
callback(this.data);
}
}
/* istanbul ignore next */ webkitGetAsEntry() {
throw new Error('not implemented');
}
constructor(dataOrFile, type){
_define_property(this, "kind", undefined);
_define_property(this, "type", undefined);
_define_property(this, "file", null);
_define_property(this, "data", undefined);
if (typeof dataOrFile === 'string') {
this.kind = 'string';
this.type = String(type);
this.data = dataOrFile;
} else {
this.kind = 'file';
this.type = dataOrFile.type;
this.file = dataOrFile;
}
}
}
class DataTransferItemListStub extends Array {
add(...args) {
const item = new DataTransferItemStub(args[0], args[1]);
this.push(item);
return item;
}
clear() {
this.splice(0, this.length);
}
remove(index) {
this.splice(index, 1);
}
}
function getTypeMatcher(type, exact) {
const [group, sub] = type.split('/');
const isGroup = !sub || sub === '*';
return (item)=>{
return exact ? item.type === (isGroup ? group : type) : isGroup ? item.type.startsWith(`${group}/`) : item.type === group;
};
}
function createDataTransferStub(window) {
return new class DataTransferStub {
getData(format) {
var _this_items_find;
const match = (_this_items_find = this.items.find(getTypeMatcher(format, true))) !== null && _this_items_find !== undefined ? _this_items_find : this.items.find(getTypeMatcher(format, false));
let text = '';
match === null || match === undefined ? undefined : match.getAsString((t)=>{
text = t;
});
return text;
}
setData(format, data) {
const matchIndex = this.items.findIndex(getTypeMatcher(format, true));
const item = new DataTransferItemStub(data, format);
if (matchIndex >= 0) {
this.items.splice(matchIndex, 1, item);
} else {
this.items.push(item);
}
}
clearData(format) {
if (format) {
const matchIndex = this.items.findIndex(getTypeMatcher(format, true));
if (matchIndex >= 0) {
this.items.remove(matchIndex);
}
} else {
this.items.clear();
}
}
get types() {
const t = [];
if (this.files.length) {
t.push('Files');
}
this.items.forEach((i)=>t.push(i.type));
Object.freeze(t);
return t;
}
/* istanbul ignore next */ setDragImage() {}
constructor(){
_define_property(this, "dropEffect", 'none');
_define_property(this, "effectAllowed", 'uninitialized');
_define_property(this, "items", new DataTransferItemListStub());
_define_property(this, "files", createFileList(window, []));
}
}();
}
function createDataTransfer(window, files = []) {
// Use real DataTransfer if available
const dt = typeof window.DataTransfer === 'undefined' ? createDataTransferStub(window) : /* istanbul ignore next */ new window.DataTransfer();
Object.defineProperty(dt, 'files', {
get: ()=>createFileList(window, files)
});
return dt;
}
async function getBlobFromDataTransferItem(window, item) {
if (item.kind === 'file') {
return item.getAsFile();
}
return new window.Blob([
await new Promise((r)=>item.getAsString(r))
], {
type: item.type
});
}
export { createDataTransfer, getBlobFromDataTransferItem };

View File

@@ -0,0 +1,22 @@
// FileList can not be created per constructor.
function createFileList(window, files) {
const list = {
...files,
length: files.length,
item: (index)=>list[index],
[Symbol.iterator]: function* nextFile() {
for(let i = 0; i < list.length; i++){
yield list[i];
}
}
};
list.constructor = window.FileList;
// guard for environments without FileList
/* istanbul ignore else */ if (window.FileList) {
Object.setPrototypeOf(list, window.FileList.prototype);
}
Object.freeze(list);
return list;
}
export { createFileList };

View File

@@ -0,0 +1,15 @@
//jsdom is not supporting isContentEditable
function isContentEditable(element) {
return element.hasAttribute('contenteditable') && (element.getAttribute('contenteditable') == 'true' || element.getAttribute('contenteditable') == '');
}
/**
* If a node is a contenteditable or inside one, return that element.
*/ function getContentEditable(node) {
const element = getElement(node);
return element && (element.closest('[contenteditable=""]') || element.closest('[contenteditable="true"]'));
}
function getElement(node) {
return node.nodeType === 1 ? node : node.parentElement;
}
export { getContentEditable, isContentEditable };

View File

@@ -0,0 +1,26 @@
import { isElementType } from '../misc/isElementType.js';
import { isContentEditable } from './isContentEditable.js';
function isEditable(element) {
return isEditableInputOrTextArea(element) && !element.readOnly || isContentEditable(element);
}
var editableInputTypes = /*#__PURE__*/ function(editableInputTypes) {
editableInputTypes["text"] = "text";
editableInputTypes["date"] = "date";
editableInputTypes["datetime-local"] = "datetime-local";
editableInputTypes["email"] = "email";
editableInputTypes["month"] = "month";
editableInputTypes["number"] = "number";
editableInputTypes["password"] = "password";
editableInputTypes["search"] = "search";
editableInputTypes["tel"] = "tel";
editableInputTypes["time"] = "time";
editableInputTypes["url"] = "url";
editableInputTypes["week"] = "week";
return editableInputTypes;
}(editableInputTypes || {});
function isEditableInputOrTextArea(element) {
return isElementType(element, 'textarea') || isElementType(element, 'input') && element.type in editableInputTypes;
}
export { isEditable, isEditableInputOrTextArea };

View File

@@ -0,0 +1,23 @@
import { isElementType } from '../misc/isElementType.js';
var maxLengthSupportedTypes = /*#__PURE__*/ function(maxLengthSupportedTypes) {
maxLengthSupportedTypes["email"] = "email";
maxLengthSupportedTypes["password"] = "password";
maxLengthSupportedTypes["search"] = "search";
maxLengthSupportedTypes["telephone"] = "telephone";
maxLengthSupportedTypes["text"] = "text";
maxLengthSupportedTypes["url"] = "url";
return maxLengthSupportedTypes;
}(maxLengthSupportedTypes || {});
// can't use .maxLength property because of a jsdom bug:
// https://github.com/jsdom/jsdom/issues/2927
function getMaxLength(element) {
var _element_getAttribute;
const attr = (_element_getAttribute = element.getAttribute('maxlength')) !== null && _element_getAttribute !== undefined ? _element_getAttribute : '';
return /^\d+$/.test(attr) && Number(attr) >= 0 ? Number(attr) : undefined;
}
function supportsMaxLength(element) {
return isElementType(element, 'textarea') || isElementType(element, 'input') && element.type in maxLengthSupportedTypes;
}
export { getMaxLength, supportsMaxLength };

View File

@@ -0,0 +1,57 @@
// It is not possible to create a real FileList programmatically.
// Therefore assigning `files` property with a programmatically created FileList results in an error.
// Just assigning the property (as per fireEvent) breaks the interweaving with the `value` property.
const fakeFiles = Symbol('files and value properties are mocked');
function restoreProperty(obj, prop, descriptor) {
if (descriptor) {
Object.defineProperty(obj, prop, descriptor);
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete obj[prop];
}
}
function setFiles(el, files) {
var _el_fakeFiles;
(_el_fakeFiles = el[fakeFiles]) === null || _el_fakeFiles === undefined ? undefined : _el_fakeFiles.restore();
const typeDescr = Object.getOwnPropertyDescriptor(el, 'type');
const valueDescr = Object.getOwnPropertyDescriptor(el, 'value');
const filesDescr = Object.getOwnPropertyDescriptor(el, 'files');
function restore() {
restoreProperty(el, 'type', typeDescr);
restoreProperty(el, 'value', valueDescr);
restoreProperty(el, 'files', filesDescr);
}
el[fakeFiles] = {
restore
};
Object.defineProperties(el, {
files: {
configurable: true,
get: ()=>files
},
value: {
configurable: true,
get: ()=>files.length ? `C:\\fakepath\\${files[0].name}` : '',
set (v) {
if (v === '') {
restore();
} else {
var _valueDescr_set;
valueDescr === null || valueDescr === undefined ? undefined : (_valueDescr_set = valueDescr.set) === null || _valueDescr_set === undefined ? undefined : _valueDescr_set.call(el, v);
}
}
},
type: {
configurable: true,
get: ()=>'file',
set (v) {
if (v !== 'file') {
restore();
el.type = v;
}
}
}
});
}
export { setFiles };

View File

@@ -0,0 +1,37 @@
const parseInt = globalThis.parseInt;
function buildTimeValue(value) {
const onlyDigitsValue = value.replace(/\D/g, '');
if (onlyDigitsValue.length < 2) {
return value;
}
const firstDigit = parseInt(onlyDigitsValue[0], 10);
const secondDigit = parseInt(onlyDigitsValue[1], 10);
if (firstDigit >= 3 || firstDigit === 2 && secondDigit >= 4) {
let index;
if (firstDigit >= 3) {
index = 1;
} else {
index = 2;
}
return build(onlyDigitsValue, index);
}
if (value.length === 2) {
return value;
}
return build(onlyDigitsValue, 2);
}
function build(onlyDigitsValue, index) {
const hours = onlyDigitsValue.slice(0, index);
const validHours = Math.min(parseInt(hours, 10), 23);
const minuteCharacters = onlyDigitsValue.slice(index);
const parsedMinutes = parseInt(minuteCharacters, 10);
const validMinutes = Math.min(parsedMinutes, 59);
return `${validHours.toString().padStart(2, '0')}:${validMinutes.toString().padStart(2, '0')}`;
}
function isValidDateOrTimeValue(element, value) {
const clone = element.cloneNode();
clone.value = value;
return clone.value === value;
}
export { buildTimeValue, isValidDateOrTimeValue };

View File

@@ -0,0 +1,124 @@
import { isContentEditable } from '../edit/isContentEditable.js';
import { isElementType } from '../misc/isElementType.js';
function getNextCursorPosition(node, offset, direction, inputType) {
// The behavior at text node zero offset is inconsistent.
// When walking backwards:
// Firefox always moves to zero offset and jumps over last offset.
// Chrome jumps over zero offset per default but over last offset when Shift is pressed.
// The cursor always moves to zero offset if the focus area (contenteditable or body) ends there.
// When walking forward both ignore zero offset.
// When walking over input elements the cursor moves before or after that element.
// When walking over line breaks the cursor moves inside any following text node.
if (isTextNode(node) && offset + direction >= 0 && offset + direction <= node.nodeValue.length) {
return {
node,
offset: offset + direction
};
}
const nextNode = getNextCharacterContentNode(node, offset, direction);
if (nextNode) {
if (isTextNode(nextNode)) {
return {
node: nextNode,
offset: direction > 0 ? Math.min(1, nextNode.nodeValue.length) : Math.max(nextNode.nodeValue.length - 1, 0)
};
} else if (isElementType(nextNode, 'br')) {
const nextPlusOne = getNextCharacterContentNode(nextNode, undefined, direction);
if (!nextPlusOne) {
// The behavior when there is no possible cursor position beyond the line break is inconsistent.
// In Chrome outside of contenteditable moving before a leading line break is possible.
// A leading line break can still be removed per deleteContentBackward.
// A trailing line break on the other hand is not removed by deleteContentForward.
if (direction < 0 && inputType === 'deleteContentBackward') {
return {
node: nextNode.parentNode,
offset: getOffset(nextNode)
};
}
return undefined;
} else if (isTextNode(nextPlusOne)) {
return {
node: nextPlusOne,
offset: direction > 0 ? 0 : nextPlusOne.nodeValue.length
};
} else if (direction < 0 && isElementType(nextPlusOne, 'br')) {
return {
node: nextNode.parentNode,
offset: getOffset(nextNode)
};
} else {
return {
node: nextPlusOne.parentNode,
offset: getOffset(nextPlusOne) + (direction > 0 ? 0 : 1)
};
}
} else {
return {
node: nextNode.parentNode,
offset: getOffset(nextNode) + (direction > 0 ? 1 : 0)
};
}
}
}
function getNextCharacterContentNode(node, offset, direction) {
const nextOffset = Number(offset) + (direction < 0 ? -1 : 0);
if (offset !== undefined && isElement(node) && nextOffset >= 0 && nextOffset < node.children.length) {
node = node.children[nextOffset];
}
return walkNodes(node, direction === 1 ? 'next' : 'previous', isTreatedAsCharacterContent);
}
function isTreatedAsCharacterContent(node) {
if (isTextNode(node)) {
return true;
}
if (isElement(node)) {
if (isElementType(node, [
'input',
'textarea'
])) {
return node.type !== 'hidden';
} else if (isElementType(node, 'br')) {
return true;
}
}
return false;
}
function getOffset(node) {
let i = 0;
while(node.previousSibling){
i++;
node = node.previousSibling;
}
return i;
}
function isElement(node) {
return node.nodeType === 1;
}
function isTextNode(node) {
return node.nodeType === 3;
}
function walkNodes(node, direction, callback) {
for(;;){
var _node_ownerDocument;
const sibling = node[`${direction}Sibling`];
if (sibling) {
node = getDescendant(sibling, direction === 'next' ? 'first' : 'last');
if (callback(node)) {
return node;
}
} else if (node.parentNode && (!isElement(node.parentNode) || !isContentEditable(node.parentNode) && node.parentNode !== ((_node_ownerDocument = node.ownerDocument) === null || _node_ownerDocument === undefined ? undefined : _node_ownerDocument.body))) {
node = node.parentNode;
} else {
break;
}
}
}
function getDescendant(node, direction) {
while(node.hasChildNodes()){
node = node[`${direction}Child`];
}
return node;
}
export { getNextCursorPosition };

View File

@@ -0,0 +1,20 @@
import { isDisabled } from '../misc/isDisabled.js';
function getActiveElement(document) {
const activeElement = document.activeElement;
if (activeElement === null || activeElement === undefined ? undefined : activeElement.shadowRoot) {
return getActiveElement(activeElement.shadowRoot);
} else {
// Browser does not yield disabled elements as document.activeElement - jsdom does
if (isDisabled(activeElement)) {
return document.ownerDocument ? /* istanbul ignore next */ document.ownerDocument.body : document.body;
}
return activeElement;
}
}
function getActiveElementOrBody(document) {
var _getActiveElement;
return (_getActiveElement = getActiveElement(document)) !== null && _getActiveElement !== undefined ? _getActiveElement : /* istanbul ignore next */ document.body;
}
export { getActiveElement, getActiveElementOrBody };

View File

@@ -0,0 +1,78 @@
import { isDisabled } from '../misc/isDisabled.js';
import { isElementType } from '../misc/isElementType.js';
import { isVisible } from '../misc/isVisible.js';
import { FOCUSABLE_SELECTOR } from './selector.js';
function getTabDestination(activeElement, shift) {
const document = activeElement.ownerDocument;
const focusableElements = document.querySelectorAll(FOCUSABLE_SELECTOR);
const enabledElements = Array.from(focusableElements).filter((el)=>el === activeElement || !(Number(el.getAttribute('tabindex')) < 0 || isDisabled(el)));
// tabindex has no effect if the active element has negative tabindex
if (Number(activeElement.getAttribute('tabindex')) >= 0) {
enabledElements.sort((a, b)=>{
const i = Number(a.getAttribute('tabindex'));
const j = Number(b.getAttribute('tabindex'));
if (i === j) {
return 0;
} else if (i === 0) {
return 1;
} else if (j === 0) {
return -1;
}
return i - j;
});
}
const checkedRadio = {};
let prunedElements = [
document.body
];
const activeRadioGroup = isElementType(activeElement, 'input', {
type: 'radio'
}) ? activeElement.name : undefined;
enabledElements.forEach((currentElement)=>{
const el = currentElement;
// For radio groups keep only the active radio
// If there is no active radio, keep only the checked radio
// If there is no checked radio, treat like everything else
if (isElementType(el, 'input', {
type: 'radio'
}) && el.name) {
// If the active element is part of the group, add only that
if (el === activeElement) {
prunedElements.push(el);
return;
} else if (el.name === activeRadioGroup) {
return;
}
// If we stumble upon a checked radio, remove the others
if (el.checked) {
prunedElements = prunedElements.filter((e)=>!isElementType(e, 'input', {
type: 'radio',
name: el.name
}));
prunedElements.push(el);
checkedRadio[el.name] = el;
return;
}
// If we already found the checked one, skip
if (typeof checkedRadio[el.name] !== 'undefined') {
return;
}
}
prunedElements.push(el);
});
for(let index = prunedElements.findIndex((el)=>el === activeElement);;){
index += shift ? -1 : 1;
// loop at overflow
if (index === prunedElements.length) {
index = 0;
} else if (index === -1) {
index = prunedElements.length - 1;
}
if (prunedElements[index] === activeElement || prunedElements[index] === document.body || isVisible(prunedElements[index])) {
return prunedElements[index];
}
}
}
export { getTabDestination };

View File

@@ -0,0 +1,7 @@
import { FOCUSABLE_SELECTOR } from './selector.js';
function isFocusable(element) {
return element.matches(FOCUSABLE_SELECTOR);
}
export { isFocusable };

View File

@@ -0,0 +1,17 @@
import { isClickableInput } from '../click/isClickableInput.js';
import { isEditableInputOrTextArea } from '../edit/isEditable.js';
/**
* Determine if the element has its own selection implementation
* and does not interact with the Document Selection API.
*/ function hasOwnSelection(node) {
return isElement(node) && isEditableInputOrTextArea(node);
}
function hasNoSelection(node) {
return isElement(node) && isClickableInput(node);
}
function isElement(node) {
return node.nodeType === 1;
}
export { hasNoSelection, hasOwnSelection };

View File

@@ -0,0 +1,12 @@
const FOCUSABLE_SELECTOR = [
'input:not([type=hidden]):not([disabled])',
'button:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[contenteditable=""]',
'[contenteditable="true"]',
'a[href]',
'[tabindex]:not([disabled])'
].join(', ');
export { FOCUSABLE_SELECTOR };

View File

@@ -0,0 +1,29 @@
export { isClickableInput } from './click/isClickableInput.js';
export { readBlobText } from './dataTransfer/Blob.js';
export { createDataTransfer, getBlobFromDataTransferItem } from './dataTransfer/DataTransfer.js';
export { createFileList } from './dataTransfer/FileList.js';
export { attachClipboardStubToView, createClipboardItem, detachClipboardStubFromView, readDataTransferFromClipboard, resetClipboardStubOnView, writeDataTransferToClipboard } from './dataTransfer/Clipboard.js';
export { buildTimeValue, isValidDateOrTimeValue } from './edit/timeValue.js';
export { getContentEditable, isContentEditable } from './edit/isContentEditable.js';
export { isEditable, isEditableInputOrTextArea } from './edit/isEditable.js';
export { getMaxLength, supportsMaxLength } from './edit/maxLength.js';
export { setFiles } from './edit/setFiles.js';
export { getNextCursorPosition } from './focus/cursor.js';
export { getActiveElement, getActiveElementOrBody } from './focus/getActiveElement.js';
export { getTabDestination } from './focus/getTabDestination.js';
export { isFocusable } from './focus/isFocusable.js';
export { hasNoSelection, hasOwnSelection } from './focus/selection.js';
export { FOCUSABLE_SELECTOR } from './focus/selector.js';
export { readNextDescriptor } from './keyDef/readNextDescriptor.js';
export { cloneEvent } from './misc/cloneEvent.js';
export { findClosest } from './misc/findClosest.js';
export { getDocumentFromNode } from './misc/getDocumentFromNode.js';
export { getTreeDiff } from './misc/getTreeDiff.js';
export { getWindow } from './misc/getWindow.js';
export { isDescendantOrSelf } from './misc/isDescendantOrSelf.js';
export { isElementType } from './misc/isElementType.js';
export { isVisible } from './misc/isVisible.js';
export { isDisabled } from './misc/isDisabled.js';
export { ApiLevel, getLevelRef, setLevelRef } from './misc/level.js';
export { wait } from './misc/wait.js';
export { assertPointerEvents, hasPointerEvents } from './pointer/cssPointerEvents.js';

View File

@@ -0,0 +1,90 @@
var bracketDict = /*#__PURE__*/ function(bracketDict) {
bracketDict["{"] = "}";
bracketDict["["] = "]";
return bracketDict;
}(bracketDict || {});
/**
* Read the next key definition from user input
*
* Describe key per `{descriptor}` or `[descriptor]`.
* Everything else will be interpreted as a single character as descriptor - e.g. `a`.
* Brackets `{` and `[` can be escaped by doubling - e.g. `foo[[bar` translates to `foo[bar`.
* A previously pressed key can be released per `{/descriptor}`.
* Keeping the key pressed can be written as `{descriptor>}`.
* When keeping the key pressed you can choose how long the key is pressed `{descriptor>3}`.
* You can then release the key per `{descriptor>3/}` or keep it pressed and continue with the next key.
*/ function readNextDescriptor(text, context) {
let pos = 0;
const startBracket = text[pos] in bracketDict ? text[pos] : '';
pos += startBracket.length;
const isEscapedChar = new RegExp(`^\\${startBracket}{2}`).test(text);
const type = isEscapedChar ? '' : startBracket;
return {
type,
...type === '' ? readPrintableChar(text, pos, context) : readTag(text, pos, type, context)
};
}
function readPrintableChar(text, pos, context) {
const descriptor = text[pos];
assertDescriptor(descriptor, text, pos, context);
pos += descriptor.length;
return {
consumedLength: pos,
descriptor,
releasePrevious: false,
releaseSelf: true,
repeat: 1
};
}
function readTag(text, pos, startBracket, context) {
var _text_slice_match, _text_slice_match1;
const releasePreviousModifier = text[pos] === '/' ? '/' : '';
pos += releasePreviousModifier.length;
const escapedDescriptor = startBracket === '{' && text[pos] === '\\';
pos += Number(escapedDescriptor);
const descriptor = escapedDescriptor ? text[pos] : (_text_slice_match = text.slice(pos).match(startBracket === '{' ? /^\w+|^[^}>/]/ : /^\w+/)) === null || _text_slice_match === undefined ? undefined : _text_slice_match[0];
assertDescriptor(descriptor, text, pos, context);
pos += descriptor.length;
var _text_slice_match_;
const repeatModifier = (_text_slice_match_ = (_text_slice_match1 = text.slice(pos).match(/^>\d+/)) === null || _text_slice_match1 === undefined ? undefined : _text_slice_match1[0]) !== null && _text_slice_match_ !== undefined ? _text_slice_match_ : '';
pos += repeatModifier.length;
const releaseSelfModifier = text[pos] === '/' || !repeatModifier && text[pos] === '>' ? text[pos] : '';
pos += releaseSelfModifier.length;
const expectedEndBracket = bracketDict[startBracket];
const endBracket = text[pos] === expectedEndBracket ? expectedEndBracket : '';
if (!endBracket) {
throw new Error(getErrorMessage([
!repeatModifier && 'repeat modifier',
!releaseSelfModifier && 'release modifier',
`"${expectedEndBracket}"`
].filter(Boolean).join(' or '), text[pos], text, context));
}
pos += endBracket.length;
return {
consumedLength: pos,
descriptor,
releasePrevious: !!releasePreviousModifier,
repeat: repeatModifier ? Math.max(Number(repeatModifier.substr(1)), 1) : 1,
releaseSelf: hasReleaseSelf(releaseSelfModifier, repeatModifier)
};
}
function assertDescriptor(descriptor, text, pos, context) {
if (!descriptor) {
throw new Error(getErrorMessage('key descriptor', text[pos], text, context));
}
}
function hasReleaseSelf(releaseSelfModifier, repeatModifier) {
if (releaseSelfModifier) {
return releaseSelfModifier === '/';
}
if (repeatModifier) {
return false;
}
}
function getErrorMessage(expected, found, text, context) {
return `Expected ${expected} but found "${found !== null && found !== undefined ? found : ''}" in "${text}"
See ${context === 'pointer' ? `https://testing-library.com/docs/user-event/pointer#pressing-a-button-or-touching-the-screen` : `https://testing-library.com/docs/user-event/keyboard`}
for more information about how userEvent parses your input.`;
}
export { readNextDescriptor };

View File

@@ -0,0 +1,5 @@
function cloneEvent(event) {
return new event.constructor(event.type, event);
}
export { cloneEvent };

View File

@@ -0,0 +1,12 @@
function findClosest(element, callback) {
let el = element;
do {
if (callback(el)) {
return el;
}
el = el.parentElement;
}while (el && el !== element.ownerDocument.body)
return undefined;
}
export { findClosest };

View File

@@ -0,0 +1,8 @@
function getDocumentFromNode(el) {
return isDocument(el) ? el : el.ownerDocument;
}
function isDocument(node) {
return node.nodeType === 9;
}
export { getDocumentFromNode };

View File

@@ -0,0 +1,23 @@
function getTreeDiff(a, b) {
const treeA = [];
for(let el = a; el; el = el.parentElement){
treeA.push(el);
}
const treeB = [];
for(let el = b; el; el = el.parentElement){
treeB.push(el);
}
let i = 0;
for(;; i++){
if (i >= treeA.length || i >= treeB.length || treeA[treeA.length - 1 - i] !== treeB[treeB.length - 1 - i]) {
break;
}
}
return [
treeA.slice(0, treeA.length - i),
treeB.slice(0, treeB.length - i),
treeB.slice(treeB.length - i)
];
}
export { getTreeDiff };

View File

@@ -0,0 +1,17 @@
function getWindow(node) {
var _node_ownerDocument;
if (isDocument(node) && node.defaultView) {
return node.defaultView;
} else if ((_node_ownerDocument = node.ownerDocument) === null || _node_ownerDocument === undefined ? undefined : _node_ownerDocument.defaultView) {
return node.ownerDocument.defaultView;
}
throw new Error(`Could not determine window of node. Node was ${describe(node)}`);
}
function isDocument(node) {
return node.nodeType === 9;
}
function describe(val) {
return typeof val === 'function' ? `function ${val.name}` : val === null ? 'null' : String(val);
}
export { getWindow };

View File

@@ -0,0 +1,12 @@
function isDescendantOrSelf(potentialDescendant, potentialAncestor) {
let el = potentialDescendant;
do {
if (el === potentialAncestor) {
return true;
}
el = el.parentElement;
}while (el)
return false;
}
export { isDescendantOrSelf };

View File

@@ -0,0 +1,31 @@
import { isElementType } from './isElementType.js';
// This should probably just rely on the :disabled pseudo-class, but JSDOM doesn't implement it properly.
function isDisabled(element) {
for(let el = element; el; el = el.parentElement){
if (isElementType(el, [
'button',
'input',
'select',
'textarea',
'optgroup',
'option'
])) {
if (el.hasAttribute('disabled')) {
return true;
}
} else if (isElementType(el, 'fieldset')) {
var _el_querySelector;
if (el.hasAttribute('disabled') && !((_el_querySelector = el.querySelector(':scope > legend')) === null || _el_querySelector === undefined ? undefined : _el_querySelector.contains(element))) {
return true;
}
} else if (el.tagName.includes('-')) {
if (el.constructor.formAssociated && el.hasAttribute('disabled')) {
return true;
}
}
}
return false;
}
export { isDisabled };

View File

@@ -0,0 +1,18 @@
function isElementType(element, tag, props) {
if (element.namespaceURI && element.namespaceURI !== 'http://www.w3.org/1999/xhtml') {
return false;
}
tag = Array.isArray(tag) ? tag : [
tag
];
// tagName is uppercase in HTMLDocument and lowercase in XMLDocument
if (!tag.includes(element.tagName.toLowerCase())) {
return false;
}
if (props) {
return Object.entries(props).every(([k, v])=>element[k] === v);
}
return true;
}
export { isElementType };

View File

@@ -0,0 +1,17 @@
import { getWindow } from './getWindow.js';
function isVisible(element) {
const window = getWindow(element);
for(let el = element; el === null || el === undefined ? undefined : el.ownerDocument; el = el.parentElement){
const { display, visibility } = window.getComputedStyle(el);
if (display === 'none') {
return false;
}
if (visibility === 'hidden') {
return false;
}
}
return true;
}
export { isVisible };

View File

@@ -0,0 +1,13 @@
var ApiLevel = /*#__PURE__*/ function(ApiLevel) {
ApiLevel[ApiLevel["Trigger"] = 2] = "Trigger";
ApiLevel[ApiLevel["Call"] = 1] = "Call";
return ApiLevel;
}({});
function setLevelRef(instance, level) {
instance.levelRefs[level] = {};
}
function getLevelRef(instance, level) {
return instance.levelRefs[level];
}
export { ApiLevel, getLevelRef, setLevelRef };

View File

@@ -0,0 +1,12 @@
function wait(config) {
const delay = config.delay;
if (typeof delay !== 'number') {
return;
}
return Promise.all([
new Promise((resolve)=>globalThis.setTimeout(()=>resolve(), delay)),
config.advanceTimers(delay)
]);
}
export { wait };

View File

@@ -0,0 +1,100 @@
import { PointerEventsCheckLevel } from '../../options.js';
import { getWindow } from '../misc/getWindow.js';
import { isElementType } from '../misc/isElementType.js';
import { ApiLevel, getLevelRef } from '../misc/level.js';
function hasPointerEvents(instance, element) {
var _checkPointerEvents;
return ((_checkPointerEvents = checkPointerEvents(instance, element)) === null || _checkPointerEvents === undefined ? undefined : _checkPointerEvents.pointerEvents) !== 'none';
}
function closestPointerEventsDeclaration(element) {
const window = getWindow(element);
for(let el = element, tree = []; el === null || el === undefined ? undefined : el.ownerDocument; el = el.parentElement){
tree.push(el);
const pointerEvents = window.getComputedStyle(el).pointerEvents;
if (pointerEvents && ![
'inherit',
'unset'
].includes(pointerEvents)) {
return {
pointerEvents,
tree
};
}
}
return undefined;
}
const PointerEventsCheck = Symbol('Last check for pointer-events');
function checkPointerEvents(instance, element) {
const lastCheck = element[PointerEventsCheck];
const needsCheck = instance.config.pointerEventsCheck !== PointerEventsCheckLevel.Never && (!lastCheck || hasBitFlag(instance.config.pointerEventsCheck, PointerEventsCheckLevel.EachApiCall) && lastCheck[ApiLevel.Call] !== getLevelRef(instance, ApiLevel.Call) || hasBitFlag(instance.config.pointerEventsCheck, PointerEventsCheckLevel.EachTrigger) && lastCheck[ApiLevel.Trigger] !== getLevelRef(instance, ApiLevel.Trigger));
if (!needsCheck) {
return lastCheck === null || lastCheck === undefined ? undefined : lastCheck.result;
}
const declaration = closestPointerEventsDeclaration(element);
element[PointerEventsCheck] = {
[ApiLevel.Call]: getLevelRef(instance, ApiLevel.Call),
[ApiLevel.Trigger]: getLevelRef(instance, ApiLevel.Trigger),
result: declaration
};
return declaration;
}
function assertPointerEvents(instance, element) {
const declaration = checkPointerEvents(instance, element);
if ((declaration === null || declaration === undefined ? undefined : declaration.pointerEvents) === 'none') {
throw new Error([
`Unable to perform pointer interaction as the element ${declaration.tree.length > 1 ? 'inherits' : 'has'} \`pointer-events: none\`:`,
'',
printTree(declaration.tree)
].join('\n'));
}
}
function printTree(tree) {
return tree.reverse().map((el, i)=>[
''.padEnd(i),
el.tagName,
el.id && `#${el.id}`,
el.hasAttribute('data-testid') && `(testId=${el.getAttribute('data-testid')})`,
getLabelDescr(el),
tree.length > 1 && i === 0 && ' <-- This element declared `pointer-events: none`',
tree.length > 1 && i === tree.length - 1 && ' <-- Asserted pointer events here'
].filter(Boolean).join('')).join('\n');
}
function getLabelDescr(element) {
var _element_labels;
let label;
if (element.hasAttribute('aria-label')) {
label = element.getAttribute('aria-label');
} else if (element.hasAttribute('aria-labelledby')) {
var _element_ownerDocument_getElementById_textContent, _element_ownerDocument_getElementById;
label = (_element_ownerDocument_getElementById = element.ownerDocument.getElementById(element.getAttribute('aria-labelledby'))) === null || _element_ownerDocument_getElementById === undefined ? undefined : (_element_ownerDocument_getElementById_textContent = _element_ownerDocument_getElementById.textContent) === null || _element_ownerDocument_getElementById_textContent === undefined ? undefined : _element_ownerDocument_getElementById_textContent.trim();
} else if (isElementType(element, [
'button',
'input',
'meter',
'output',
'progress',
'select',
'textarea'
]) && ((_element_labels = element.labels) === null || _element_labels === undefined ? undefined : _element_labels.length)) {
label = Array.from(element.labels).map((el)=>{
var _el_textContent;
return (_el_textContent = el.textContent) === null || _el_textContent === undefined ? undefined : _el_textContent.trim();
}).join('|');
} else if (isElementType(element, 'button')) {
var _element_textContent;
label = (_element_textContent = element.textContent) === null || _element_textContent === undefined ? undefined : _element_textContent.trim();
}
label = label === null || label === undefined ? undefined : label.replace(/\n/g, ' ');
if (Number(label === null || label === undefined ? undefined : label.length) > 30) {
label = `${label === null || label === undefined ? undefined : label.substring(0, 29)}`;
}
return label ? `(label=${label})` : '';
}
// With the eslint rule and prettier the bitwise operation isn't nice to read
function hasBitFlag(conf, flag) {
// eslint-disable-next-line no-bitwise
return (conf & flag) > 0;
}
export { assertPointerEvents, hasPointerEvents };