Skip to content

Commit

Permalink
Support Chrome recorder generated selectors (#148)
Browse files Browse the repository at this point in the history
  • Loading branch information
sammacbeth authored Jul 11, 2023
1 parent 49a23b9 commit 98849da
Show file tree
Hide file tree
Showing 18 changed files with 661 additions and 101 deletions.
13 changes: 13 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ jobs:
- run: npm ci

- run: npm run lint

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:lib
65 changes: 64 additions & 1 deletion addon/devtools/loader.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,65 @@
/* global chrome */
chrome.devtools.panels.create('Autoconsent', '/icons/cookie.png', "/devtools/panel.html")
chrome.devtools.panels.create(
"Autoconsent",
"/icons/cookie.png",
"/devtools/panel.html"
);

if (chrome.devtools?.recorder) {
class MyPlugin {
stringify(recording) {
const autoconsentSteps = recording.steps
.filter((step) => ["click"].includes(step.type))
.map(({ selectors }) => this.convertStep(selectors));
return Promise.resolve(JSON.stringify(autoconsentSteps, undefined, 4));
}
stringifyStep(step) {
return Promise.resolve(
JSON.stringify(
this.convertStep(step.selectors),
undefined,
4
)
);
}
convertStep(selectors) {
return {
any: selectors.filter((s) => {
return this.isSupportedSelector(s)
}).map(s => {
if (s.length === 1) {
return {
waitForThenClick: s[0]
}
}
return {
waitForThenClick: s
}
})
}
}

/**
*
* @param {string | string[]} selector
* @returns
*/
isSupportedSelector(selector) {
const unsupportedPrefixes = ['aria/', 'pierce/', 'text/']
if (!Array.isArray(selector)) {
return !unsupportedPrefixes.some(p => selector.startsWith(p))
}
// Chained xpath selectors are not supported
if (selector.length > 1 && selector.some(s => s.startsWith('xpath/'))) {
return false
}
return selector.every(s => this.isSupportedSelector(s))
}
}

chrome.devtools.recorder.registerRecorderExtensionPlugin(
new MyPlugin(),
/*name=*/ "Autoconsent",
/*mediaType=*/ "application/json"
);
}
8 changes: 8 additions & 0 deletions addon/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
cursor: pointer;
border-radius: 3px;
}
#reload {
width: 100%;
height: 2em;
margin: 0.5em auto;
cursor: pointer;
border-radius: 2px;
}
</style>
</head>
<body>
Expand Down Expand Up @@ -75,6 +82,7 @@
<input type="number" id="retries" name="retries" value="20">
<label for="retries">Detection attempts</label>
</div>
<button id="reload">Reload rules</button>
</fieldset>
<script src="popup.bundle.js"></script>
</body>
Expand Down
8 changes: 8 additions & 0 deletions addon/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ async function init() {
const cosmeticOnRadio = document.querySelector('input#cosmetic-on') as HTMLInputElement;
const cosmeticOffRadio = document.querySelector('input#cosmetic-off') as HTMLInputElement;
const retriesInput = document.querySelector('input#retries') as HTMLInputElement;
const ruleReloadButton = document.querySelector('#reload') as HTMLButtonElement;

// enable proceed button when necessary

Expand Down Expand Up @@ -104,6 +105,13 @@ async function init() {
}
cosmeticOnRadio.addEventListener('change', cosmeticChange);
cosmeticOffRadio.addEventListener('change', cosmeticChange);

ruleReloadButton.addEventListener('click', async () => {
const res = await fetch("./rules.json");
storageSet({
rules: await res.json(),
});
})
}

init();
8 changes: 8 additions & 0 deletions lib/cmps/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ async function evaluateRuleStep(rule: AutoConsentRuleStep) {
results.push(_runRulesSequentially(rule.else));
}
}
if (rule.any) {
for (const step of rule.any) {
if (await evaluateRuleStep(step)) {
return true
}
}
return false
}

if (results.length === 0) {
enableLogs && console.warn('Unrecognized rule', rule);
Expand Down
4 changes: 2 additions & 2 deletions lib/cmps/sourcepoint-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ export default class SourcePoint extends AutoConsentCMPBase {
// toggles with 2 buttons
const toggles = document.querySelectorAll('.priv-purpose-container .sp-switch-arrow-block a.neutral.on .right') as NodeListOf<HTMLElement>;
for (const t of toggles) {
click([t]);
t.click()
}
// switch toggles
const switches = document.querySelectorAll('.priv-purpose-container .sp-switch-arrow-block a.switch-bg.on') as NodeListOf<HTMLElement>;
for (const t of switches) {
click([t]);
t.click()
}
return click('.priv-save-btn');
}
Expand Down
76 changes: 59 additions & 17 deletions lib/rule-executors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { enableLogs } from "./config";
import { requestEval } from "./eval-handler";
import { HideMethod, VisibilityCheck } from "./rules";
import { ElementSelector, HideMethod, VisibilityCheck } from "./rules";
import { getStyleElement, hideElements, isElementVisible, waitFor } from "./utils";

export function doEval(expr: string): Promise<boolean> {
Expand All @@ -10,14 +10,9 @@ export function doEval(expr: string): Promise<boolean> {
});
}

