Skip to content

Commit 15739bc

Browse files
authored
Add conditional action type (#1799)
* Add conditional action type * Finish tests * Fix lint
1 parent 71a25a8 commit 15739bc

File tree

11 files changed

+293
-127
lines changed

11 files changed

+293
-127
lines changed

injected/integration-test/broker-protection-tests/broker-protection.spec.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,4 +664,44 @@ test.describe('Broker Protection communications', () => {
664664
dbp.isErrorMessage(response);
665665
});
666666
});
667+
668+
test.describe('condition', () => {
669+
test('a successful condition returns success with steps in the response', async ({ page }, workerInfo) => {
670+
const dbp = BrokerProtectionPage.create(page, workerInfo.project.use);
671+
await dbp.enabled();
672+
await dbp.navigatesTo('form.html');
673+
await dbp.receivesAction('condition-success.json');
674+
const response = await dbp.collector.waitForMessage('actionCompleted');
675+
dbp.isSuccessMessage(response);
676+
677+
// Check that the response contains an actions array
678+
const successResponse = await dbp.getSuccessResponse();
679+
680+
expect(successResponse).toHaveProperty('actions');
681+
expect(Array.isArray(successResponse.actions)).toBe(true);
682+
expect(successResponse.actions.length).toBeGreaterThan(0);
683+
});
684+
685+
test('a condition with failSilently returns success with empty response', async ({ page }, workerInfo) => {
686+
const dbp = BrokerProtectionPage.create(page, workerInfo.project.use);
687+
await dbp.enabled();
688+
await dbp.navigatesTo('form.html');
689+
await dbp.receivesAction('condition-fail-silently.json');
690+
const response = await dbp.collector.waitForMessage('actionCompleted');
691+
dbp.isSuccessMessage(response);
692+
693+
// Check that the response does not contain an actions array
694+
const successResponse = await dbp.getSuccessResponse();
695+
expect(successResponse).toBeNull();
696+
});
697+
698+
test('a failing condition returns error', async ({ page }, workerInfo) => {
699+
const dbp = BrokerProtectionPage.create(page, workerInfo.project.use);
700+
await dbp.enabled();
701+
await dbp.navigatesTo('form.html');
702+
await dbp.receivesAction('condition-fail.json');
703+
const response = await dbp.collector.waitForMessage('actionCompleted');
704+
dbp.isErrorMessage(response);
705+
});
706+
});
667707
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"state": {
3+
"action": {
4+
"actionType": "condition",
5+
"retry": {
6+
"environment": "web",
7+
"interval": { "ms": 1000 },
8+
"maxAttempts": 1
9+
},
10+
"expectations": [
11+
{
12+
"type": "element",
13+
"selector": "form.fakeForm",
14+
"failSilently": true
15+
}
16+
],
17+
"actions": [
18+
{
19+
"actionType": "click",
20+
"elements": [
21+
{
22+
"type": "button",
23+
"selector": ".btn-sbmt"
24+
}
25+
]
26+
}
27+
]
28+
}
29+
}
30+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"state": {
3+
"action": {
4+
"actionType": "condition",
5+
"retry": {
6+
"environment": "web",
7+
"interval": { "ms": 1000 },
8+
"maxAttempts": 1
9+
},
10+
"expectations": [
11+
{
12+
"type": "element",
13+
"selector": "form.fakeForm"
14+
}
15+
],
16+
"actions": [
17+
{
18+
"actionType": "click",
19+
"elements": [
20+
{
21+
"type": "button",
22+
"selector": ".btn-sbmt"
23+
}
24+
]
25+
}
26+
]
27+
}
28+
}
29+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"state": {
3+
"action": {
4+
"actionType": "condition",
5+
"expectations": [
6+
{
7+
"type": "element",
8+
"selector": "form.ahm"
9+
}
10+
],
11+
"actions": [
12+
{
13+
"actionType": "click",
14+
"elements": [
15+
{
16+
"type": "button",
17+
"selector": ".btn-sbmt"
18+
}
19+
]
20+
}
21+
]
22+
}
23+
}
24+
}

