Skip to content

Commit 65dcd2d

Browse files
committed
✨ Add new test harness methods
1 parent d907218 commit 65dcd2d

File tree

2 files changed

+259
-1
lines changed

2 files changed

+259
-1
lines changed

lib/testing/browser.js

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,5 +798,253 @@ BrowserHelper.setMethod(async function writeCoverageFiles(output_dir, prefix) {
798798
return coverages.length;
799799
});
800800

801+
/**
802+
* Wait for the Hawkejs scene to be ready.
803+
* This is important when testing pages with custom elements or client-side rendering.
804+
*
805+
* The scene is ready when:
806+
* - The hawkejs object exists
807+
* - The scene has been initialized
808+
* - The general_renderer is available
809+
*
810+
* @author Jelle De Loecker <jelle@elevenways.be>
811+
* @since 1.4.1
812+
* @version 1.4.1
813+
*
814+
* @param {number} timeout Maximum time to wait in ms (default: 5000)
815+
*
816+
* @return {Promise<boolean>}
817+
*/
818+
BrowserHelper.setMethod(async function waitForSceneReady(timeout = 5000) {
819+
820+
if (!this.page) {
821+
throw new Error('Browser not loaded. Call load() or goto() first.');
822+
}
823+
824+
return this.evaluate((timeout) => {
825+
return new Promise((resolve) => {
826+
827+
const isReady = () => {
828+
return typeof hawkejs !== 'undefined' &&
829+
hawkejs.scene &&
830+
hawkejs.scene.general_renderer;
831+
};
832+
833+
if (isReady()) {
834+
return resolve(true);
835+
}
836+
837+
const start = Date.now();
838+
839+
const check = () => {
840+
if (isReady()) {
841+
return resolve(true);
842+
}
843+
844+
if (Date.now() - start > timeout) {
845+
return resolve(false);
846+
}
847+
848+
setTimeout(check, 50);
849+
};
850+
851+
check();
852+
});
853+
}, timeout);
854+
});
855+
856+
/**
857+
* Wait for an element to appear in the DOM using MutationObserver.
858+
* This is the proper way to wait for Alchemy custom elements that render asynchronously.
859+
*
860+
* Unlike Puppeteer's waitForSelector, this:
861+
* - Uses MutationObserver (efficient, no polling)
862+
* - Returns element info, not an ElementHandle
863+
* - Supports multiple selectors with "any" mode
864+
*
865+
* @author Jelle De Loecker <jelle@elevenways.be>
866+
* @since 1.4.1
867+
* @version 1.4.1
868+
*
869+
* @param {string|string[]} selector CSS selector(s) to wait for
870+
* @param {Object} options
871+
* @param {number} options.timeout Max wait time in ms (default: 5000)
872+
* @param {boolean} options.visible Wait for element to be visible (default: false)
873+
*
874+
* @return {Promise<Object|false>} Element info or false if timeout
875+
*/
876+
BrowserHelper.setMethod(async function waitForElement(selector, options = {}) {
877+
878+
const timeout = options.timeout || 5000;
879+
const visible = options.visible || false;
880+
881+
if (!this.page) {
882+
throw new Error('Browser not loaded. Call load() or goto() first.');
883+
}
884+
885+
const selectors = Array.isArray(selector) ? selector : [selector];
886+
887+
return this.evaluate((selectors, timeout, visible) => {
888+
return new Promise((resolve) => {
889+
890+
const isVisible = (el) => {
891+
if (!visible) return true;
892+
const rect = el.getBoundingClientRect();
893+
return rect.width > 0 && rect.height > 0;
894+
};
895+
896+
const findElement = () => {
897+
for (let selector of selectors) {
898+
const el = document.querySelector(selector);
899+
if (el && isVisible(el)) {
900+
return el;
901+
}
902+
}
903+
return null;
904+
};
905+
906+
const createResult = (el) => ({
907+
found: true,
908+
selector: selectors.find(s => document.querySelector(s)),
909+
tagName: el.tagName.toLowerCase(),
910+
text: el.textContent
911+
});
912+
913+
const found = findElement();
914+
if (found) {
915+
return resolve(createResult(found));
916+
}
917+
918+
const observer = new MutationObserver(() => {
919+
const el = findElement();
920+
if (el) {
921+
observer.disconnect();
922+
resolve(createResult(el));
923+
}
924+
});
925+
926+
observer.observe(document.body, {
927+
childList: true,
928+
subtree: true,
929+
attributes: visible
930+
});
931+
932+
setTimeout(() => {
933+
observer.disconnect();
934+
const el = findElement();
935+
resolve(el ? createResult(el) : false);
936+
}, timeout);
937+
});
938+
}, selectors, timeout, visible);
939+
});
940+
941+
/**
942+
* Wait for all pending AJAX/fetch requests to complete.
943+
* This is useful after clicking buttons that trigger API calls.
944+
*
945+
* Uses the browser's performance API to track pending requests.
946+
*
947+
* @author Jelle De Loecker <jelle@elevenways.be>
948+
* @since 1.4.1
949+
* @version 1.4.1
950+
*
951+
* @param {number} timeout Max wait time in ms (default: 5000)
952+
* @param {number} settle_time Time with no requests to consider settled (default: 200)
953+
*
954+
* @return {Promise<boolean>}
955+
*/
956+
BrowserHelper.setMethod(async function waitForAjaxComplete(timeout = 5000, settle_time = 200) {
957+
958+
if (!this.page) {
959+
throw new Error('Browser not loaded. Call load() or goto() first.');
960+
}
961+
962+
return this.evaluate((timeout, settle_time) => {
963+
return new Promise((resolve) => {
964+
const start = Date.now();
965+
let lastActivity = Date.now();
966+
967+
const checkSettled = () => {
968+
const now = Date.now();
969+
970+
const entries = performance.getEntriesByType('resource');
971+
const pending = entries.filter(e => {
972+
return e.startTime > (performance.now() - settle_time) &&
973+
(e.initiatorType === 'fetch' || e.initiatorType === 'xmlhttprequest');
974+
});
975+
976+
if (pending.length > 0) {
977+
lastActivity = now;
978+
}
979+
980+
if (now - lastActivity >= settle_time) {
981+
return resolve(true);
982+
}
983+
984+
if (now - start > timeout) {
985+
return resolve(false);
986+
}
987+
988+
setTimeout(checkSettled, 50);
989+
};
990+
991+
checkSettled();
992+
});
993+
}, timeout, settle_time);
994+
});
995+
996+
/**
997+
* Wait for client-side navigation to complete.
998+
* This handles both full page navigations and Hawkejs SPA-style navigations.
999+
*
1000+
* For Hawkejs navigations, it waits for:
1001+
* - The scene to finish rendering
1002+
* - Any pending AJAX requests to settle
1003+
*
1004+
* @author Jelle De Loecker <jelle@elevenways.be>
1005+
* @since 1.4.1
1006+
* @version 1.4.1
1007+
*
1008+
* @param {number} timeout Max wait time in ms (default: 5000)
1009+
*
1010+
* @return {Promise<boolean>}
1011+
*/
1012+
BrowserHelper.setMethod(async function waitForClientNavigation(timeout = 5000) {
1013+
1014+
if (!this.page) {
1015+
throw new Error('Browser not loaded. Call load() or goto() first.');
1016+
}
1017+
1018+
return this.evaluate((timeout) => {
1019+
return new Promise((resolve) => {
1020+
const start = Date.now();
1021+
1022+
const checkReady = () => {
1023+
const now = Date.now();
1024+
1025+
if (now - start > timeout) {
1026+
return resolve(false);
1027+
}
1028+
1029+
if (typeof hawkejs !== 'undefined' && hawkejs.scene) {
1030+
const renderer = hawkejs.scene.general_renderer;
1031+
1032+
if (renderer && !renderer.rendering && document.readyState === 'complete') {
1033+
setTimeout(() => resolve(true), 100);
1034+
return;
1035+
}
1036+
} else if (document.readyState === 'complete') {
1037+
setTimeout(() => resolve(true), 100);
1038+
return;
1039+
}
1040+
1041+
setTimeout(checkReady, 50);
1042+
};
1043+
1044+
checkReady();
1045+
});
1046+
}, timeout);
1047+
});
1048+
8011049
// Export the BrowserHelper class
8021050
module.exports = BrowserHelper;

