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

Fix DocumentFragment XPath evaluate #1242

Merged
merged 8 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 4 additions & 2 deletions common/element_handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import (
"github.com/grafana/xk6-browser/k6ext"
)

const resultDone = "done"
const resultNeedsInput = "needsinput"
const (
resultDone = "done"
resultNeedsInput = "needsinput"
)
ankur22 marked this conversation as resolved.
Show resolved Hide resolved

type (
elementHandleActionFunc func(context.Context, *ElementHandle) (any, error)
Expand Down
124 changes: 96 additions & 28 deletions common/js/injected_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ class XPathQueryEngine {
selector = "." + selector;
}
const result = [];

// DocumentFragments cannot be queried with XPath and they do not implement
// evaluate. It first needs to be converted to a Document before being able
// to run the evaluate against it.
//
// This avoids the following error:
// - Failed to execute 'evaluate' on 'Document': The node provided is
// '#document-fragment', which is not a valid context node type.
if (root instanceof DocumentFragment) {
root = convertToDocument(root);
}

const document = root instanceof Document ? root : root.ownerDocument;
if (!document) {
return result;
Expand All @@ -143,6 +155,43 @@ class XPathQueryEngine {
}
}

// convertToDocument will convert a DocumentFragment into a Document. It does
// this by creating a new Document and copying the elements from the
// DocumentFragment to the Document.
function convertToDocument(fragment) {
var newDoc = document.implementation.createHTMLDocument("Temporary Document");

copyNodesToDocument(fragment, newDoc.body);

return newDoc;
}

// copyNodesToDocument manually copies nodes to a new document, excluding
// ShadowRoot nodes -- ShadowRoot are not cloneable so we need to manually
// clone them one element at a time.
function copyNodesToDocument(sourceNode, targetNode) {
ankur22 marked this conversation as resolved.
Show resolved Hide resolved
sourceNode.childNodes.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Clone the child node without its descendants
let clonedChild = child.cloneNode(false);
targetNode.appendChild(clonedChild);

// If the child has a shadow root, recursively copy its children
// instead of the shadow root itself.
if (child.shadowRoot) {
copyNodesToDocument(child.shadowRoot, clonedChild);
} else {
// Recursively copy normal child nodes
copyNodesToDocument(child, clonedChild);
}
} else {
// For non-element nodes (like text nodes), clone them directly.
let clonedChild = child.cloneNode(true);
targetNode.appendChild(clonedChild);
}
});
}

class InjectedScript {
constructor() {
this._replaceRafWithTimeout = false;
Expand Down Expand Up @@ -777,26 +826,31 @@ class InjectedScript {
resolve = res;
reject = rej;
});
const observer = new MutationObserver(async () => {
if (timedOut) {
try {
const observer = new MutationObserver(async () => {
if (timedOut) {
observer.disconnect();
reject(`timed out after ${timeout}ms`);
}
const success = predicate();
if (success !== continuePolling) {
observer.disconnect();
resolve(success);
}
});
timeoutPoll = () => {
observer.disconnect();
reject(`timed out after ${timeout}ms`);
}
const success = predicate();
if (success !== continuePolling) {
observer.disconnect();
resolve(success);
}
});
timeoutPoll = () => {
observer.disconnect();
reject(`timed out after ${timeout}ms`);
};
observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
});
};
observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
});
} catch(error) {
reject(error);
return;
}
return result;
}

Expand All @@ -810,13 +864,22 @@ class InjectedScript {
return result;

async function onRaf() {
if (timedOut) {
reject(`timed out after ${timeout}ms`);
try {
if (timedOut) {
reject(`timed out after ${timeout}ms`);
return;
}
const success = predicate();
if (success !== continuePolling) {
resolve(success);
return
} else {
requestAnimationFrame(onRaf);
}
} catch (error) {
reject(error);
return;
}
const success = predicate();
if (success !== continuePolling) resolve(success);
else requestAnimationFrame(onRaf);
}
}

Expand All @@ -830,13 +893,18 @@ class InjectedScript {
return result;

async function onTimeout() {
if (timedOut) {
reject(`timed out after ${timeout}ms`);
try{
if (timedOut) {
reject(`timed out after ${timeout}ms`);
return;
}
const success = predicate();
if (success !== continuePolling) resolve(success);
else setTimeout(onTimeout, pollInterval);
} catch(error) {
reject(error);
return;
}
const success = predicate();
if (success !== continuePolling) resolve(success);
else setTimeout(onTimeout, pollInterval);
}
}
}
Expand Down
69 changes: 69 additions & 0 deletions tests/page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"image/png"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"
"time"
Expand Down Expand Up @@ -1630,3 +1632,70 @@ func TestPageIsHidden(t *testing.T) {
})
}
}

