Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions driver_patches/frameSelectorsPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,64 @@ export function patchFrameSelectors(project: Project) {
// ------- FrameSelectors Class -------
const frameSelectorsClass = frameSelectorsSourceFile.getClassOrThrow("FrameSelectors");

// -- _hasClosedShadowRoots Method --
frameSelectorsClass.addMethod({ name: "_hasClosedShadowRoots", isAsync: true });
frameSelectorsClass.getMethodOrThrow("_hasClosedShadowRoots").setBodyText(`
const client = this.frame._page.delegate._sessionForFrame(this.frame)._client;
const { root } = await client.send("DOM.getDocument", { depth: -1, pierce: true });
const nodes = [root];
while (nodes.length) {
const node = nodes.pop()!;
if (node.shadowRoots?.some(root => root.shadowRootType === "closed"))
return true;
nodes.push(...node.children || [], ...node.shadowRoots || []);
}
return false;
`);

// -- callOnSelector Method --
const callOnSelectorMethod = frameSelectorsClass.getMethodOrThrow("callOnSelector");
callOnSelectorMethod.setBodyText(`
const resolved = await this._resolveInjectedForSelector(selector, options, options.scope);
if (!resolved)
return null;
let result = await resolved.injected.evaluate(callMatchedElements, {
info: resolved.info,
scope: resolved.scope,
functionText: String(pageFunction),
arg,
callWithoutMatches: !!options.callWithoutMatches,
markTargets: options.markTargets,
}) as R | undefined;
const useCustomSelector = options.markTargets === "all" && !options.callWithoutMatches && !options.mainWorld && !resolved.scope && await resolved.frame.selectors._hasClosedShadowRoots();
if ((result !== undefined && !useCustomSelector) || options.callWithoutMatches)
return { frame: resolved.frame, info: resolved.info, result };
if (resolved.scope)
return null;

const elements = await resolved.frame.querySelectorAll(nullProgress, stringifySelector(resolved.info.parsed));
try {
if (!elements.length)
return null;
const customResult = await elements[0].evaluateInUtility(([injected, node, { info, elements, functionText, arg, markTargets }]) => {
if (markTargets === "all") injected.markTargetElements(new Set(elements));
else if (markTargets === "first") injected.markTargetElements(new Set([elements[0]]));
injected.checkDeprecatedSelectorUsage(info.parsed, elements);
if (info.strict && elements.length > 1)
throw injected.strictModeViolationError(info.parsed, elements);
const callback = injected.eval("(" + functionText + ")");
return callback({ injected, elements, info }, arg);
}, { info: resolved.info, elements, functionText: String(pageFunction), arg, markTargets: options.markTargets });
if (customResult === "error:notconnected")
return null;
result = customResult as R;
return { frame: resolved.frame, info: resolved.info, result };
} finally {
for (const element of elements)
element.dispose();
}
`);

// -- queryArrayInMainWorld Method --
const queryArrayInMainWorldMethod = frameSelectorsClass.getMethodOrThrow("queryArrayInMainWorld");
if (!queryArrayInMainWorldMethod.getParameter("isolatedContext"))
Expand Down
37 changes: 23 additions & 14 deletions driver_patches/framesPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,28 @@ export function patchFrames(project: Project) {
hasQuestionToken: true,
});
evalOnSelectorAllMethod.setBodyText(`
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
isolatedContext = this.selectors._parseSelector(selector, { strict: false }).world !== "main" && isolatedContext;
const arrayHandle = await this.selectors.queryArrayInMainWorld(selector, scope, isolatedContext);
const result = await arrayHandle.internalEvaluateExpression(expression, { isFunction }, arg);
arrayHandle.dispose();
return result;
} catch (e) {
// Retry only on specific context mismatch errors, and only a bounded number of times.
if ("JSHandles can be evaluated only in the context they were created!" !== e.message || attempt === maxAttempts) throw e;
await new Promise(resolve => setTimeout(resolve, 50 * attempt));
}
isolatedContext = this.selectors._parseSelector(selector, { strict: false }).world !== "main" && isolatedContext;
const injectedArrayHandle = await this.selectors.queryArrayInMainWorld(selector, scope, isolatedContext);
const count = await injectedArrayHandle.evaluate(elements => elements.length);
if ((count && !await this.selectors._hasClosedShadowRoots()) || scope) {
const result = await injectedArrayHandle.internalEvaluateExpression(expression, { isFunction }, arg);
injectedArrayHandle.dispose();
return result;
}
injectedArrayHandle.dispose();

const context = isolatedContext ? await this.utilityContext() : await this.mainContext();
const handles = await this.querySelectorAll(progress, selector);
const adoptedHandles = await Promise.all(handles.map(handle =>
handle._context === context ? handle : this._page.delegate.adoptElementHandle(handle, context)
));
const arrayHandle = await context.evaluateHandle(elements => elements, adoptedHandles);
try {
return await arrayHandle.internalEvaluateExpression(expression, { isFunction }, arg);
} finally {
arrayHandle.dispose();
for (const handle of new Set([...handles, ...adoptedHandles]))
handle.dispose();
}
`);

