Skip to content

Commit

Permalink
MDL-72593 behat: Load the Field node content locally for processing
Browse files Browse the repository at this point in the history
The standard NodeElement functions for getAttribute, getTagName,
getParent, and friends go back to WebDriver and parse the DOM for each
request. This is insanely slow per request, and in the case of forms we
do a lot of checking to determine the field type.

This change modifies the form field detection to copy the entire node
content into a DOMDocument and parse the document locally.

This is significantly faster - in some cases where there are large
documents minutes faster.

I believe that this should be a safe change as the document fetched from
the browser is normalised to match the doctype specified.
  • Loading branch information
andrewnicols committed Sep 21, 2021
1 parent c71b2db commit 4258188
Showing 1 changed file with 84 additions and 30 deletions.
114 changes: 84 additions & 30 deletions lib/behat/behat_field_manager.php
Expand Up @@ -54,7 +54,8 @@ public static function get_form_field_from_label($label, RawMinkContext $context
$fieldnode = $context->find_field($label);

// The behat field manager.
return self::get_form_field($fieldnode, $context->getSession());
$field = self::get_form_field($fieldnode, $context->getSession());
return $field;
}

/**
Expand All @@ -74,12 +75,7 @@ public static function get_form_field(NodeElement $fieldnode, Session $session)

// Get the field type if is part of a moodleform.
if (self::is_moodleform_field($fieldnode)) {
// This might go out of scope, finding element beyond the dom and fail. So fallback to guessing type.
try {
$type = self::get_field_node_type($fieldnode, $session);
} catch (WebDriver\Exception\InvalidSelector $e) {
$type = 'field';
}
$type = self::get_field_node_type($fieldnode, $session);
}

// If is not a moodleforms field use the base field type.
Expand All @@ -105,8 +101,7 @@ public static function get_field_instance($type, NodeElement $fieldnode, Session

// If the field is not part of a moodleform, we should still try to find out
// which field type are we dealing with.
if ($type == 'field' &&
$guessedtype = self::guess_field_type($fieldnode, $session)) {
if ($type == 'field' && $guessedtype = self::guess_field_type($fieldnode, $session)) {
$type = $guessedtype;
}

Expand Down Expand Up @@ -135,26 +130,31 @@ public static function get_field_instance($type, NodeElement $fieldnode, Session
* @return string|bool The field type or false.
*/
public static function guess_field_type(NodeElement $fieldnode, Session $session) {
[
'document' => $document,
'node' => $node,
] = self::get_dom_elements_for_node($fieldnode, $session);

// If the type is explicitly set on the element pointed to by the label - use it.
if ($fieldtype = $fieldnode->getAttribute('data-fieldtype')) {
if ($fieldtype = $node->getAttribute('data-fieldtype')) {
return self::normalise_fieldtype($fieldtype);
}

// Textareas are considered text based elements.
$tagname = strtolower($fieldnode->getTagName());
$tagname = strtolower($node->nodeName);
if ($tagname == 'textarea') {
$xpath = new \DOMXPath($document);

// If there is an iframe with $id + _ifr there a TinyMCE editor loaded.
$xpath = '//div[@id="' . $fieldnode->getAttribute('id') . 'editable"]';
if ($session->getPage()->find('xpath', $xpath)) {
if ($xpath->query('//div[@id="' . $node->getAttribute('id') . 'editable"]')->count() !== 0) {
return 'editor';
}
return 'textarea';

} else if ($tagname == 'input') {
$type = $fieldnode->getAttribute('type');
switch ($type) {
}

if ($tagname == 'input') {
switch ($node->getAttribute('type')) {
case 'text':
case 'password':
case 'email':
Expand All @@ -172,11 +172,15 @@ public static function guess_field_type(NodeElement $fieldnode, Session $session
return false;
}

} else if ($tagname == 'select') {
}

if ($tagname == 'select') {
// Select tag.
return 'select';
} else if ($tagname == 'span') {
if ($fieldnode->hasAttribute('data-inplaceeditable') && $fieldnode->getAttribute('data-inplaceeditable')) {
}

if ($tagname == 'span') {
if ($node->hasAttribute('data-inplaceeditable') && $node->getAttribute('data-inplaceeditable')) {
return 'inplaceeditable';
}
}
Expand Down Expand Up @@ -206,6 +210,32 @@ protected static function is_moodleform_field(NodeElement $fieldnode) {
return ($parentformfound != false);
}

/**
* Get the DOMDocument and DOMElement for a NodeElement.
*
* @param NodeElement $fieldnode
* @param Session $session
* @return array
*/
protected static function get_dom_elements_for_node(NodeElement $fieldnode, Session $session): array {
$html = $session->getPage()->getContent();

$document = new \DOMDocument();

$previousinternalerrors = libxml_use_internal_errors(true);
$document->loadHTML($html, LIBXML_HTML_NODEFDTD | LIBXML_BIGLINES);
libxml_clear_errors();
libxml_use_internal_errors($previousinternalerrors);

$xpath = new \DOMXPath($document);
$node = $xpath->query($fieldnode->getXpath())->item(0);

return [
'document' => $document,
'node' => $node,
];
}

/**
* Recursive method to find the field type.
*
Expand All @@ -214,31 +244,54 @@ protected static function is_moodleform_field(NodeElement $fieldnode) {
*
* @param NodeElement $fieldnode The current node.
* @param Session $session The behat browser session
* @return mixed A NodeElement if we continue looking for the element type and String or false when we are done.
* @return null|string A text description of the node type, or null if one could not be accurately determined
*/
protected static function get_field_node_type(NodeElement $fieldnode, Session $session) {
protected static function get_field_node_type(NodeElement $fieldnode, Session $session): ?string {
[
'document' => $document,
'node' => $node,
] = self::get_dom_elements_for_node($fieldnode, $session);

return self::get_field_type($document, $node, $session);
}

// Special handling for availability field which requires custom JavaScript.
if ($fieldnode->getAttribute('name') === 'availabilityconditionsjson') {
/**
* Get the field type from the specified DOMElement.
*
* @param \DOMDocument $document
* @param \DOMElement $node
* @param Session $session
* @return null|string
*/
protected static function get_field_type(\DOMDocument $document, \DOMElement $node, Session $session): ?string {
$xpath = new \DOMXPath($document);

if ($node->getAttribute('name') === 'availabilityconditionsjson') {
// Special handling for availability field which requires custom JavaScript.
return 'availability';
}

if ($fieldnode->getTagName() == 'html') {
return false;
if ($node->nodeName == 'html') {
// The top of the document has been reached.
return null;
}

// If the type is explictly set on the element pointed to by the label - use it.
$fieldtype = $fieldnode->getAttribute('data-fieldtype');
$fieldtype = $node->getAttribute('data-fieldtype');
if ($fieldtype) {
return self::normalise_fieldtype($fieldtype);
}

if (!empty($fieldnode->find('xpath', '/ancestor::*[@data-passwordunmaskid]'))) {
if ($xpath->query('/ancestor::*[@data-passwordunmaskid]', $node)->count() !== 0) {
// This element has a passwordunmaskid as a parent.
return 'passwordunmask';
}

// Fetch the parentnode only once.
$parentnode = $fieldnode->getParent();
$parentnode = $node->parentNode;
if ($parentnode instanceof \DOMDocument) {
return null;
}

// Check the parent fieldtype before we check classes.
$fieldtype = $parentnode->getAttribute('data-fieldtype');
Expand All @@ -255,11 +308,12 @@ protected static function get_field_node_type(NodeElement $fieldnode, Session $s

// Stop propagation through the DOM, if it does not have a felement is not part of a moodle form.
if (strstr($class, 'fcontainer') != false) {
return false;
return null;
}
}

return self::get_field_node_type($parentnode, $session);
// Move up the tree.
return self::get_field_type($document, $parentnode, $session);
}

/**
Expand Down

0 comments on commit 4258188

Please sign in to comment.