injected/src/features/broker-protection.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ export default class BrokerProtection extends ContentFeature {
100100
};
101101
}
102102
/**
103-
* Special case for when expectation contains a check for an element, retry it
103+
* Special case for when expectation or condition contains a check for an element, retry it
104104
*/
105-
if (!retryConfig && action.actionType === 'expectation') {
105+
if (!retryConfig && (action.actionType === 'expectation' || action.actionType === 'condition')) {
106106
if (action.expectations.some((x) => x.type === 'element')) {
107107
return {
108108
interval: { ms: 1000 },

injected/src/features/broker-protection/actions/actions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { click } from './click.js';
44
export { expectation } from './expectation.js';
55
export { navigate } from './navigate.js';
66
export { getCaptchaInfo, solveCaptcha } from '../captcha-services/captcha.service.js';
7+
export { condition } from './condition.js';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ErrorResponse, SuccessResponse } from '../types.js';
2+
import { expectMany } from '../utils/expectations.js';
3+
4+
/**
5+
* @param {Record<string, any>} action
6+
* @param {Document} root
7+
* @return {import('../types.js').ActionResponse}
8+
*/
9+
export function condition(action, root = document) {
10+
const results = expectMany(action.expectations, root);
11+
12+
// filter out good results + silent failures, leaving only fatal errors
13+
const errors = results
14+
.filter((x, index) => {
15+
if (x.result === true) return false;
16+
if (action.expectations[index].failSilently) return false;
17+
return true;
18+
})
19+
.map((x) => {
20+
return 'error' in x ? x.error : 'unknown error';
21+
});
22+
23+
if (errors.length > 0) {
24+
return new ErrorResponse({ actionID: action.id, message: errors.join(', ') });
25+
}
26+
27+
// only return actions if every expectation was met (these actions will be executed by the native clients)
28+
const returnActions = results.every((x) => x.result === true);
29+
30+
if (action.actions?.length && returnActions) {
31+
return new SuccessResponse({
32+
actionID: action.id,
33+
actionType: action.actionType,
34+
response: { actions: action.actions },
35+
});
36+
}
37+
38+
return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: null });
39+
}
Lines changed: 1 addition & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { getElement } from '../utils/utils.js';
21
import { ErrorResponse, SuccessResponse } from '../types.js';
2+
import { expectMany } from '../utils/expectations.js';
33

44
/**
55
* @param {Record<string, any>} action
@@ -38,125 +38,3 @@ export function expectation(action, root = document) {
3838

3939
return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: null });
4040
}
41-
42-
/**
43-
* Return a true/false result for every expectation
44-
*
45-
* @param {import("../types").Expectation[]} expectations
46-
* @param {Document | HTMLElement} root
47-
* @return {import("../types").BooleanResult[]}
48-
*/
49-
export function expectMany(expectations, root) {
50-
return expectations.map((expectation) => {
51-
switch (expectation.type) {
52-
case 'element':
53-
return elementExpectation(expectation, root);
54-
case 'text':
55-
return textExpectation(expectation, root);
56-
case 'url':
57-
return urlExpectation(expectation);
58-
default: {
59-
return {
60-
result: false,
61-
error: `unknown expectation type: ${expectation.type}`,
62-
};
63-
}
64-
}
65-
});
66-
}
67-
68-
/**
69-
* Verify that an element exists. If the `.parent` property exists,
70-
* scroll it into view first
71-
*
72-
* @param {import("../types").Expectation} expectation
73-
* @param {Document | HTMLElement} root
74-
* @return {import("../types").BooleanResult}
75-
*/
76-
export function elementExpectation(expectation, root) {
77-
if (expectation.parent) {
78-
const parent = getElement(root, expectation.parent);
79-
if (!parent) {
80-
return {
81-
result: false,
82-
error: `parent element not found with selector: ${expectation.parent}`,
83-
};
84-
}
85-
parent.scrollIntoView();
86-
}
87-
88-
const elementExists = getElement(root, expectation.selector) !== null;
89-
90-
if (!elementExists) {
91-
return {
92-
result: false,
93-
error: `element with selector ${expectation.selector} not found.`,
94-
};
95-
}
96-
return { result: true };
97-
}
98-
99-
/**
100-
* Check that an element includes a given text string
101-
*
102-
* @param {import("../types").Expectation} expectation
103-
* @param {Document | HTMLElement} root
104-
* @return {import("../types").BooleanResult}
105-
*/
106-
export function textExpectation(expectation, root) {
107-
// get the target element first
108-
const elem = getElement(root, expectation.selector);
109-
if (!elem) {
110-
return {
111-
result: false,
112-
error: `element with selector ${expectation.selector} not found.`,
113-
};
114-
}
115-
116-
// todo: remove once we have stronger types
117-
if (!expectation.expect) {
118-
return {
119-
result: false,
120-
error: "missing key: 'expect'",
121-
};
122-
}
123-
124-
// todo: is this too strict a match? we may also want to try innerText
125-
const textExists = Boolean(elem?.textContent?.includes(expectation.expect));
126-
127-
if (!textExists) {
128-
return {
129-
result: false,
130-
error: `expected element with selector ${expectation.selector} to have text: ${expectation.expect}, but it didn't`,
131-
};
132-
}
133-
134-
return { result: true };
135-
}
136-
137-
/**
138-
* Check that the current URL includes a given string
139-
*
140-
* @param {import("../types").Expectation} expectation
141-
* @return {import("../types").BooleanResult}
142-
*/
143-
export function urlExpectation(expectation) {
144-
const url = window.location.href;
145-
146-
// todo: remove once we have stronger types
147-
if (!expectation.expect) {
148-
return {
149-
result: false,
150-
error: "missing key: 'expect'",
151-
};
152-
}
153-
154-
if (!url.includes(expectation.expect)) {
155-
return {
156-
result: false,
157-
error: `expected URL to include ${expectation.expect}, but it didn't`,
158-
};
159-
}
160-
161-
return { result: true };
162-
}

injected/src/features/broker-protection/execute.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { navigate, extract, click, expectation, fillForm, getCaptchaInfo, solveCaptcha } from './actions/actions';
1+
import { navigate, extract, click, expectation, fillForm, getCaptchaInfo, solveCaptcha, condition } from './actions/actions';
22
import { ErrorResponse } from './types';
33

44
/**
@@ -24,6 +24,8 @@ export async function execute(action, inputData, root = document) {
2424
return await getCaptchaInfo(action, root);
2525
case 'solveCaptcha':
2626
return solveCaptcha(action, data(action, inputData, 'token'), root);
27+
case 'condition':
28+
return condition(action, root);
2729
default: {
2830
return new ErrorResponse({
2931
actionID: action.id,

injected/src/features/broker-protection/types.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
/**
88
* @typedef {object} PirAction
99
* @property {string} id
10-
* @property {"extract" | "fillForm" | "click" | "expectation" | "getCaptchaInfo" | "solveCaptcha" | "navigate"} actionType
10+
* @property {"extract" | "fillForm" | "click" | "expectation" | "getCaptchaInfo" | "solveCaptcha" | "navigate" | "condition"} actionType
1111
* @property {string} [selector]
1212
* @property {string} [captchaType]
1313
* @property {string} [injectCaptchaHandler]

0 commit comments

Comments
 (0)