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

MAN-3694 -- Add a waitUntilSettled function that waits for an element to finish updating #554

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/browser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { assert, aTimeout, defineCE, expect, html, nextFrame, oneDefaultPrevente
export { clickAt, clickElem, clickElemAt, dragDropElems, focusElem, hoverAt, hoverElem, hoverElemAt, sendKeys, sendKeysElem, setViewport } from './commands.js';
export { fixture } from './fixture.js';
export { runConstructor } from './constructor.js';
export { waitUntilSettled } from './wait.js';
173 changes: 173 additions & 0 deletions src/browser/wait.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { aTimeout, nextFrame } from '@open-wc/testing';

class DomSnapshot {
constructor(element) {
this._roots = [element.outerHTML];
this._addShadowRoots = this._addShadowRoots.bind(this);
this._addShadowRoots(element);
}

equals(other) {
if (!other || this._roots.length !== other._roots.length) {
return false;
}

for (let i = 0; i < this._roots.length; i++) {
if (this._roots[i] !== other._roots[i]) {
return false;
}
}

return true;
}

_addShadowRoots(element) {
if (element.shadowRoot) {
this._roots.push(element.shadowRoot.innerHTML);
Array.from(element.shadowRoot.children).forEach(this._addShadowRoots);
}

if (element.children) {
Array.from(element.children).forEach(this._addShadowRoots);
}
}
}

function getUpdatePromises(element, updateAwaiters) {
if (element.updateComplete) {
updateAwaiters.push(element.updateComplete);
}

if (element.shadowRoot) {
Array.from(element.shadowRoot.children).forEach(child => getUpdatePromises(child, updateAwaiters));
}

if (element.children) {
Array.from(element.children).forEach(child => getUpdatePromises(child, updateAwaiters));
}
};

/**
* Waits for an element (and its children) to finish updating. While there is no
* foolproof way to ensure that the element will no longer update without user
* interaction, this function performs a number of checks in a loop, and is able
* to handle most causes of element changes to safely wait for it to fully
* finish updating. Additional checks can be added to the loop by providing a
* customAwaiter function in the options object.
*
* @param {Element} element - The element to await
* @param {?Object} [options]
* @param {number} [options.timeout=0] - Timeout in milliseconds (0 for none)
* @param {boolean} [options.failOnTimeout=true] - If true (default), this
* function will throw an error if the timeout is exceeded. Otherwise, it
* will simply return normally after the timeout expires.
* @param {boolean} [options.awaitHypermedia=true} - If true (default), wait for
* all hypermedia requests to finish.
* @param {?function(Element):Promise<boolean>} [options.customAwaiter] - An
* optional additional function to await on. If the function returns a
* Promise that resolves to a truthy value, all checks will be rerun and the
* function will be called again until it returns a falsey value or the
* timeout is reached.
* @returns {Element} The HTML element passed into the first parameter
* @throws {Error} If options.timeout is set and options.failOnTimeout is not
* set to false, then an error will be thrown on a timeout.
*/
export function waitUntilSettled(element, options) {
options = Object.assign(
{
timeout: 0,
failOnTimeout: true,
awaitHypermedia: true,
customAwaiter: null,
},
options,
);

if (options.timeout <= 0) {
return _waitUntilSettledImpl(element, options);
} else if (options.failOnTimeout) {
return Promise.race([
_waitUntilSettledImpl(element, options),
aTimeout(options.timeout).then(() => element)
]);
} else {
return Promise.race([
_waitUntilSettledImpl(element, options),
aTimeout(options.timeout).then(() => {
throw new Error('Timeout waiting for element to finish updating');
})
]);
}
};

async function _waitUntilSettledImpl(element, options) {
const hasSirenActionQueue = options.awaitHypermedia &&
window.D2L &&
D2L.Siren &&
D2L.Siren.ActionQueue &&
D2L.Siren.ActionQueue.isPending &&
D2L.Siren.ActionQueue.enqueue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like too much D2L-specific knowledge leakage into what we're hoping to keep as a bare bones testing library. Have you looked at the getLoadingComplete stuff (or mixin) to handle this kind of thing? Each component that does async calls would need to be aware of it, but that's the way we'd always imagined this stuff working.

If you did want to try and write a single thing that "waits for things to settle", you could still use getLoadingComplete in a shared mixin and put this logic there.

Alternately, you could create your own fixture method that calls into @brightspace-ui/testing's fixture (which calls into the open-wc one) but then additionally waits for more stuff?

Copy link
Author

@mpharoah-d2l mpharoah-d2l Nov 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternately, you could create your own fixture method that calls into @brightspace-ui/testing's fixture (which calls into the open-wc one) but then additionally waits for more stuff?

We actually do that in d2l-rubric, but also add the waitUntilSettled method to the fixture so we can call it again after simulating clicks and such. We were hoping to add this to the testing repo to avoid copy-pasting the same code to our other repos. Is there another library we can put this in if its too D2L specific for this one?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm not sure... there's a lot of Siren-specific stuff in here so maybe as a testing utility in siren-sdk? Or does your team have a shared utils library?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but I guess we can just make one


let lastSnapshot = null;
const startTime = Date.now();

while (options.timeout <= 0 || Date.now() < startTime + options.timeout) {
// Wait for next frame
await nextFrame();

// Wait for next event cycle
await aTimeout(0);

// Wait for events to stop firing
if (window.requestIdleCallback) {
await new Promise(resolve => requestIdleCallback(resolve));
}

// Check for pending/active siren web requests
if (hasSirenActionQueue && D2L.Siren.ActionQueue.isPending()) {
// Wait for current request queue to complete, then repeat all steps
await new Promise(D2L.Siren.ActionQueue.enqueue);
lastSnapshot = null;
continue;
}

const updateAwaiters = [];
getUpdatePromises(element, updateAwaiters);

// Wait for any pending Lit element updates
if (await Promise.all(updateAwaiters).then(x => x.includes(false))) {
// At least one of the updates triggered another update. Repeat all steps.
lastSnapshot = null;
continue;
}

// If a custom awaiter was provided, run it
if (options.customAwaiter && await options.customAwaiter(element)) {
// Custom awaiter returned true. Start over.
lastSnapshot = null;
continue;
}

const snapshot = new DomSnapshot(element);
if (!lastSnapshot) {
// Everything that we can test looks settled.
// Do one more loop and verify that the DOM hasn't changed
lastSnapshot = snapshot;
continue;
}

if (!snapshot.equals(lastSnapshot)) {
// Something changed. Restart the whole process
lastSnapshot = null;
continue;
}

return element;
}

if (options.failOnTimeout) {
throw new Error('Timeout waiting for element to finish updating');
}

return element;
}