Expand Down Expand Up @@ -929,7 +938,7 @@ export function patchFrames(project: Project) {
}
return null;
};
if (!eventInitContainsHandle(eventInit)) {
if (typeof (taskData as any)?.expression === "string") {
const promise = this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => {
const resolved = await progress.race(this.selectors.callOnSelector(selector, { ...options, scope, markTargets: "first" }, ({ injected, elements }, { callbackText, taskData }) => {
const callback = injected.eval(callbackText) as ElementCallback<T, R>;
Expand Down
46 changes: 30 additions & 16 deletions driver_patches/javascriptPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,21 @@ export function patchJavascript(project: Project) {
type: "boolean",
hasQuestionToken: true,
});
jsHandleEvaluateExpressionMethod.replaceWithText(
jsHandleEvaluateExpressionMethod
.getText()
.replace(
/this\.internalEvaluateExpression\(expression, options, arg\)/g,
"this.internalEvaluateExpression(expression, options, arg)",
),
);
jsHandleEvaluateExpressionMethod.setBodyText(`
const frame = (this as any)._frame;
if (frame && isolatedContext !== undefined) {
const context = isolatedContext ? await frame.utilityContext() : await frame.mainContext();
if (context !== this._context) {
const adopted = await frame._page.delegate.adoptElementHandle(this as any, context);
try {
return await progress.race(adopted.internalEvaluateExpression(expression, options, arg));
} finally {
adopted.dispose();
}
}
}
return await progress.race(this.internalEvaluateExpression(expression, options, arg));
`);

// -- evaluateExpressionHandle Method --
const jsHandleEvaluateExpressionHandleMethod = jsHandleClass.getMethodOrThrow("evaluateExpressionHandle");
Expand All @@ -60,12 +67,19 @@ export function patchJavascript(project: Project) {
type: "boolean",
hasQuestionToken: true,
});
jsHandleEvaluateExpressionHandleMethod.replaceWithText(
jsHandleEvaluateExpressionHandleMethod
.getText()
.replace(
/this\._evaluateExpressionHandle\(expression, options, arg\)/g,
"this._evaluateExpressionHandle(expression, options, arg)",
),
);
jsHandleEvaluateExpressionHandleMethod.setBodyText(`
const frame = (this as any)._frame;
if (frame && isolatedContext !== undefined) {
const context = isolatedContext ? await frame.utilityContext() : await frame.mainContext();
if (context !== this._context) {
const adopted = await frame._page.delegate.adoptElementHandle(this as any, context);
try {
return await progress.race(adopted._evaluateExpressionHandle(expression, options, arg));
} finally {
adopted.dispose();
}
}
}
return await progress.race(this._evaluateExpressionHandle(expression, options, arg));
`);
}
99 changes: 99 additions & 0 deletions utils/custom_tests/closed-shadow-root.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, contextTest as it } from '../../config/browserTest';

it('should support representative locator operations in closed shadow roots @patchright', async ({ page }) => {
await page.setContent('<div id="host"></div>');
await page.evaluate(() => {
const root = document.querySelector('#host')!.attachShadow({ mode: 'closed' });
root.innerHTML = '<button class="item action" data-value="button">Click me</button><input value="input value">';
window['clickCount'] = 0;
root.querySelector('button')!.addEventListener('click', () => ++window['clickCount']);
}, undefined, undefined, false);

const button = page.locator('#host .action');
expect(await button.count()).toBe(1);
await button.waitFor({ state: 'visible' });
await expect(button).toHaveText('Click me');
expect(await button.textContent()).toBe('Click me');
expect(await button.getAttribute('data-value')).toBe('button');
expect(await page.locator('#host input').inputValue()).toBe('input value');
expect(await button.evaluate(element => element.tagName)).toBe('BUTTON');
expect(await page.locator('#host .item').evaluateAll(elements => elements.map(element => element.textContent))).toEqual(['Click me']);
expect(await page.locator('#host .item').allTextContents()).toEqual(['Click me']);

await button.click();
expect(await page.evaluate(() => window['clickCount'], undefined, undefined, false)).toBe(1);
});

it('should preserve order and locator composition after closed shadow DOM changes @patchright', async ({ page }) => {
await page.setContent('<span class="entry">light-1</span><div id="host"></div><span class="entry">light-2</span>');
await page.evaluate(() => {
const root = document.querySelector('#host')!.attachShadow({ mode: 'closed' });
root.innerHTML = '<span class="entry">shadow-1</span><div id="nested"></div>';
const nestedRoot = root.querySelector('#nested')!.attachShadow({ mode: 'closed' });
nestedRoot.innerHTML = '<span class="entry" data-kind="target">nested</span>';
window['shadowRootForTest'] = root;
}, undefined, undefined, false);

const entries = page.locator('.entry');
await expect(entries).toHaveCount(4);
expect(await entries.allTextContents()).toEqual(['light-1', 'shadow-1', 'nested', 'light-2']);
await expect(entries.first()).toHaveText('light-1');
await expect(entries.nth(2)).toHaveText('nested');
await expect(entries.last()).toHaveText('light-2');
await expect(entries.filter({ hasText: 'shadow-1' })).toHaveCount(1);
await expect(entries.and(page.locator('[data-kind="target"]'))).toHaveText('nested');

await page.evaluate(() => {
window['shadowRootForTest'].querySelector('.entry')!.textContent = 'shadow-updated';
}, undefined, undefined, false);
expect(await entries.allTextContents()).toEqual(['light-1', 'shadow-updated', 'nested', 'light-2']);
});

it('should locate nested closed shadow DOM through a cross-origin iframe and XPath @patchright', async ({ page, server }) => {
server.setRoute('/patchright-shadow-frame.html', (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end(`<!doctype html><div id="frame-host"></div><script>
const root = document.querySelector('#frame-host').attachShadow({ mode: 'closed' });
root.innerHTML = '<div id="nested-host"></div>';
const nested = root.querySelector('#nested-host').attachShadow({ mode: 'closed' });
nested.innerHTML = '<button data-testid="shadow-button">Nested button</button>';
nested.querySelector('button').addEventListener('click', () => document.body.dataset.clicked = 'true');
</script>`);
});

await page.goto(server.EMPTY_PAGE);
const frameUrl = server.CROSS_PROCESS_PREFIX + '/patchright-shadow-frame.html';
const frameNavigation = page.waitForEvent('framenavigated', frame => frame.url() === frameUrl);
await page.evaluate(url => {
const root = document.body.attachShadow({ mode: 'closed' });
const iframe = document.createElement('iframe');
iframe.src = url;
root.append(iframe);
}, frameUrl, undefined, false);
await frameNavigation;

await expect(page.locator('iframe')).toHaveCount(1);
const frame = page.frameLocator('iframe');
await expect(frame.locator('body')).toBeVisible();
const cssButton = frame.locator('[data-testid="shadow-button"]');
await expect(cssButton).toHaveText('Nested button');
await expect(frame.locator('xpath=//*[@data-testid="shadow-button"]')).toHaveText('Nested button');
await cssButton.click();
await expect(frame.locator('body')).toHaveAttribute('data-clicked', 'true');
});
64 changes: 64 additions & 0 deletions utils/custom_tests/execution-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, contextTest as it } from '../../config/browserTest';

it('should keep isolated and main execution contexts separate across APIs @patchright', async ({ page, server }) => {
server.setRoute('/patchright-context.html', (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end('<!doctype html><button id="target">target</button><iframe src="/patchright-context-frame.html"></iframe><script>window.mainMarker = "main"</script>');
});
server.setRoute('/patchright-context-frame.html', (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.end('<!doctype html><script>window.frameMarker = "frame-main"</script>');
});

await page.goto(server.PREFIX + '/patchright-context.html');
expect(await page.evaluate(() => window['mainMarker'])).toBeUndefined();
expect(await page.evaluate(() => window['mainMarker'], undefined, undefined, false)).toBe('main');

await page.evaluate(() => window['isolatedMarker'] = 'isolated');
expect(await page.evaluate(() => window['isolatedMarker'], undefined, undefined, false)).toBeUndefined();

const frame = page.frames()[1];
expect(await frame.evaluate(() => window['frameMarker'])).toBeUndefined();
expect(await frame.evaluate(() => window['frameMarker'], undefined, undefined, false)).toBe('frame-main');

const target = page.locator('#target');
expect(await target.evaluate(() => window['mainMarker'])).toBeUndefined();
expect(await target.evaluate(() => window['mainMarker'], undefined, undefined, false)).toBe('main');
expect(await page.locator('button').evaluateAll(() => window['mainMarker'], undefined, false)).toBe('main');

await page.reload();
expect(await page.evaluate(() => window['mainMarker'], undefined, undefined, false)).toBe('main');
expect(await page.evaluate(() => window['mainMarker'])).toBeUndefined();
});

it('should preserve main-world handles and accept context selection for workers @patchright', async ({ page, server }) => {
await page.goto(server.PREFIX + '/drag-n-drop.html');
const dataTransfer = await page.evaluateHandle(() => new DataTransfer(), undefined, undefined, false);
await page.locator('#source').dispatchEvent('dragstart', { dataTransfer });
await page.locator('#target').dispatchEvent('drop', { dataTransfer });
await expect(page.locator('#target > #source')).toHaveCount(1);

const workerPromise = page.waitForEvent('worker');
await page.evaluate(() => {
new Worker(URL.createObjectURL(new Blob(['self.answer = 42'], { type: 'text/javascript' })));
}, undefined, undefined, false);
const worker = await workerPromise;
expect(await worker.evaluate(() => self['answer'])).toBe(42);
expect(await worker.evaluate(() => self['answer'], undefined, false)).toBe(42);
});
Loading
Loading