@midscene/web 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/es/index.js +442 -0
- package/dist/lib/index.js +473 -0
- package/dist/script/htmlElement.js +272 -0
- package/dist/script/types/htmlElement.d.ts +26 -0
- package/dist/types/index.d.ts +66 -0
- package/modern.config.ts +13 -0
- package/modern.inspect.config.ts +20 -0
- package/package.json +85 -0
- package/playwright.config.ts +42 -0
- package/src/html-element/constants.ts +10 -0
- package/src/html-element/debug.ts +3 -0
- package/src/html-element/dom-util.ts +11 -0
- package/src/html-element/extractInfo.ts +168 -0
- package/src/html-element/index.ts +1 -0
- package/src/html-element/util.ts +160 -0
- package/src/img/img.ts +132 -0
- package/src/img/util.ts +28 -0
- package/src/index.ts +2 -0
- package/src/playwright/actions.ts +276 -0
- package/src/playwright/cdp.ts +322 -0
- package/src/playwright/element.ts +74 -0
- package/src/playwright/index.ts +120 -0
- package/src/playwright/utils.ts +88 -0
- package/src/puppeteer/element.ts +49 -0
- package/src/puppeteer/index.ts +6 -0
- package/src/puppeteer/utils.ts +116 -0
- package/tests/e2e/ai-auto-todo.spec.ts +24 -0
- package/tests/e2e/ai-xicha.spec.ts +34 -0
- package/tests/e2e/fixture.ts +6 -0
- package/tests/e2e/generate-test-data.spec.ts +60 -0
- package/tests/e2e/todo-app-midscene.spec.ts +98 -0
- package/tests/e2e/tool.ts +63 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import assert from 'assert';
|
|
2
|
+
import type { Page as PlaywrightPage } from 'playwright';
|
|
3
|
+
import Insight, {
|
|
4
|
+
DumpSubscriber,
|
|
5
|
+
ExecutionDump,
|
|
6
|
+
ExecutionRecorderItem,
|
|
7
|
+
ExecutionTaskActionApply,
|
|
8
|
+
ExecutionTaskApply,
|
|
9
|
+
ExecutionTaskInsightLocateApply,
|
|
10
|
+
ExecutionTaskInsightQueryApply,
|
|
11
|
+
ExecutionTaskPlanningApply,
|
|
12
|
+
Executor,
|
|
13
|
+
InsightDump,
|
|
14
|
+
InsightExtractParam,
|
|
15
|
+
PlanningAction,
|
|
16
|
+
PlanningActionParamHover,
|
|
17
|
+
PlanningActionParamInputOrKeyPress,
|
|
18
|
+
PlanningActionParamScroll,
|
|
19
|
+
PlanningActionParamTap,
|
|
20
|
+
plan,
|
|
21
|
+
} from '@midscene/core';
|
|
22
|
+
import { commonScreenshotParam, getTmpFile, sleep } from '@midscene/core/utils';
|
|
23
|
+
import { base64Encoded } from '@midscene/core/image';
|
|
24
|
+
import { parseContextFromPlaywrightPage } from './utils';
|
|
25
|
+
import { WebElementInfo } from './element';
|
|
26
|
+
|
|
27
|
+
export class PlayWrightActionAgent {
|
|
28
|
+
page: PlaywrightPage;
|
|
29
|
+
|
|
30
|
+
insight: Insight<WebElementInfo>;
|
|
31
|
+
|
|
32
|
+
executor: Executor;
|
|
33
|
+
|
|
34
|
+
actionDump?: ExecutionDump;
|
|
35
|
+
|
|
36
|
+
constructor(page: PlaywrightPage, opt?: { taskName?: string }) {
|
|
37
|
+
this.page = page;
|
|
38
|
+
this.insight = new Insight<WebElementInfo>(async () => {
|
|
39
|
+
return await parseContextFromPlaywrightPage(page);
|
|
40
|
+
});
|
|
41
|
+
this.executor = new Executor(opt?.taskName || 'MidScene - PlayWrightAI');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async recordScreenshot(timing: ExecutionRecorderItem['timing']) {
|
|
45
|
+
const file = getTmpFile('jpeg');
|
|
46
|
+
await this.page.screenshot({
|
|
47
|
+
...commonScreenshotParam,
|
|
48
|
+
path: file,
|
|
49
|
+
});
|
|
50
|
+
const item: ExecutionRecorderItem = {
|
|
51
|
+
type: 'screenshot',
|
|
52
|
+
ts: Date.now(),
|
|
53
|
+
screenshot: base64Encoded(file),
|
|
54
|
+
timing,
|
|
55
|
+
};
|
|
56
|
+
return item;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private wrapExecutorWithScreenshot(taskApply: ExecutionTaskApply): ExecutionTaskApply {
|
|
60
|
+
const taskWithScreenshot: ExecutionTaskApply = {
|
|
61
|
+
...taskApply,
|
|
62
|
+
executor: async (param, context, ...args) => {
|
|
63
|
+
const recorder: ExecutionRecorderItem[] = [];
|
|
64
|
+
const { task } = context;
|
|
65
|
+
// set the recorder before executor in case of error
|
|
66
|
+
task.recorder = recorder;
|
|
67
|
+
const shot = await this.recordScreenshot(`before ${task.type}`);
|
|
68
|
+
recorder.push(shot);
|
|
69
|
+
const result = await taskApply.executor(param, context, ...args);
|
|
70
|
+
if (taskApply.type === 'Action') {
|
|
71
|
+
await sleep(1000);
|
|
72
|
+
const shot2 = await this.recordScreenshot('after Action');
|
|
73
|
+
recorder.push(shot2);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return taskWithScreenshot;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async convertPlanToExecutable(plans: PlanningAction[]) {
|
|
82
|
+
const tasks: ExecutionTaskApply[] = plans
|
|
83
|
+
.map((plan) => {
|
|
84
|
+
if (plan.type === 'Locate') {
|
|
85
|
+
const taskFind: ExecutionTaskInsightLocateApply = {
|
|
86
|
+
type: 'Insight',
|
|
87
|
+
subType: 'Locate',
|
|
88
|
+
param: {
|
|
89
|
+
prompt: plan.thought,
|
|
90
|
+
},
|
|
91
|
+
executor: async (param) => {
|
|
92
|
+
let insightDump: InsightDump | undefined;
|
|
93
|
+
const dumpCollector: DumpSubscriber = (dump) => {
|
|
94
|
+
insightDump = dump;
|
|
95
|
+
};
|
|
96
|
+
this.insight.onceDumpUpdatedFn = dumpCollector;
|
|
97
|
+
const element = await this.insight.locate(param.prompt);
|
|
98
|
+
assert(element, `Element not found: ${param.prompt}`);
|
|
99
|
+
return {
|
|
100
|
+
output: {
|
|
101
|
+
element,
|
|
102
|
+
},
|
|
103
|
+
log: {
|
|
104
|
+
dump: insightDump,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
return taskFind;
|
|
110
|
+
} else if (plan.type === 'Input') {
|
|
111
|
+
const taskActionInput: ExecutionTaskActionApply<PlanningActionParamInputOrKeyPress> = {
|
|
112
|
+
type: 'Action',
|
|
113
|
+
subType: 'Input',
|
|
114
|
+
param: plan.param,
|
|
115
|
+
executor: async (taskParam) => {
|
|
116
|
+
assert(taskParam.value, 'No value to input');
|
|
117
|
+
await this.page.keyboard.type(taskParam.value);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
// TODO: return a recorder Object
|
|
121
|
+
return taskActionInput;
|
|
122
|
+
} else if (plan.type === 'KeyboardPress') {
|
|
123
|
+
const taskActionKeyboardPress: ExecutionTaskActionApply<PlanningActionParamInputOrKeyPress> = {
|
|
124
|
+
type: 'Action',
|
|
125
|
+
subType: 'KeyboardPress',
|
|
126
|
+
param: plan.param,
|
|
127
|
+
executor: async (taskParam) => {
|
|
128
|
+
assert(taskParam.value, 'No key to press');
|
|
129
|
+
await this.page.keyboard.press(taskParam.value);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
return taskActionKeyboardPress;
|
|
133
|
+
} else if (plan.type === 'Tap') {
|
|
134
|
+
const taskActionTap: ExecutionTaskActionApply<PlanningActionParamTap> = {
|
|
135
|
+
type: 'Action',
|
|
136
|
+
subType: 'Tap',
|
|
137
|
+
executor: async (param, { element }) => {
|
|
138
|
+
assert(element, 'Element not found, cannot tap');
|
|
139
|
+
await this.page.mouse.click(element.center[0], element.center[1]);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
return taskActionTap;
|
|
143
|
+
} else if (plan.type === 'Hover') {
|
|
144
|
+
const taskActionHover: ExecutionTaskActionApply<PlanningActionParamHover> = {
|
|
145
|
+
type: 'Action',
|
|
146
|
+
subType: 'Hover',
|
|
147
|
+
executor: async (param, { element }) => {
|
|
148
|
+
// console.log('executor args', param, element);
|
|
149
|
+
assert(element, 'Element not found, cannot hover');
|
|
150
|
+
await this.page.mouse.move(element.center[0], element.center[1]);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
return taskActionHover;
|
|
154
|
+
} else if (plan.type === 'Scroll') {
|
|
155
|
+
const taskActionScroll: ExecutionTaskActionApply<PlanningActionParamScroll> = {
|
|
156
|
+
type: 'Action',
|
|
157
|
+
subType: 'Scroll',
|
|
158
|
+
param: plan.param,
|
|
159
|
+
executor: async (taskParam) => {
|
|
160
|
+
const scrollToEventName = taskParam.scrollType;
|
|
161
|
+
const innerHeight = await this.page.evaluate(() => window.innerHeight);
|
|
162
|
+
|
|
163
|
+
switch (scrollToEventName) {
|
|
164
|
+
case 'ScrollUntilTop':
|
|
165
|
+
await this.page.mouse.wheel(0, -9999999);
|
|
166
|
+
break;
|
|
167
|
+
case 'ScrollUntilBottom':
|
|
168
|
+
await this.page.mouse.wheel(0, 9999999);
|
|
169
|
+
break;
|
|
170
|
+
case 'ScrollUp':
|
|
171
|
+
await this.page.mouse.wheel(0, -innerHeight);
|
|
172
|
+
break;
|
|
173
|
+
case 'ScrollDown':
|
|
174
|
+
await this.page.mouse.wheel(0, innerHeight);
|
|
175
|
+
break;
|
|
176
|
+
default:
|
|
177
|
+
console.error('Unknown scroll event type:', scrollToEventName);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
return taskActionScroll;
|
|
182
|
+
} else if (plan.type === 'Error') {
|
|
183
|
+
throw new Error(`Got a task plan with type Error: ${plan.thought}`);
|
|
184
|
+
} else {
|
|
185
|
+
throw new Error(`Unknown or Unsupported task type: ${plan.type}`);
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
.map((task: ExecutionTaskApply) => {
|
|
189
|
+
return this.wrapExecutorWithScreenshot(task);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return tasks;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async action(userPrompt: string /* , actionInfo?: { actionType?: EventActions[number]['action'] } */) {
|
|
196
|
+
this.executor.description = userPrompt;
|
|
197
|
+
const pageContext = await this.insight.contextRetrieverFn();
|
|
198
|
+
|
|
199
|
+
let plans: PlanningAction[] = [];
|
|
200
|
+
const planningTask: ExecutionTaskPlanningApply = {
|
|
201
|
+
type: 'Planning',
|
|
202
|
+
param: {
|
|
203
|
+
userPrompt,
|
|
204
|
+
},
|
|
205
|
+
async executor(param) {
|
|
206
|
+
const planResult = await plan(pageContext, param.userPrompt);
|
|
207
|
+
assert(planResult.plans.length > 0, 'No plans found');
|
|
208
|
+
// eslint-disable-next-line prefer-destructuring
|
|
209
|
+
plans = planResult.plans;
|
|
210
|
+
return {
|
|
211
|
+
output: planResult,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
// plan
|
|
218
|
+
await this.executor.append(this.wrapExecutorWithScreenshot(planningTask));
|
|
219
|
+
await this.executor.flush();
|
|
220
|
+
this.actionDump = this.executor.dump();
|
|
221
|
+
|
|
222
|
+
// append tasks
|
|
223
|
+
const executables = await this.convertPlanToExecutable(plans);
|
|
224
|
+
await this.executor.append(executables);
|
|
225
|
+
|
|
226
|
+
// flush actions
|
|
227
|
+
await this.executor.flush();
|
|
228
|
+
this.actionDump = this.executor.dump();
|
|
229
|
+
|
|
230
|
+
assert(
|
|
231
|
+
this.executor.status !== 'error',
|
|
232
|
+
`failed to execute tasks: ${this.executor.status}, msg: ${this.executor.errorMsg || ''}`,
|
|
233
|
+
);
|
|
234
|
+
} catch (e: any) {
|
|
235
|
+
// keep the dump before throwing
|
|
236
|
+
this.actionDump = this.executor.dump();
|
|
237
|
+
const err = new Error(e.message, { cause: e });
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async query(demand: InsightExtractParam) {
|
|
243
|
+
this.executor.description = JSON.stringify(demand);
|
|
244
|
+
let data: any;
|
|
245
|
+
const queryTask: ExecutionTaskInsightQueryApply = {
|
|
246
|
+
type: 'Insight',
|
|
247
|
+
subType: 'Query',
|
|
248
|
+
param: {
|
|
249
|
+
dataDemand: demand,
|
|
250
|
+
},
|
|
251
|
+
executor: async (param) => {
|
|
252
|
+
let insightDump: InsightDump | undefined;
|
|
253
|
+
const dumpCollector: DumpSubscriber = (dump) => {
|
|
254
|
+
insightDump = dump;
|
|
255
|
+
};
|
|
256
|
+
this.insight.onceDumpUpdatedFn = dumpCollector;
|
|
257
|
+
data = await this.insight.extract<any>(param.dataDemand);
|
|
258
|
+
return {
|
|
259
|
+
output: data,
|
|
260
|
+
log: { dump: insightDump },
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
try {
|
|
265
|
+
await this.executor.append(this.wrapExecutorWithScreenshot(queryTask));
|
|
266
|
+
await this.executor.flush();
|
|
267
|
+
this.actionDump = this.executor.dump();
|
|
268
|
+
} catch (e: any) {
|
|
269
|
+
// keep the dump before throwing
|
|
270
|
+
this.actionDump = this.executor.dump();
|
|
271
|
+
const err = new Error(e.message, { cause: e });
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// fork from https://github.com/zerostep-ai/zerostep/blob/5ba8ca5282879f444e5dfefbcc1f03c76591469e/packages/playwright/src/cdp.ts
|
|
2
|
+
import type { Page } from 'playwright';
|
|
3
|
+
|
|
4
|
+
const cdpSessionByPage = new Map<Page, CDPSession>();
|
|
5
|
+
|
|
6
|
+
export const WEBDRIVER_ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
|
|
7
|
+
|
|
8
|
+
export type ScrollType = 'up' | 'down' | 'bottom' | 'top';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Closes the cdp session and clears the global shared reference. This
|
|
12
|
+
* happens automatically when a page closes in a playwright test, so
|
|
13
|
+
* should generally not be necessary.
|
|
14
|
+
*/
|
|
15
|
+
export const detachCPDSession = async (page: Page) => {
|
|
16
|
+
if (cdpSessionByPage.has(page)) {
|
|
17
|
+
await cdpSessionByPage.get(page)!.detach();
|
|
18
|
+
cdpSessionByPage.delete(page);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns a stable reference to a CDP session.
|
|
24
|
+
*/
|
|
25
|
+
export const getCDPSession = async (page: Page): Promise<CDPSession> => {
|
|
26
|
+
if (!cdpSessionByPage.has(page)) {
|
|
27
|
+
try {
|
|
28
|
+
const session = await page.context().newCDPSession(page);
|
|
29
|
+
cdpSessionByPage.set(page, session);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw Error('The ai() function can only be run against Chromium browsers.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return cdpSessionByPage.get(page)!;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const getScreenshot = async (page: Page) => {
|
|
39
|
+
const cdpSession = await getCDPSession(page);
|
|
40
|
+
const screenshot = await cdpSession.send('Page.captureScreenshot');
|
|
41
|
+
return screenshot.data; // Base64-encoded image data
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const scrollIntoView = async (page: Page, args: { id: string }) => {
|
|
45
|
+
const cdpSession = await getCDPSession(page);
|
|
46
|
+
|
|
47
|
+
await cdpSession.send('DOM.scrollIntoViewIfNeeded', {
|
|
48
|
+
backendNodeId: parseInt(args.id, 10),
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const getTitle = async (page: Page) => {
|
|
53
|
+
const cdpSession = await getCDPSession(page);
|
|
54
|
+
const returnedValue = await cdpSession.send('Runtime.evaluate', {
|
|
55
|
+
expression: 'document.title',
|
|
56
|
+
returnByValue: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return returnedValue.result.value;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const get = async (page: Page, args: { url: string }) => {
|
|
63
|
+
const cdpSession = await getCDPSession(page);
|
|
64
|
+
await cdpSession.send('Page.navigate', {
|
|
65
|
+
url: args.url,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const scrollElement = async (page: Page, args: { id: string; target: ScrollType }) => {
|
|
70
|
+
await runFunctionOn(page, {
|
|
71
|
+
functionDeclaration: `function() {
|
|
72
|
+
let element = this
|
|
73
|
+
let elementHeight = 0
|
|
74
|
+
|
|
75
|
+
switch (element.tagName) {
|
|
76
|
+
case 'BODY':
|
|
77
|
+
case 'HTML':
|
|
78
|
+
element = document.scrollingElement || document.body
|
|
79
|
+
elementHeight = window.visualViewport?.height ?? 720
|
|
80
|
+
break
|
|
81
|
+
default:
|
|
82
|
+
elementHeight = element.clientHeight ?? 720
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const relativeScrollDistance = 0.75 * elementHeight
|
|
87
|
+
|
|
88
|
+
switch ("${args.target}") {
|
|
89
|
+
case 'top':
|
|
90
|
+
return element.scrollTo({ top: 0 })
|
|
91
|
+
case 'bottom':
|
|
92
|
+
return element.scrollTo({ top: element.scrollHeight })
|
|
93
|
+
case 'up':
|
|
94
|
+
return element.scrollBy({ top: -relativeScrollDistance })
|
|
95
|
+
case 'down':
|
|
96
|
+
return element.scrollBy({ top: relativeScrollDistance })
|
|
97
|
+
default:
|
|
98
|
+
throw Error('Unsupported scroll target ${args.target}')
|
|
99
|
+
}
|
|
100
|
+
}`,
|
|
101
|
+
backendNodeId: parseInt(args.id, 10),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const runFunctionOn = async (
|
|
106
|
+
page: Page,
|
|
107
|
+
args: { functionDeclaration: string; backendNodeId: number },
|
|
108
|
+
) => {
|
|
109
|
+
const cdpSession = await getCDPSession(page);
|
|
110
|
+
const {
|
|
111
|
+
object: { objectId },
|
|
112
|
+
} = await cdpSession.send('DOM.resolveNode', { backendNodeId: args.backendNodeId });
|
|
113
|
+
await cdpSession.send('Runtime.callFunctionOn', {
|
|
114
|
+
functionDeclaration: args.functionDeclaration,
|
|
115
|
+
objectId,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const clearElement = async (page: Page, args: { id: string }) => {
|
|
120
|
+
return await runFunctionOn(page, {
|
|
121
|
+
functionDeclaration: `function() {this.value=''}`,
|
|
122
|
+
backendNodeId: parseInt(args.id, 10),
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const sendKeysToElement = async (page: Page, args: { id: string; value: string[] }) => {
|
|
127
|
+
const cdpSession = await getCDPSession(page);
|
|
128
|
+
const value = args.value[0];
|
|
129
|
+
|
|
130
|
+
const { nodeId } = await cdpSession.send('DOM.requestNode', { objectId: args.id });
|
|
131
|
+
await cdpSession.send('DOM.focus', { nodeId });
|
|
132
|
+
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
134
|
+
for (let i = 0; i < value.length; i++) {
|
|
135
|
+
await cdpSession.send('Input.dispatchKeyEvent', {
|
|
136
|
+
type: 'char',
|
|
137
|
+
text: value[i],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const getElementAttribute = async (page: Page, args: { id: string; name: string }) => {
|
|
145
|
+
const cdpSession = await getCDPSession(page);
|
|
146
|
+
|
|
147
|
+
const { nodeId } = await cdpSession.send('DOM.requestNode', { objectId: args.id });
|
|
148
|
+
const { attributes } = await cdpSession.send('DOM.getAttributes', { nodeId });
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < attributes.length; i++) {
|
|
151
|
+
if (attributes[i] === args.name) {
|
|
152
|
+
return attributes[i + 1];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return attributes;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const getElementTagName = async (page: Page, args: { id: string }) => {
|
|
159
|
+
const cdpSession = await getCDPSession(page);
|
|
160
|
+
const returnedValue = await cdpSession.send('Runtime.callFunctionOn', {
|
|
161
|
+
functionDeclaration: `function() {return this.tagName}`,
|
|
162
|
+
objectId: args.id,
|
|
163
|
+
returnByValue: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return returnedValue.result.value;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const clickElement = async (page: Page, args: { id: string }) => {
|
|
170
|
+
const cdpSession = await getCDPSession(page);
|
|
171
|
+
const { centerX, centerY } = await getContentQuads(page, { backendNodeId: parseInt(args.id, 10) });
|
|
172
|
+
|
|
173
|
+
await cdpSession.send('Input.dispatchMouseEvent', {
|
|
174
|
+
type: 'mousePressed',
|
|
175
|
+
x: centerX,
|
|
176
|
+
y: centerY,
|
|
177
|
+
button: 'left',
|
|
178
|
+
clickCount: 1,
|
|
179
|
+
buttons: 1,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await cdpSession.send('Input.dispatchMouseEvent', {
|
|
183
|
+
type: 'mouseReleased',
|
|
184
|
+
x: centerX,
|
|
185
|
+
y: centerY,
|
|
186
|
+
button: 'left',
|
|
187
|
+
clickCount: 1,
|
|
188
|
+
buttons: 1,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return true;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const getContentQuads = async (page: Page, args: { backendNodeId: number }) => {
|
|
195
|
+
const cdpSession = await getCDPSession(page);
|
|
196
|
+
const quadsResponse = await cdpSession.send('DOM.getContentQuads', args);
|
|
197
|
+
|
|
198
|
+
const [topLeftX, topLeftY, topRightX, topRightY, bottomRightX, bottomRightY, bottomLeftX, bottomLeftY] =
|
|
199
|
+
quadsResponse.quads[0];
|
|
200
|
+
|
|
201
|
+
const width = topRightX - topLeftX;
|
|
202
|
+
const height = bottomRightY - topRightY;
|
|
203
|
+
const centerX = topLeftX + width / 2;
|
|
204
|
+
const centerY = topRightY + height / 2;
|
|
205
|
+
|
|
206
|
+
console.log('getContentQuads', args, {
|
|
207
|
+
width,
|
|
208
|
+
height,
|
|
209
|
+
topLeftX,
|
|
210
|
+
topLeftY,
|
|
211
|
+
centerX,
|
|
212
|
+
centerY,
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
topLeftX,
|
|
216
|
+
topLeftY,
|
|
217
|
+
topRightX,
|
|
218
|
+
topRightY,
|
|
219
|
+
bottomRightX,
|
|
220
|
+
bottomRightY,
|
|
221
|
+
bottomLeftX,
|
|
222
|
+
bottomLeftY,
|
|
223
|
+
width,
|
|
224
|
+
height,
|
|
225
|
+
centerX,
|
|
226
|
+
centerY,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const focusElement = async (page: Page, args: { backendNodeId: number }) => {
|
|
231
|
+
const cdpSession = await getCDPSession(page);
|
|
232
|
+
await cdpSession.send('DOM.focus', args);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const getElementRect = async (page: Page, args: { id: string }) => {
|
|
236
|
+
const cdpSession = await getCDPSession(page);
|
|
237
|
+
const returnedValue = await cdpSession.send('Runtime.callFunctionOn', {
|
|
238
|
+
functionDeclaration: `function() {return JSON.parse(JSON.stringify(this.getBoundingClientRect()))}`,
|
|
239
|
+
objectId: args.id,
|
|
240
|
+
returnByValue: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return returnedValue.result.value;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export const findElements = async (page: Page, args: { using: string; value: string }) => {
|
|
247
|
+
switch (args.using) {
|
|
248
|
+
case 'css selector':
|
|
249
|
+
case 'tag name':
|
|
250
|
+
return await querySelectorAll(page, { selector: args.value });
|
|
251
|
+
default:
|
|
252
|
+
throw Error(`Unsupported findElements strategy ${args.using}`);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const querySelectorAll = async (page: Page, args: { selector: string }) => {
|
|
257
|
+
const cdpSession = await getCDPSession(page);
|
|
258
|
+
const rootDocumentNode = await cdpSession.send('DOM.getDocument', { depth: -1 });
|
|
259
|
+
const returned = await cdpSession.send('DOM.querySelectorAll', {
|
|
260
|
+
nodeId: rootDocumentNode.root.nodeId,
|
|
261
|
+
selector: args.selector,
|
|
262
|
+
});
|
|
263
|
+
const resolvedNodesPromises = returned.nodeIds.map(
|
|
264
|
+
async (nodeId) => await cdpSession.send('DOM.resolveNode', { nodeId }),
|
|
265
|
+
);
|
|
266
|
+
const resolvedNodes = await Promise.all(resolvedNodesPromises);
|
|
267
|
+
const returnValue = resolvedNodes.map((node) => ({ [WEBDRIVER_ELEMENT_KEY]: node.object.objectId }));
|
|
268
|
+
return returnValue;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const getCurrentUrl = async (page: Page) => {
|
|
272
|
+
const cdpSession = await getCDPSession(page);
|
|
273
|
+
const returned = await cdpSession.send('Page.getNavigationHistory');
|
|
274
|
+
const returnValue = returned.entries[returned.currentIndex].url;
|
|
275
|
+
return returnValue;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const executeScript = async (page: Page, args: { script: string; args: any[] }) => {
|
|
279
|
+
const functionDeclaration = `function() { ${args.script} }`;
|
|
280
|
+
const functionArgs = args.args.map((arg) => {
|
|
281
|
+
if (typeof arg === 'boolean' || typeof arg === 'string' || typeof arg === 'number') {
|
|
282
|
+
return { value: arg };
|
|
283
|
+
} else if (arg && typeof arg === 'object' && Reflect.has(arg, WEBDRIVER_ELEMENT_KEY)) {
|
|
284
|
+
return { objectId: arg[WEBDRIVER_ELEMENT_KEY] };
|
|
285
|
+
} else {
|
|
286
|
+
return { value: undefined };
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const cdpSession = await getCDPSession(page);
|
|
291
|
+
await cdpSession.send('Runtime.enable');
|
|
292
|
+
const window = await cdpSession.send('Runtime.evaluate', { expression: 'window' });
|
|
293
|
+
|
|
294
|
+
const returnedRef = await cdpSession.send('Runtime.callFunctionOn', {
|
|
295
|
+
objectId: window.result.objectId,
|
|
296
|
+
functionDeclaration,
|
|
297
|
+
arguments: functionArgs,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (returnedRef.result.className === 'NodeList') {
|
|
301
|
+
const nodeProperties = await cdpSession.send('Runtime.getProperties', {
|
|
302
|
+
objectId: returnedRef.result.objectId!,
|
|
303
|
+
ownProperties: true,
|
|
304
|
+
});
|
|
305
|
+
return nodeProperties.result
|
|
306
|
+
.map((e: any) => (!isNaN(parseInt(e.name, 10)) ? { [WEBDRIVER_ELEMENT_KEY]: e.value?.objectId } : null))
|
|
307
|
+
.filter((e: any) => e);
|
|
308
|
+
} else if (returnedRef.result.className === 'HTMLHtmlElement') {
|
|
309
|
+
return { [WEBDRIVER_ELEMENT_KEY]: returnedRef.result.objectId };
|
|
310
|
+
} else {
|
|
311
|
+
const returnedValue = await cdpSession.send('Runtime.callFunctionOn', {
|
|
312
|
+
objectId: window.result.objectId,
|
|
313
|
+
functionDeclaration,
|
|
314
|
+
arguments: functionArgs,
|
|
315
|
+
returnByValue: true,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return returnedValue.result.value;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
type CDPSession = Awaited<ReturnType<ReturnType<Page['context']>['newCDPSession']>>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Page } from 'playwright';
|
|
2
|
+
import { BaseElement, Rect } from '@midscene/core';
|
|
3
|
+
import { NodeType } from '../html-element/constants';
|
|
4
|
+
|
|
5
|
+
export interface WebElementInfoType extends BaseElement {
|
|
6
|
+
id: string;
|
|
7
|
+
locator: string;
|
|
8
|
+
attributes: {
|
|
9
|
+
['nodeType']: NodeType;
|
|
10
|
+
[key: string]: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class WebElementInfo implements BaseElement {
|
|
15
|
+
content: string;
|
|
16
|
+
|
|
17
|
+
locator: string;
|
|
18
|
+
|
|
19
|
+
rect: Rect;
|
|
20
|
+
|
|
21
|
+
center: [number, number];
|
|
22
|
+
|
|
23
|
+
page: Page;
|
|
24
|
+
|
|
25
|
+
id: string;
|
|
26
|
+
|
|
27
|
+
attributes: {
|
|
28
|
+
['nodeType']: NodeType;
|
|
29
|
+
[key: string]: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
constructor({
|
|
33
|
+
content,
|
|
34
|
+
rect,
|
|
35
|
+
page,
|
|
36
|
+
locator,
|
|
37
|
+
id,
|
|
38
|
+
attributes,
|
|
39
|
+
}: {
|
|
40
|
+
content: string;
|
|
41
|
+
rect: Rect;
|
|
42
|
+
page: Page;
|
|
43
|
+
locator: string;
|
|
44
|
+
id: string;
|
|
45
|
+
attributes: {
|
|
46
|
+
['nodeType']: NodeType;
|
|
47
|
+
[key: string]: string;
|
|
48
|
+
};
|
|
49
|
+
}) {
|
|
50
|
+
this.content = content;
|
|
51
|
+
this.rect = rect;
|
|
52
|
+
this.center = [Math.floor(rect.left + rect.width / 2), Math.floor(rect.top + rect.height / 2)];
|
|
53
|
+
this.page = page;
|
|
54
|
+
this.locator = locator;
|
|
55
|
+
this.id = id;
|
|
56
|
+
this.attributes = attributes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async tap() {
|
|
60
|
+
await this.page.mouse.click(this.center[0], this.center[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async hover() {
|
|
64
|
+
await this.page.mouse.move(this.center[0], this.center[1]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async type(text: string) {
|
|
68
|
+
await this.page.keyboard.type(text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async press(key: Parameters<typeof this.page.keyboard.press>[0]) {
|
|
72
|
+
await this.page.keyboard.press(key);
|
|
73
|
+
}
|
|
74
|
+
}
|