Skip to content
This repository has been archived by the owner on Feb 3, 2022. It is now read-only.

Commit

Permalink
Add support for shadow dom
Browse files Browse the repository at this point in the history
This commit will allow basic support for shadow dom (therefore closing #4).
  • Loading branch information
jschfflr committed Jul 10, 2020
1 parent 3a6f4ae commit c95f078
Show file tree
Hide file tree
Showing 5 changed files with 63 additions and 19 deletions.
32 changes: 20 additions & 12 deletions src/injected/dom-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,26 @@ import { cssPath } from './css-path';

export const isSubmitButton = (e: HTMLElement) => e.tagName === 'BUTTON' && (e as HTMLButtonElement).type === 'submit' && (e as HTMLButtonElement).form !== null;

export const getSelector = (e: HTMLElement) => {
const name = getName(e);
const role = getRole(e);
if (name) return `aria/${role}[name="${name}"]`;

const closest = e.closest('a,button');
const closestName = closest && getName(closest);
const closestRole = closest && getRole(closest);
if (closestName && (!e.textContent || closestName.includes(e.textContent))) {
const operator = (!e.textContent || e.textContent === closestName) ? '=' : '*=';
return `aria/${closestRole}[name${operator}"${e.textContent || closestName}"]`;
export const getSelector = (targetNode: HTMLElement) => {
const rootTextContent = targetNode.textContent.trim();
let currentNode = targetNode;
while (currentNode) {
// Prevent aria-api from throwing
if (currentNode.parentElement) {
const name = getName(currentNode);
const role = getRole(currentNode);
if (name && role && (!rootTextContent || name.includes(rootTextContent))) {
const operator = (!rootTextContent || rootTextContent === name) ? '=' : '*=';
return `aria/${role}[name${operator}"${rootTextContent || name}"]`;
}
}
// @ts-ignore
currentNode = currentNode.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE ? currentNode.parentNode.host : currentNode.parentElement;
}

return cssPath(e);
return cssPath(targetNode);
}


export const getSelectorForEvent = (e: Event) =>
getSelector((e.composedPath ? e.composedPath()[0] : e.target) as HTMLElement);
8 changes: 4 additions & 4 deletions src/injected/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { getSelector, isSubmitButton } from './dom-helpers';
import { isSubmitButton, getSelectorForEvent } from './dom-helpers';

declare global {
function addLineToPuppeteerScript(line: string): void;
Expand All @@ -30,18 +30,18 @@ window.addEventListener('click', (e) => {
currentElement = currentElement.parentElement;
}

const selector = getSelector(e.target as HTMLElement);
const selector = getSelectorForEvent(e);
addLineToPuppeteerScript(`await click('${selector}');`);
}, true);

window.addEventListener('change', (e) => {
const value = (e.target as HTMLInputElement).value;
const escapedValue = value.replace(/'/g, '\\\'');
const selector = getSelector(e.target as HTMLElement);
const selector = getSelectorForEvent(e);
addLineToPuppeteerScript(`await type('${selector}', '${escapedValue}');`);
}, true);

window.addEventListener('submit', (e) => {
const selector = getSelector(e.target as HTMLElement);
const selector = getSelectorForEvent(e);
addLineToPuppeteerScript(`await submit('${selector}');`);
}, true);
5 changes: 4 additions & 1 deletion src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export default async (url: string) => {
}

page.exposeFunction('addLineToPuppeteerScript', addLineToPuppeteerScript);
page.evaluateOnNewDocument(readFileSync(path.join(__dirname, '../lib/inject.js'), { encoding: 'utf-8' }));

let script = readFileSync(path.join(__dirname, '../lib/inject.js'), { encoding: 'utf-8' })
script = script.replace(`var childNodes = [];`, `var childNodes = Array.from(node.shadowRoot?.childNodes || []).filter(n => !getOwner(n) && !isHidden(n))`);
page.evaluateOnNewDocument(script);

// Setup puppeteer
addLineToPuppeteerScript(`const {open, click, type, submit} = require('@pptr/recorder');`)
Expand Down
23 changes: 21 additions & 2 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import * as readline from 'readline';

declare const __dirname;

const aria = fs.readFileSync(path.join(__dirname, '../node_modules/aria-api/dist/aria.js'), { encoding: 'utf8' });
let aria = fs.readFileSync(path.join(__dirname, '../node_modules/aria-api/dist/aria.js'), { encoding: 'utf8' });

aria = aria.replace(`var childNodes = [];`, `var childNodes = Array.from(node.shadowRoot?.childNodes || []).filter(n => !getOwner(n) && !isHidden(n))`);

const ariaSelectorEngine = new Function('element', 'selector', `
// Inject the aria library in case it has not been loaded yet
Expand All @@ -34,7 +36,24 @@ const ariaSelectorEngine = new Function('element', 'selector', `
const [, role, attribute, operator, value] = m;
if(attribute !== 'name') throw new Error('Only name is currently supported as an aria attribute.');
const elements = document.getElementsByTagName('*');
const elements = [];
const collect = (root) => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
do {
const currentNode = walker.currentNode;
if (currentNode.shadowRoot) {
collect(currentNode.shadowRoot);
}
// We're only interested in actual elements that we can later use for selector
// matching, so skip shadow roots.
if (!(currentNode instanceof ShadowRoot)) {
elements.push(currentNode);
}
} while (walker.nextNode());
};
collect(document.body);
for(const element of elements) {
if(!element.parentElement) continue;
if(aria.getRole(element) !== role) continue;
Expand Down
14 changes: 14 additions & 0 deletions test/dom-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,19 @@ describe('DOM', () => {
const element = document.getElementById('button') as any;
expect(getSelector(element)).toBe('#button');
});

// This is currently not testable because it relies on hot patching the aria-api module
it.skip('should pierce shadow roots to get an aria name', () => {
const link = document.createElement('a');
document.body.appendChild(link);
const span1 = document.createElement('span');
link.appendChild(span1);
const shadow = span1.attachShadow({mode: 'open'});
const span2 = document.createElement('span');
span2.textContent = 'Hello World';
shadow.appendChild(span2);

expect(getSelector(link)).toBe('aria/link[name="Hello World"]');
});
});
});

0 comments on commit c95f078

Please sign in to comment.