export function click(selectorOrElements: string | HTMLElement[], all = false): boolean {
let elem: HTMLElement[] = [];
if (typeof selectorOrElements === 'string') {
elem = Array.from(document.querySelectorAll<HTMLElement>(selectorOrElements));
} else {
elem = selectorOrElements;
}
enableLogs && console.log("[click]", selectorOrElements, all, elem);
export function click(selector: ElementSelector, all = false): boolean {
const elem = elementSelector(selector)
enableLogs && console.log("[click]", selector, all, elem);
if (elem.length > 0) {
if (all) {
elem.forEach((e) => e.click());
Expand All @@ -28,14 +23,14 @@ export function click(selectorOrElements: string | HTMLElement[], all = false):
return elem.length > 0;
}

export function elementExists(selector: string): boolean {
const exists = document.querySelector(selector) !== null;
export function elementExists(selector: ElementSelector): boolean {
const exists = elementSelector(selector).length > 0;
// enableLogs && console.log("[exists?]", selector, exists);
return exists;
}

export function elementVisible(selector: string, check: VisibilityCheck): boolean {
const elem = document.querySelectorAll<HTMLElement>(selector);
export function elementVisible(selector: ElementSelector, check: VisibilityCheck): boolean {
const elem = elementSelector(selector);
const results = new Array(elem.length);
elem.forEach((e, i) => {
// check for display: none
Expand All @@ -53,18 +48,18 @@ export function elementVisible(selector: string, check: VisibilityCheck): boolea
return results.every(r => r);
}

export function waitForElement(selector: string, timeout = 10000): Promise<boolean> {
export function waitForElement(selector: ElementSelector, timeout = 10000): Promise<boolean> {
const interval = 200;
const times = Math.ceil((timeout) / interval);
// enableLogs && console.log("[waitFor]", ruleStep.waitFor);
return waitFor(
() => document.querySelector(selector) !== null,
() => elementSelector(selector).length > 0,
times,
interval
);
}

export function waitForVisible(selector: string, timeout = 10000, check: VisibilityCheck = 'any'): Promise<boolean> {
export function waitForVisible(selector: ElementSelector, timeout = 10000, check: VisibilityCheck = 'any'): Promise<boolean> {
const interval = 200;
const times = Math.ceil((timeout) / interval);
// enableLogs && console.log("[waitForVisible]", ruleStep.waitFor);
Expand All @@ -75,7 +70,7 @@ export function waitForVisible(selector: string, timeout = 10000, check: Visibil
);
}

export async function waitForThenClick(selector: string, timeout = 10000, all = false): Promise<boolean> {
export async function waitForThenClick(selector: ElementSelector, timeout = 10000, all = false): Promise<boolean> {
// enableLogs && console.log("[waitForThenClick]", ruleStep.waitForThenClick);
await waitForElement(selector, timeout);
return click(selector, all);
Expand Down Expand Up @@ -111,3 +106,50 @@ export function undoPrehide(): boolean {
}
return !!existingElement;
}

export function querySingleReplySelector(selector: string, parent: any = document): HTMLElement[] {
if (selector.startsWith('aria/')) {
return []
}
if (selector.startsWith('xpath/')) {
const xpath = selector.slice(6)
const result = document.evaluate(xpath, parent, null, XPathResult.ANY_TYPE, null)
let node: Node = null
const elements: HTMLElement[] = []
// eslint-disable-next-line no-cond-assign
while (node = result.iterateNext()) {
elements.push(node as HTMLElement)
}
return elements
}
if (selector.startsWith('text/')) {
return []
}
if (selector.startsWith('pierce/')) {
return []
}
if (parent.shadowRoot) {
return Array.from(parent.shadowRoot.querySelectorAll(selector))
}
return Array.from(parent.querySelectorAll(selector))
}

export function querySelectorChain(selectors: string[]): HTMLElement[] {
let parent: ParentNode = document
let matches: HTMLElement[]
for (const selector of selectors) {
matches = querySingleReplySelector(selector, parent)
if (matches.length === 0) {
return []
}
parent = matches[0]
}
return matches;
}

export function elementSelector(selector: ElementSelector): HTMLElement[] {
if (typeof selector === 'string') {
return querySingleReplySelector(selector)
}
return querySelectorChain(selector)
}
21 changes: 14 additions & 7 deletions lib/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type RunContext = {
urlPattern?: string,
}

export type ElementSelector = string | string[]

export type AutoConsentRuleStep = { optional?: boolean } & Partial<
ElementExistsRule
> &
Expand All @@ -30,16 +32,17 @@ export type AutoConsentRuleStep = { optional?: boolean } & Partial<
Partial<WaitRule> &
Partial<UrlRule> &
Partial<HideRule> &
Partial<IfRule>;
Partial<IfRule> &
Partial<AnyRule>

export type ElementExistsRule = {
exists: string;
exists: ElementSelector;
};

export type VisibilityCheck = "any" | "all" | "none";

export type ElementVisibleRule = {
visible: string;
visible: ElementSelector;
check?: VisibilityCheck;
};

Expand All @@ -48,23 +51,23 @@ export type EvalRule = {
};

export type WaitForRule = {
waitFor: string;
waitFor: ElementSelector;
timeout?: number;
};

export type WaitForVisibleRule = {
waitForVisible: string;
waitForVisible: ElementSelector;
timeout?: number;
check?: VisibilityCheck;
};

export type ClickRule = {
click: string;
click: ElementSelector;
all?: boolean;
};

export type WaitForThenClickRule = {
waitForThenClick: string;
waitForThenClick: ElementSelector;
timeout?: number;
};

Expand All @@ -88,3 +91,7 @@ export type IfRule = {
then: AutoConsentRuleStep[];
else?: AutoConsentRuleStep[];
};

export type AnyRule = {
any: AutoConsentRuleStep[];
}
Loading

0 comments on commit 98849da

Please sign in to comment.