lib/testing/harness.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,21 +449,31 @@ TestHarness.setMethod(function getRouteUrl(route_name, params) {
449449
/**
450450
* Make an HTTP request using Blast.fetch
451451
*
452+
* By default on the server, Blast.fetch doesn't undry JSON-dry responses.
453+
* This method sets `allow_json_dry_response: true` to mimic browser behavior,
454+
* since Alchemy sends `application/json-dry` responses by default.
455+
*
452456
* @author Jelle De Loecker <jelle@elevenways.be>
453457
* @since 1.4.1
454458
* @version 1.4.1
455459
*
456460
* @param {string} path_or_url
457461
* @param {Object} options
458462
*
459-
* @return {Promise<{response: Object, body: string}>}
463+
* @return {Promise<{response: Object, body: *}>}
460464
*/
461465
TestHarness.setMethod(function fetch(path_or_url, options) {
462466

463467
if (options == null) {
464468
options = {};
465469
}
466470

471+
// Enable JSON-dry response handling (like browser does)
472+
// This makes the test harness behave like a browser client
473+
if (options.allow_json_dry_response == null) {
474+
options.allow_json_dry_response = true;
475+
}
476+
467477
return new Promise((resolve, reject) => {
468478

469479
let url;

0 commit comments

Comments
 (0)