Files
OverlayScrollbars/packages/overlayscrollbars/tests/playwright/observers/domObserver/index.browser.ts
T
2022-06-16 14:17:34 +02:00

755 lines
25 KiB
TypeScript

import 'styles/overlayscrollbars.scss';
import './index.scss';
import should from 'should';
import { generateSelectCallback, iterateSelect } from '@/testing-browser/Select';
import { timeout } from '@/testing-browser/timeout';
import { setTestResult, waitForOrFailTest } from '@/testing-browser/TestResult';
import {
appendChildren,
createDiv,
removeElements,
children,
isArray,
isNumber,
liesBetween,
addClass,
removeClass,
diffClass,
on,
} from 'support';
import { createDOMObserver } from 'observers/domObserver';
type DOMContentObserverResult = {
contentChange: boolean;
troughEvent: boolean;
};
type DOMTargetObserverResult = {
changedTargetAttrs: string[];
styleChanged: boolean;
};
interface SeparateChangeThrough {
added?: DOMContentObserverResult[];
removed?: DOMContentObserverResult[];
}
const targetChangesCountSlot: HTMLElement | null = document.querySelector('#targetChanges');
const contentChangesCountSlot: HTMLElement | null = document.querySelector('#contentChanges');
const targetElm: HTMLElement | null = document.querySelector('#target');
const trargetContentElm: HTMLElement | null = document.querySelector('#target .content');
const targetElmContentElm: HTMLElement | null = document.querySelector('#content-host');
const contentElmAttrChange: HTMLElement | null = document.querySelector('#target .content-nest');
const contentBetweenElmAttrChange: HTMLElement | null = document.querySelector(
'#content-host .padding-nest-item'
);
const contentHostElmAttrChange: HTMLElement | null =
document.querySelector('#content-nest-item-host');
const targetElmsSlot = document.querySelector('#target .host-nest-item');
const targetContentElmsSlot = document.querySelector('#target .content .content-nest');
const targetContentBetweenElmsSlot = document.querySelector('#content-host');
const imgElmsSlot = document.querySelector('#target .content-nest');
const transitionElmsSlot = document.querySelector('#content-host .content');
const addRemoveTargetElms: HTMLButtonElement | null =
document.querySelector('#addRemoveTargetElms');
const addRemoveTargetContentElms: HTMLButtonElement | null = document.querySelector(
'#addRemoveTargetContentElms'
);
const addRemoveTargetContentBetweenElms: HTMLButtonElement | null = document.querySelector(
'#addRemoveTargetContentBetweenElms'
);
const addRemoveImgElms: HTMLButtonElement | null = document.querySelector('#addRemoveImgElms');
const addRemoveTransitionElms: HTMLButtonElement | null = document.querySelector(
'#addRemoveTransitionElms'
);
const ignoreTargetChange: HTMLButtonElement | null = document.querySelector('#ignoreTargetChange');
const setTargetAttr: HTMLSelectElement | null = document.querySelector('#setTargetAttr');
const setFilteredTargetAttr: HTMLSelectElement | null =
document.querySelector('#setFilteredTargetAttr');
const setContentAttr: HTMLSelectElement | null = document.querySelector('#setContentAttr');
const setFilteredContentAttr: HTMLSelectElement | null =
document.querySelector('#setFilteredContentAttr');
const setContentBetweenAttr: HTMLSelectElement | null =
document.querySelector('#setContentBetweenAttr');
const setFilteredContentBetweenAttr: HTMLSelectElement | null = document.querySelector(
'#setFilteredContentBetweenAttr'
);
const setContentHostElmAttr: HTMLSelectElement | null =
document.querySelector('#setContentHostElmAttr');
const setFilteredContentHostElmAttr: HTMLSelectElement | null = document.querySelector(
'#setFilteredContentHostElmAttr'
);
const summaryContent: HTMLElement | null = document.querySelector('#summary-content');
const summaryBetween: HTMLElement | null = document.querySelector('#summary-between');
const startBtn: HTMLButtonElement | null = document.querySelector('#start');
const hostSelector = '.host';
const ignorePrefix = 'ignore';
const attrs = ['id', 'class', 'style', 'open'];
const contentChange: Array<[string?, string?]> = [['img', 'load']];
const domTargetObserverObservations: DOMTargetObserverResult[] = [];
const domContentObserverObservations: DOMContentObserverResult[] = [];
const targetDomObserver = createDOMObserver(
document.querySelector('#target')!,
false,
(changedTargetAttrs: string[], styleChanged: boolean) => {
should.ok(
Array.isArray(changedTargetAttrs),
'The changedTargetAttrs parameter in a target dom observer must be a array.'
);
should.equal(
typeof styleChanged,
'boolean',
'The styleChanged parameter in a target dom observer must be a boolean.'
);
if (styleChanged && changedTargetAttrs.length === 0) {
should.ok(
false,
'Style changing properties must always be inside the changedTargetAttrs array.'
);
}
domTargetObserverObservations.push({ changedTargetAttrs, styleChanged });
requestAnimationFrame(() => {
if (targetChangesCountSlot) {
targetChangesCountSlot.textContent = `${domTargetObserverObservations.length}`;
}
});
},
{
_styleChangingAttributes: attrs,
_attributes: attrs.concat(['data-target']),
_ignoreTargetChange: (target, attrName, oldValue, newValue) => {
if (attrName === 'class' && oldValue && newValue) {
const diff = diffClass(oldValue, newValue);
const ignore = diff.length === 1 && diff[0].startsWith(ignorePrefix);
return ignore;
}
return false;
},
// @ts-ignore
_ignoreContentChange: () => {
// if param: isContentObserver = false, this function should never be called.
should.ok(false, 'A target dom observer must not call the _ignoreContentChange method.');
return true;
},
// @ts-ignore
_ignoreNestedTargetChange: () => {
// if param: isContentObserver = false, this function should never be called.
should.ok(false, 'A target dom observer must not call the _ignoreNestedTargetChange method.');
return true;
},
}
);
const createContentDomOserver = (
eventContentChange: Array<[string?, string?] | null | undefined>
) =>
createDOMObserver(
trargetContentElm!,
true,
(contentChangedTroughEvent: boolean) => {
should.equal(
typeof contentChangedTroughEvent,
'boolean',
'The contentChanged parameter in a content dom observer must be a boolean.'
);
domContentObserverObservations.push({
contentChange: true,
troughEvent: contentChangedTroughEvent,
});
requestAnimationFrame(() => {
if (contentChangesCountSlot) {
contentChangesCountSlot.textContent = `${domContentObserverObservations.length}`;
}
});
},
{
_styleChangingAttributes: attrs,
_attributes: attrs,
_eventContentChange: eventContentChange,
_nestedTargetSelector: hostSelector,
_ignoreContentChange: (mutation, isNestedTarget) => {
const { target, attributeName } = mutation;
return isNestedTarget
? false
: attributeName
? liesBetween(target as Element, hostSelector, '.content')
: false;
},
_ignoreNestedTargetChange: (_target, attrName, oldValue, newValue) => {
if (attrName === 'class' && oldValue && newValue) {
const diff = diffClass(oldValue, newValue);
const ignore = diff.length === 1 && diff[0].startsWith(ignorePrefix);
return ignore;
}
return false;
},
// @ts-ignore
_ignoreTargetChange: () => {
// if param: isContentObserver = true, this function should never be called.
should.ok(false, 'A content dom observer must not call the _ignoreTargetChange method.');
return true;
},
}
);
let contentDomObserver = createContentDomOserver(contentChange);
const getTotalObservations = () =>
domTargetObserverObservations.length + domContentObserverObservations.length;
const getLast = <T>(arr: T[], indexFromLast = 0): T =>
arr[arr.length - 1 - indexFromLast] || ({} as T);
const changedThrough = <ChangeThrough extends DOMContentObserverResult | DOMTargetObserverResult>(
observationLists?: Array<ChangeThrough[]> | ChangeThrough[]
) => {
interface Stat {
total: number;
lists: Array<[ChangeThrough[], number]>;
}
const noObservationLists = observationLists === undefined;
let before: Stat;
let after: Stat;
if (noObservationLists) {
observationLists = [];
}
if (isArray(observationLists) && !isArray(observationLists[0])) {
observationLists = [observationLists] as Array<ChangeThrough[]>;
}
const getStats = (): Stat => ({
total: getTotalObservations(),
lists: (observationLists as Array<ChangeThrough[]>).map((list) => [list, list.length]),
});
return {
before: () => {
before = getStats();
},
after: () => {
after = getStats();
},
compare: (comparisonTableOrNumber: number | Map<ChangeThrough[], number> = 0) => {
let totalDiff = 0;
if (isNumber(comparisonTableOrNumber) || noObservationLists) {
before.lists.forEach((_, index) => {
const [, beforeCount] = before.lists[index];
const [, afterCount] = after.lists[index];
totalDiff += afterCount - beforeCount;
should.equal(
afterCount,
beforeCount + (noObservationLists ? 0 : (comparisonTableOrNumber as number)),
'Before and after changes for a certain observer are correct. (number)'
);
});
} else {
before.lists.forEach((_, index) => {
const [list, beforeCount] = before.lists[index];
const [, afterCount] = after.lists[index];
totalDiff += afterCount - beforeCount;
should.equal(
afterCount,
beforeCount + (comparisonTableOrNumber.get(list) || 0),
'Before and after changes for a certain observer are correct. (Map)'
);
});
}
should.equal(after.total, before.total + totalDiff, 'Total changes are correct.');
},
};
};
const attrChangeListener = (attrChangeTarget: HTMLElement | null) =>
generateSelectCallback(attrChangeTarget, (target, possibleValues, selectedValue) => {
const isClass = selectedValue === 'class';
target.classList.remove('something');
possibleValues.forEach((val) => val !== 'class' && target.removeAttribute(val));
isClass && target.classList.add('something');
!isClass && target.setAttribute(selectedValue, 'something');
});
const iterateAttrChange = async <
ChangeThrough extends DOMContentObserverResult | DOMTargetObserverResult
>(
select: HTMLSelectElement | null,
changeThrough?: ChangeThrough[],
checkChange?: (observation: ChangeThrough, selected: string) => any
) => {
const { before, after, compare } = changedThrough(changeThrough);
await iterateSelect<unknown>(select, {
beforeEach() {
before();
},
async check(_, selected) {
await waitForOrFailTest(async () => {
after();
if (changeThrough) {
compare(1);
checkChange && checkChange(getLast(changeThrough), selected);
} else {
await timeout(250);
compare(0);
}
});
},
});
};
const addRemoveElementsTest = async (
slot: Element | null,
changeThrough?: DOMContentObserverResult[] | SeparateChangeThrough
) => {
if (slot) {
let addChangeThrough: DOMContentObserverResult[] | undefined = changeThrough as
| DOMContentObserverResult[]
| undefined;
let removeChangeThrough: DOMContentObserverResult[] | undefined = changeThrough as
| DOMContentObserverResult[]
| undefined;
if (changeThrough && !isArray(changeThrough)) {
addChangeThrough = (changeThrough as SeparateChangeThrough).added;
removeChangeThrough = (changeThrough as SeparateChangeThrough).removed;
}
const addElm = async () => {
const { before, after, compare } = changedThrough(addChangeThrough);
before();
appendChildren(slot, createDiv('addedElm'));
await timeout(250);
after();
await waitForOrFailTest(() => {
compare(1);
});
if (addChangeThrough) {
const contentChanged = getLast(addChangeThrough);
await waitForOrFailTest(() => {
should.deepEqual(
contentChanged,
{ contentChange: true, troughEvent: false },
'Adding an content element must result in a content change.'
);
});
}
};
const removeElm = async () => {
const removeItem = children(slot, '.addedElm')[0];
const { before, after, compare } = changedThrough(removeChangeThrough);
if (removeItem) {
before();
removeElements(removeItem);
await timeout(250);
await waitForOrFailTest(() => {
after();
compare(1);
if (removeChangeThrough) {
const contentChanged = getLast(removeChangeThrough);
should.deepEqual(
contentChanged,
{ contentChange: true, troughEvent: false },
'Removing an content element must result in a content change.'
);
}
});
}
};
await addElm();
await addElm();
await addElm();
await removeElm();
await removeElm();
await removeElm();
}
};
const triggerSummaryElemet = async (
summaryElm: HTMLElement | null,
changeThrough?: DOMContentObserverResult[]
) => {
// onyl do if summary is working (IE. exception)
if (summaryElm && (summaryElm.nextElementSibling as HTMLElement)?.offsetHeight === 0) {
const click = async () => {
const { before, after, compare } = changedThrough(changeThrough);
before();
summaryElm?.click();
await timeout(250);
after();
await waitForOrFailTest(() => {
compare(1);
});
};
await click();
await click();
}
};
const addRemoveTargetElmsFn = async () => {
await addRemoveElementsTest(targetElmsSlot);
};
const addRemoveTargetContentElmsFn = async () => {
await addRemoveElementsTest(targetContentElmsSlot, domContentObserverObservations);
};
const addRemoveTargetContentBetweenElmsFn = async () => {
await addRemoveElementsTest(targetContentBetweenElmsSlot, domContentObserverObservations);
};
const addRemoveImgElmsFn = async (changeless = false) => {
const add = async () => {
const img = new Image(1, 1);
img.src = 'www.something.com/something/sometest';
setTimeout(() => {
img.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
}, 250);
const { before, after, compare } = changedThrough(domContentObserverObservations);
const imgHolder = createDiv('img');
appendChildren(imgHolder, img);
before();
appendChildren(imgElmsSlot, imgHolder);
await timeout(250);
await waitForOrFailTest(() => {
after();
compare(changeless ? 0 : 2);
if (!changeless) {
const previousContentChanged = getLast(domContentObserverObservations, 1);
should.deepEqual(
previousContentChanged,
{ contentChange: true, troughEvent: false },
'Adding an content image must result in a content change.'
);
const lastContentChanged = getLast(domContentObserverObservations);
should.deepEqual(
lastContentChanged,
{ contentChange: true, troughEvent: true },
'The images load event must result in a content change.'
);
}
});
};
await add();
await add();
await add();
// test event content change debounce
const addMultiple = async () => {
const { before, after, compare } = changedThrough(domContentObserverObservations);
const genImage = () => {
const img = new Image(1, 1);
img.src = 'www.something.com/something/sometest';
setTimeout(() => {
img.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
}, 250);
const imgHolder = createDiv('img');
appendChildren(imgHolder, img);
return imgHolder;
};
await timeout(250);
before();
appendChildren(imgElmsSlot, [genImage(), genImage(), genImage()]);
await timeout(250);
await waitForOrFailTest(() => {
after();
compare(changeless ? 0 : 2);
if (!changeless) {
const previousContentChanged = getLast(domContentObserverObservations, 1);
should.deepEqual(
previousContentChanged,
{ contentChange: true, troughEvent: false },
'Adding mutliple content images must result in a single content change. (debounced)'
);
const lastContentChanged = getLast(domContentObserverObservations);
should.deepEqual(
lastContentChanged,
{ contentChange: true, troughEvent: true },
'Multiple images load events must result in a single cintent change. (debounced)'
);
}
});
};
await addMultiple();
// remove load event from image test
const addChanged = async (
newEventContentChange: Array<[string?, string?] | null | undefined>
) => {
contentDomObserver._destroy();
contentDomObserver = createContentDomOserver(newEventContentChange);
const img = new Image(1, 1);
img.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
const { before, after, compare } = changedThrough(domContentObserverObservations);
const imgHolder = createDiv('img');
appendChildren(imgHolder, img);
before();
appendChildren(imgElmsSlot, imgHolder);
await timeout(250);
await waitForOrFailTest(() => {
after();
compare(1);
});
contentDomObserver._destroy();
contentDomObserver = createContentDomOserver(contentChange);
};
if (!changeless) {
await addChanged([
['img', 'something'],
['img', 'something2'],
['img', ''],
['img', undefined],
['', ''],
[undefined, undefined],
null,
undefined,
]);
await addChanged([]);
}
removeElements(document.querySelectorAll('.img'));
await timeout(250);
};
const addRemoveTransitionElmsFn = async () => {
const startTransition = async (elm: Element, expectTransitionEndContentChange: boolean) => {
await timeout(50); // time for css to apply class a bit later to trigger transition
const {
before: beforeTransition,
after: afterTransition,
compare: compareTransition,
} = changedThrough(domContentObserverObservations);
beforeTransition();
removeClass(elm, 'resetTransition'); // IE...
addClass(elm, 'active');
await new Promise((resolve) => {
on(
elm,
'transitionend',
async () => {
await waitForOrFailTest(() => {
afterTransition();
compareTransition(expectTransitionEndContentChange ? 2 : 1); // 2 because 1: added class mutation and 2: transition end event
const contentChanged = getLast(domContentObserverObservations);
should.deepEqual(
contentChanged,
{ contentChange: true, troughEvent: expectTransitionEndContentChange },
'The transitionend event must trigger a event content change.'
);
resolve(1);
});
},
{ _once: true }
);
});
removeClass(elm, 'active');
addClass(elm, 'resetTransition'); // IE...
};
const add = async (expectTransitionEndContentChange: boolean) => {
const elm = createDiv(`transition ${expectTransitionEndContentChange ? 'highlight' : ''}`);
const { before, after, compare } = changedThrough(domContentObserverObservations);
before();
appendChildren(transitionElmsSlot, elm);
await waitForOrFailTest(() => {
after();
compare(1);
const contentChanged = getLast(domContentObserverObservations);
should.deepEqual(
contentChanged,
{ contentChange: true, troughEvent: false },
'Adding an content element (transition) must result in a content change.'
);
});
await startTransition(elm, expectTransitionEndContentChange && true);
contentDomObserver._destroy();
contentDomObserver = createContentDomOserver(contentChange);
await startTransition(elm, expectTransitionEndContentChange && false);
removeElements(elm);
await timeout(250);
};
await add(false);
contentDomObserver._destroy();
contentDomObserver = createContentDomOserver(
contentChange.concat([['.transition', 'transitionend']])
);
await add(true);
};
const ignoreTargetChangeFn = async () => {
const check = async <ChangeThrough extends DOMContentObserverResult | DOMTargetObserverResult>(
target: Element | null,
changeThrough: ChangeThrough[]
) => {
const { before, after, compare } = changedThrough(changeThrough);
before();
target?.classList.add(`${ignorePrefix}-something`);
await timeout(250);
target?.classList.remove(`${ignorePrefix}-something`);
await timeout(250);
await waitForOrFailTest(() => {
after();
compare(0);
});
};
await check(targetElm, domTargetObserverObservations);
await check(targetElmContentElm, domContentObserverObservations);
};
const iterateTargetAttrChange = async () => {
await iterateAttrChange(setTargetAttr, domTargetObserverObservations, (observation, selected) => {
const { changedTargetAttrs, styleChanged } = observation;
should.equal(
changedTargetAttrs.includes(selected),
true,
'A attribute change on the target element for a DOMTargetObserver must be inside the changedTargetAttrs array.'
);
should.equal(
styleChanged,
true,
'A style changing attribute on the target element for a DOMTargetObserver must set styleChanged to true.'
);
});
await iterateAttrChange(setFilteredTargetAttr);
};
const iterateContentAttrChange = async () => {
await iterateAttrChange(setContentAttr, domContentObserverObservations, (observation) => {
const contentChanged = observation;
should.deepEqual(
contentChanged,
{ contentChange: true, troughEvent: false },
'A attribute change inside the content must trigger a content change for a DOMContentObserver.'
);
});
await iterateAttrChange(setFilteredContentAttr);
};
const iterateContentBetweenAttrChange = async () => {
await iterateAttrChange(setContentBetweenAttr);
await iterateAttrChange(setFilteredContentBetweenAttr);
};
const iterateContentHostElmAttrChange = async () => {
await iterateAttrChange(setContentHostElmAttr, domContentObserverObservations, (observation) => {
const contentChanged = observation;
should.deepEqual(
contentChanged,
{ contentChange: true, troughEvent: false },
'A attribute change for a nested target must trigger a content change for a DOMContentObserver.'
);
});
await iterateAttrChange(setFilteredContentHostElmAttr);
};
const triggerContentSummaryChange = async () => {
await triggerSummaryElemet(summaryContent, domContentObserverObservations);
};
const triggerBetweenSummaryChange = async () => {
await triggerSummaryElemet(summaryBetween);
};
addRemoveTargetElms?.addEventListener('click', addRemoveTargetElmsFn);
addRemoveTargetContentElms?.addEventListener('click', addRemoveTargetContentElmsFn);
addRemoveTargetContentBetweenElms?.addEventListener('click', addRemoveTargetContentBetweenElmsFn);
addRemoveImgElms?.addEventListener('click', () => addRemoveImgElmsFn());
addRemoveTransitionElms?.addEventListener('click', addRemoveTransitionElmsFn);
ignoreTargetChange?.addEventListener('click', ignoreTargetChangeFn);
setTargetAttr?.addEventListener('change', attrChangeListener(targetElm));
setFilteredTargetAttr?.addEventListener('change', attrChangeListener(targetElm));
setContentAttr?.addEventListener('change', attrChangeListener(contentElmAttrChange));
setFilteredContentAttr?.addEventListener('change', attrChangeListener(contentElmAttrChange));
setContentBetweenAttr?.addEventListener('change', attrChangeListener(contentBetweenElmAttrChange));
setFilteredContentBetweenAttr?.addEventListener(
'change',
attrChangeListener(contentBetweenElmAttrChange)
);
setContentHostElmAttr?.addEventListener('change', attrChangeListener(contentHostElmAttrChange));
setFilteredContentHostElmAttr?.addEventListener(
'change',
attrChangeListener(contentHostElmAttrChange)
);
const start = async () => {
setTestResult(null);
await addRemoveTargetElmsFn();
await addRemoveTargetContentElmsFn();
await addRemoveTargetContentBetweenElmsFn();
await iterateTargetAttrChange();
await iterateContentAttrChange();
await addRemoveTransitionElmsFn();
await iterateContentBetweenAttrChange();
await iterateContentHostElmAttrChange();
await triggerContentSummaryChange();
await triggerBetweenSummaryChange();
await addRemoveImgElmsFn();
targetDomObserver._update();
targetDomObserver._destroy();
targetDomObserver._destroy();
targetDomObserver._update();
contentDomObserver._update();
contentDomObserver._destroy();
contentDomObserver._destroy();
contentDomObserver._update();
await addRemoveImgElmsFn(true); // won't trigger changes after destroy
setTestResult(true);
};
startBtn?.addEventListener('click', start);
export { start };