func TestShadowDOMAndDocumentFragment(t *testing.T) {
t.Parallel()

// Start a server that will return static html files.
mux := http.NewServeMux()
s := httptest.NewServer(mux)
t.Cleanup(s.Close)

const (
slash = string(os.PathSeparator)
path = slash + testBrowserStaticDir + slash
)
fs := http.FileServer(http.Dir(testBrowserStaticDir))
mux.Handle(path, http.StripPrefix(path, fs))

tests := []struct {
name string
selector string
want string
}{
{
// This test waits for an element that is in the DocumentFragment.
name: "waitFor_DocumentFragment",
selector: `//p[@id="inDocFrag"]`,
want: "This text is added via a document fragment!",
},
{
// This test waits for an element that is in the DocumentFragment
// that is within an open shadow root.
name: "waitFor_ShadowRoot_DocumentFragment",
selector: `//p[@id="inShadowRootDocFrag"]`,
want: "This is inside Shadow DOM, added via a DocumentFragment!",
},
{
// This test waits for an element that is in the original Document.
name: "waitFor_done",
selector: `//div[@id="done"]`,
want: "All additions to page completed (i'm in the original document)",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

_, rt, _, cleanUp := startIteration(t)
defer cleanUp()

got, err := rt.RunString(fmt.Sprintf(`
const p = browser.newPage()
p.goto("%s/%s/shadow_and_doc_frag.html")

const s = p.locator('%s')
s.waitFor({
timeout: 1000,
state: 'attached',
});

s.innerText();
`, s.URL, testBrowserStaticDir, tt.selector))
assert.NoError(t, err)
assert.Equal(t, tt.want, got.String())
})
}
}
74 changes: 74 additions & 0 deletions tests/static/shadow_and_doc_frag.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocumentFragment and ShadowRoot Test page</title>
</head>
<body>
<h2>DocumentFragment and ShadowRoot Test page</h2>
<div id="docFrag"></div>

<!-- Element that will host the Shadow DOM -->
<div id="shadowHost"></div>

<script>
function addDocFrag() {
const container = document.getElementById('docFrag');
const fragment = document.createDocumentFragment();

// Add some additional text in a paragraph
const paragraph = document.createElement('p');
paragraph.id = 'inDocFrag'; // Set the id of the div
paragraph.textContent = 'This text is added via a document fragment!';
fragment.appendChild(paragraph);

// Append the fragment to the container
container.appendChild(fragment);
}

function addShadowDom() {
const shadowHost = document.getElementById('shadowHost');
// When mode is set to closed, we cannot access internals with JS.
// We will need to create a custom element that exposes these
// internals with getters and setters.
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });

// Create a DocumentFragment to add to the Shadow DOM
const fragment = document.createDocumentFragment();

// Add some styled content to the fragment
const styleElement = document.createElement('style');
styleElement.textContent = `
p {
color: blue;
font-weight: bold;
}
`;
fragment.appendChild(styleElement);

const paragraphElement = document.createElement('p');
paragraphElement.id = 'inShadowRootDocFrag';
paragraphElement.textContent = 'This is inside Shadow DOM, added via a DocumentFragment!';
fragment.appendChild(paragraphElement);

// Append the DocumentFragment to the Shadow DOM.
shadowRoot.appendChild(fragment);
}

function done() {
// Create a new div element which will reside in the original Document.
const doneDiv = document.createElement('div');
doneDiv.id = 'done';
doneDiv.textContent = "All additions to page completed (i'm in the original document)";

// Append it to the original Document.
document.body.appendChild(doneDiv);
}

addDocFrag();
addShadowDom();
done();
</script>
</body>
</html>