mirror of
https://github.com/cockpit-project/cockpit.git
synced 2026-11-03 21:49:50 -09:00
Making test-functions strict required some test adjustments where we incorrectly expected an number while we receive in fact a string. As getAttribute() returns either a string or null.
431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
/* eslint no-unused-vars: 0 */
|
|
|
|
/*
|
|
* These are routines used by our testing code.
|
|
*/
|
|
|
|
/* Detect if we have any shadow DOM */
|
|
window.__haveShadowDom = function() {
|
|
if (window.__haveShadowDomResult === undefined)
|
|
window.__haveShadowDomResult = !!Array.from(document.querySelectorAll('*')).find(el => el.shadowRoot);
|
|
|
|
return window.__haveShadowDomResult;
|
|
};
|
|
|
|
// Like querySelectorAll(), but traverses shadow DOM
|
|
window.querySelectorAllDeep = function(query, element) {
|
|
const result = Array.from(
|
|
element.shadowRoot
|
|
? element.shadowRoot.childNodes
|
|
: element.nodeName === 'SLOT' ? element.assignedElements() : element.childNodes,
|
|
)
|
|
.filter(element => element instanceof Element)
|
|
.map(element => window.querySelectorAllDeep(query, element))
|
|
.flat();
|
|
|
|
if (element.matches?.(query))
|
|
result.push(element);
|
|
return result;
|
|
};
|
|
|
|
window.getQuerySelectorAll = function() {
|
|
if (window.__querySelectorAllFunction === undefined) {
|
|
if (window.__haveShadowDom()) {
|
|
window.__querySelectorAllFunction = function (sel) { return window.querySelectorAllDeep(sel, document) };
|
|
} else {
|
|
window.__querySelectorAllFunction = function (sel) { return Array.from(document.querySelectorAll(sel)) };
|
|
}
|
|
}
|
|
|
|
return window.__querySelectorAllFunction;
|
|
};
|
|
|
|
function elem_contains_text(elems, text) {
|
|
return elems.filter(elem => elem && elem.innerText.includes(text));
|
|
}
|
|
|
|
window.ph_select = function(sel) {
|
|
const querySelectorAll = window.getQuerySelectorAll();
|
|
|
|
if (sel.includes(":contains(")) {
|
|
if (!window.Sizzle) {
|
|
// Best effort support `:contains()`
|
|
// Cockpit supports multiple forms:
|
|
// - :contains(foo)
|
|
// - :contains('foo')
|
|
// - :contains("foo")
|
|
const re = /:contains\(([\sa-zA-Z0-9]+)\)|:contains\('([^']+)'\)|:contains\("([^"]+)"\)/g;
|
|
const matches = re.exec(sel);
|
|
if (matches === null)
|
|
throw new Error("Unsupported ':contains' when window.Sizzle is not available.");
|
|
|
|
if (matches.length !== 4)
|
|
throw new Error(`Match not found for builtin :contains ${sel}`);
|
|
|
|
if (re.exec(sel) !== null)
|
|
throw new Error(`Unsupported multiple ':contains' when window.Sizzle is not available`);
|
|
|
|
const searchText = matches[1] || matches[2] || matches[3];
|
|
const base = sel.replace(re, '');
|
|
|
|
const elems = querySelectorAll(base);
|
|
return elem_contains_text(elems, searchText);
|
|
} else {
|
|
return window.Sizzle(sel);
|
|
}
|
|
} else {
|
|
return querySelectorAll(sel);
|
|
}
|
|
};
|
|
|
|
window.ph_only = function(els, sel) {
|
|
if (els.length === 0)
|
|
throw new Error(sel + " not found");
|
|
if (els.length > 1)
|
|
throw new Error(sel + " is ambiguous");
|
|
return els[0];
|
|
};
|
|
|
|
window.ph_find = function(sel) {
|
|
const els = window.ph_select(sel);
|
|
return window.ph_only(els, sel);
|
|
};
|
|
|
|
window.ph_find_scroll_into_view = function(sel) {
|
|
const el = window.ph_find(sel);
|
|
/* we cannot make this conditional, as there is no way to find out whether
|
|
an element is currently visible -- the usual trick to compare getBoundingClientRect() against
|
|
window.innerHeight does not work if the element is in e.g. a scrollable dialog area */
|
|
return new Promise(resolve => {
|
|
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
|
|
// scrolling needs a little bit of time to stabilize, and it's not predictable
|
|
// in particular, 'scrollend' is not reliably emitted
|
|
window.setTimeout(() => resolve(el), 200);
|
|
});
|
|
};
|
|
|
|
window.ph_count = function(sel) {
|
|
const els = window.ph_select(sel);
|
|
return els.length;
|
|
};
|
|
|
|
window.ph_count_check = function(sel, expected_num) {
|
|
return (window.ph_count(sel) === expected_num);
|
|
};
|
|
|
|
window.ph_val = function(sel) {
|
|
const el = window.ph_find(sel);
|
|
if (el.value === undefined)
|
|
throw new Error(sel + " does not have a value");
|
|
return el.value;
|
|
};
|
|
|
|
window.ph_set_val = function(sel, val) {
|
|
const el = window.ph_find(sel);
|
|
if (el.value === undefined)
|
|
throw new Error(sel + " does not have a value");
|
|
el.value = val;
|
|
const ev = new Event("change", { bubbles: true, cancelable: false });
|
|
el.dispatchEvent(ev);
|
|
};
|
|
|
|
window.ph_has_val = function(sel, val) {
|
|
return window.ph_val(sel) === val;
|
|
};
|
|
|
|
window.ph_collected_text_is = function(sel, val) {
|
|
const els = window.ph_select(sel);
|
|
const rest = els.map(el => {
|
|
if (el.textContent === undefined)
|
|
throw new Error(sel + " can not have text");
|
|
return el.textContent.replaceAll("\xa0", " ");
|
|
}).join("");
|
|
return rest === val;
|
|
};
|
|
|
|
window.ph_text = function(sel) {
|
|
const el = window.ph_find(sel);
|
|
if (el.textContent === undefined)
|
|
throw new Error(sel + " can not have text");
|
|
|
|
// HACK: https://github.com/patternfly/patternfly-react/issues/11678
|
|
const el_copy = el.cloneNode(true);
|
|
// find and clear .pf-v6-screen-reader subelements for HelperText
|
|
el_copy.querySelectorAll('.pf-v6-c-helper-text__item-text > .pf-v6-screen-reader').forEach(el => { el.textContent = '' });
|
|
|
|
// 0xa0 is a non-breakable space, which is a rendering detail of Chromium
|
|
// and awkward to handle in tests; turn it into normal spaces
|
|
return el_copy.textContent.replaceAll("\xa0", " ");
|
|
};
|
|
|
|
window.ph_attr = function(sel, attr) {
|
|
return window.ph_find(sel).getAttribute(attr);
|
|
};
|
|
|
|
window.ph_set_attr = function(sel, attr, val) {
|
|
const el = window.ph_find(sel);
|
|
if (val === null || val === undefined)
|
|
el.removeAttribute(attr);
|
|
else
|
|
el.setAttribute(attr, val);
|
|
|
|
const ev = new Event("change", { bubbles: true, cancelable: false });
|
|
el.dispatchEvent(ev);
|
|
};
|
|
|
|
window.ph_has_attr = function(sel, attr, val) {
|
|
return window.ph_attr(sel, attr) === val;
|
|
};
|
|
|
|
window.ph_attr_contains = function(sel, attr, val) {
|
|
const a = window.ph_attr(sel, attr);
|
|
return a && a.indexOf(val) > -1;
|
|
};
|
|
|
|
window.ph_mouse = function(sel, type, x, y, btn, ctrlKey, shiftKey, altKey, metaKey) {
|
|
const el = window.ph_find(sel);
|
|
|
|
/* The element has to be visible, and not collapsed */
|
|
if (el.offsetWidth <= 0 && el.offsetHeight <= 0 && el.tagName !== 'svg')
|
|
throw new Error(sel + " is not visible");
|
|
|
|
/* The event has to actually work */
|
|
let processed = false;
|
|
function handler() {
|
|
processed = true;
|
|
}
|
|
|
|
el.addEventListener(type, handler, true);
|
|
|
|
let elp = el;
|
|
let left = elp.offsetLeft || 0;
|
|
let top = elp.offsetTop || 0;
|
|
while (elp.offsetParent) {
|
|
elp = elp.offsetParent;
|
|
left += elp.offsetLeft;
|
|
top += elp.offsetTop;
|
|
}
|
|
|
|
let detail = 0;
|
|
if (["click", "mousedown", "mouseup"].indexOf(type) > -1)
|
|
detail = 1;
|
|
else if (type === "dblclick")
|
|
detail = 2;
|
|
|
|
const ev = new MouseEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
view: window,
|
|
detail,
|
|
screenX: left + x,
|
|
screenY: top + y,
|
|
clientX: left + x,
|
|
clientY: top + y,
|
|
button: btn,
|
|
ctrlKey: ctrlKey || false,
|
|
shiftKey: shiftKey || false,
|
|
altKey: altKey || false,
|
|
metaKey: metaKey || false
|
|
});
|
|
|
|
el.dispatchEvent(ev);
|
|
|
|
el.removeEventListener(type, handler, true);
|
|
|
|
/* It really had to work */
|
|
if (!processed)
|
|
throw new Error(sel + " is disabled or somehow doesn't process events");
|
|
};
|
|
|
|
window.ph_get_checked = function(sel) {
|
|
const el = window.ph_find(sel);
|
|
if (el.checked === undefined)
|
|
throw new Error(sel + " is not checkable");
|
|
|
|
return el.checked;
|
|
};
|
|
|
|
window.ph_set_checked = function(sel, val) {
|
|
const el = window.ph_find(sel);
|
|
if (el.checked === undefined)
|
|
throw new Error(sel + " is not checkable");
|
|
|
|
if (el.checked !== val)
|
|
window.ph_mouse(sel, "click", 0, 0, 0);
|
|
};
|
|
|
|
window.ph_is_visible = function(sel) {
|
|
const el = window.ph_find(sel);
|
|
return el.tagName === "svg" || ((el.offsetWidth > 0 || el.offsetHeight > 0) && !(getComputedStyle(el).visibility === "hidden" || getComputedStyle(el).display === "none"));
|
|
};
|
|
|
|
window.ph_is_present = function(sel) {
|
|
const els = window.ph_select(sel);
|
|
return els.length > 0;
|
|
};
|
|
|
|
window.ph_in_text = function(sel, text) {
|
|
return window.ph_text(sel).indexOf(text) !== -1;
|
|
};
|
|
|
|
window.ph_text_is = function(sel, text) {
|
|
return window.ph_text(sel) === text;
|
|
};
|
|
|
|
window.ph_text_matches = function(sel, pattern) {
|
|
return window.ph_text(sel).match(pattern);
|
|
};
|
|
|
|
window.ph_jump = function(path) {
|
|
if (path[0] !== '/')
|
|
throw new Error("ph_jump() needs to be called with a path");
|
|
|
|
if (window.name.indexOf("cockpit1") !== 0)
|
|
throw new Error("ph_jump() called in non cockpit window");
|
|
const control = {
|
|
command: "jump",
|
|
location: path
|
|
};
|
|
window.parent.postMessage("\n" + JSON.stringify(control), "*");
|
|
};
|
|
|
|
window.ph_focus = function(sel) {
|
|
window.ph_find(sel).focus();
|
|
};
|
|
|
|
window.ph_scrollIntoViewIfNeeded = function(sel) {
|
|
window.ph_find(sel).scrollIntoViewIfNeeded();
|
|
};
|
|
|
|
window.ph_blur = function(sel) {
|
|
window.ph_find(sel).blur();
|
|
};
|
|
|
|
window.ph_blur_active = function() {
|
|
const elt = window.document.activeElement;
|
|
if (elt)
|
|
elt.blur();
|
|
};
|
|
|
|
class PhWaitCondTimeout extends Error {
|
|
constructor(description) {
|
|
if (description && description.apply)
|
|
description = description.apply();
|
|
if (description)
|
|
super(description);
|
|
else
|
|
super("condition did not become true");
|
|
}
|
|
}
|
|
|
|
window.ph_wait_cond = function(cond, timeout, error_description) {
|
|
return new Promise((resolve, reject) => {
|
|
// poll every 100 ms for now; FIXME: poll less often and re-check on mutations using
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
|
|
let stepTimer = null;
|
|
let last_err = null;
|
|
const tm = window.setTimeout(() => {
|
|
if (stepTimer)
|
|
window.clearTimeout(stepTimer);
|
|
reject(last_err || new PhWaitCondTimeout(error_description));
|
|
}, timeout);
|
|
function step() {
|
|
try {
|
|
if (cond()) {
|
|
window.clearTimeout(tm);
|
|
resolve();
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
last_err = err;
|
|
}
|
|
stepTimer = window.setTimeout(step, 100);
|
|
}
|
|
step();
|
|
});
|
|
};
|
|
|
|
function currentFrameAbsolutePosition() {
|
|
let currentWindow = window;
|
|
let currentParentWindow;
|
|
const positions = [];
|
|
let rect;
|
|
|
|
while (currentWindow !== window.top) {
|
|
currentParentWindow = currentWindow.parent;
|
|
for (let idx = 0; idx < currentParentWindow.frames.length; idx++)
|
|
if (currentParentWindow.frames[idx] === currentWindow) {
|
|
for (const frameElement of currentParentWindow.document.getElementsByTagName('iframe')) {
|
|
if (frameElement.contentWindow === currentWindow) {
|
|
rect = frameElement.getBoundingClientRect();
|
|
positions.push({ x: rect.x, y: rect.y });
|
|
}
|
|
}
|
|
currentWindow = currentParentWindow;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return positions.reduce((accumulator, currentValue) => {
|
|
return {
|
|
x: accumulator.x + currentValue.x,
|
|
y: accumulator.y + currentValue.y
|
|
};
|
|
}, { x: 0, y: 0 });
|
|
}
|
|
|
|
window.ph_selector_clips = function(sels) {
|
|
const f = currentFrameAbsolutePosition();
|
|
const elts = sels.map(window.ph_select).flat();
|
|
return elts.map(e => {
|
|
const r = e.getBoundingClientRect();
|
|
return { x: r.x + f.x, y: r.y + f.y, width: r.width, height: r.height, scale: 1 };
|
|
});
|
|
};
|
|
|
|
window.ph_element_clip = function(sel) {
|
|
window.ph_find(sel); // just to make sure it is not ambiguous
|
|
return window.ph_selector_clips([sel])[0];
|
|
};
|
|
|
|
window.ph_count_animations = function(sel) {
|
|
let animations = window.ph_find(sel).getAnimations({ subtree: true });
|
|
// ignore animations that have already finished running
|
|
animations = animations.filter(a => a.playState !== "finished");
|
|
|
|
return animations.length;
|
|
};
|
|
|
|
window.ph_set_texts = function(new_texts) {
|
|
for (const sel in new_texts) {
|
|
const elts = window.ph_select(sel);
|
|
if (elts.length === 0)
|
|
throw new Error(sel + " not found");
|
|
for (let elt of elts) {
|
|
// We have to be careful to not replace any actual nodes
|
|
// in the DOM since that would cause React to fail later
|
|
// when it tries to remove some of its nodes that are no
|
|
// longer in the DOM. This means that setting the
|
|
// "textContent" property is out, for example.
|
|
//
|
|
// Instead, we insist on finding an actual "Text" node
|
|
// that we then modify. If the given selector results in
|
|
// elements that have other elements in them, we refuse to
|
|
// mock them.
|
|
//
|
|
// However, for convenience, this function digs into
|
|
// elements that have exactly one other child element.
|
|
while (elt.children.length === 1)
|
|
elt = elt.children[0];
|
|
if (elt.children.length !== 0)
|
|
throw new Error(sel + " can not be mocked since it contains more than text");
|
|
let subst = new_texts[sel];
|
|
for (const n of elt.childNodes) {
|
|
if (n.nodeType === 3) { // 3 == TEXT
|
|
n.data = subst;
|
|
subst = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|