@midscene/web 0.0.1 → 0.1.0

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/lib/index.js CHANGED
@@ -47,26 +47,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
47
47
  // src/index.ts
48
48
  var src_exports = {};
49
49
  __export(src_exports, {
50
- PlaywrightAiFixture: () => PlaywrightAiFixture
50
+ PlaywrightAiFixture: () => PlaywrightAiFixture,
51
+ PuppeteerAgent: () => PageAgent
51
52
  });
52
53
  module.exports = __toCommonJS(src_exports);
53
54
 
54
55
  // src/playwright/index.ts
55
- var import_utils3 = require("@midscene/core/utils");
56
+ var import_crypto = require("crypto");
56
57
 
57
- // src/playwright/actions.ts
58
- var import_assert2 = __toESM(require("assert"));
59
- var import_core = __toESM(require("@midscene/core"));
60
- var import_utils = require("@midscene/core/utils");
61
- var import_image2 = require("@midscene/core/image");
58
+ // src/playwright/cache.ts
59
+ var import_path2 = __toESM(require("path"));
60
+ var import_fs2 = __toESM(require("fs"));
61
+ var import_utils2 = require("@midscene/core/utils");
62
62
 
63
- // src/playwright/utils.ts
63
+ // src/common/utils.ts
64
64
  var import_fs = __toESM(require("fs"));
65
65
  var import_assert = __toESM(require("assert"));
66
66
  var import_path = __toESM(require("path"));
67
67
  var import_image = require("@midscene/core/image");
68
+ var import_utils = require("@midscene/core/utils");
68
69
 
69
- // src/playwright/element.ts
70
+ // src/web-element.ts
70
71
  var WebElementInfo = class {
71
72
  constructor({
72
73
  content,
@@ -84,24 +85,13 @@ var WebElementInfo = class {
84
85
  this.id = id;
85
86
  this.attributes = attributes;
86
87
  }
87
- async tap() {
88
- await this.page.mouse.click(this.center[0], this.center[1]);
89
- }
90
- async hover() {
91
- await this.page.mouse.move(this.center[0], this.center[1]);
92
- }
93
- async type(text) {
94
- await this.page.keyboard.type(text);
95
- }
96
- async press(key) {
97
- await this.page.keyboard.press(key);
98
- }
99
88
  };
100
89
 
101
- // src/playwright/utils.ts
102
- async function parseContextFromPlaywrightPage(page, _opt) {
90
+ // src/common/utils.ts
91
+ async function parseContextFromWebPage(page, _opt) {
103
92
  (0, import_assert.default)(page, "page is required");
104
- const file = "/Users/bytedance/workspace/midscene/packages/midscene/tests/fixtures/heytea.jpeg";
93
+ const url = page.url();
94
+ const file = (0, import_utils.getTmpFile)("jpeg");
105
95
  await page.screenshot({ path: file, type: "jpeg", quality: 75 });
106
96
  const screenshotBuffer = (0, import_fs.readFileSync)(file);
107
97
  const screenshotBase64 = (0, import_image.base64Encoded)(file);
@@ -111,7 +101,8 @@ async function parseContextFromPlaywrightPage(page, _opt) {
111
101
  return {
112
102
  content: elementsInfo,
113
103
  size,
114
- screenshotBase64
104
+ screenshotBase64,
105
+ url
115
106
  };
116
107
  }
117
108
  async function getElementInfosFromPage(page) {
@@ -153,18 +144,136 @@ function findNearestPackageJson(dir) {
153
144
  return findNearestPackageJson(parentDir);
154
145
  }
155
146
 
156
- // src/playwright/actions.ts
157
- var PlayWrightActionAgent = class {
158
- constructor(page, opt) {
147
+ // src/playwright/cache.ts
148
+ function writeTestCache(taskFile, taskTitle, taskCacheJson) {
149
+ const packageJson = getPkgInfo();
150
+ (0, import_utils2.writeDumpFile)({
151
+ fileName: `${taskFile}(${taskTitle})`,
152
+ fileExt: "json",
153
+ fileContent: JSON.stringify(
154
+ __spreadValues({
155
+ pkgName: packageJson.name,
156
+ pkgVersion: packageJson.version,
157
+ taskFile,
158
+ taskTitle
159
+ }, taskCacheJson),
160
+ null,
161
+ 2
162
+ ),
163
+ type: "cache"
164
+ });
165
+ }
166
+ function readTestCache(taskFile, taskTitle) {
167
+ const cacheFile = (0, import_path2.join)((0, import_utils2.getDumpDirPath)("cache"), `${taskFile}(${taskTitle}).json`);
168
+ const pkgInfo = getPkgInfo();
169
+ if (process.env.MIDSCENE_CACHE === "true" && import_fs2.default.existsSync(cacheFile)) {
170
+ try {
171
+ const data = import_fs2.default.readFileSync(cacheFile, "utf8");
172
+ const jsonData = JSON.parse(data);
173
+ if (jsonData.pkgName !== pkgInfo.name || jsonData.pkgVersion !== pkgInfo.version) {
174
+ return void 0;
175
+ }
176
+ return jsonData;
177
+ } catch (err) {
178
+ return void 0;
179
+ }
180
+ }
181
+ return void 0;
182
+ }
183
+ function getPkgInfo() {
184
+ const packageJsonDir = findNearestPackageJson(__dirname);
185
+ if (!packageJsonDir) {
186
+ console.error("Cannot find package.json directory: ", __dirname);
187
+ return {
188
+ name: "@midscene/web",
189
+ version: "0.0.0"
190
+ };
191
+ }
192
+ const packageJsonPath = import_path2.default.join(packageJsonDir, "package.json");
193
+ const data = import_fs2.default.readFileSync(packageJsonPath, "utf8");
194
+ const packageJson = JSON.parse(data);
195
+ return {
196
+ name: packageJson.name,
197
+ version: packageJson.version
198
+ };
199
+ }
200
+
201
+ // src/common/agent.ts
202
+ var import_utils6 = require("@midscene/core/utils");
203
+
204
+ // src/common/tasks.ts
205
+ var import_assert2 = __toESM(require("assert"));
206
+ var import_core = __toESM(require("@midscene/core"));
207
+ var import_utils4 = require("@midscene/core/utils");
208
+ var import_image2 = require("@midscene/core/image");
209
+
210
+ // src/common/task-cache.ts
211
+ var TaskCache = class {
212
+ constructor(opts) {
213
+ this.cache = opts == null ? void 0 : opts.cache;
214
+ this.newCache = {
215
+ aiTasks: []
216
+ };
217
+ }
218
+ readCache(pageContext, type, userPrompt) {
219
+ var _a;
220
+ if (this.cache) {
221
+ const { aiTasks } = this.cache;
222
+ const index = aiTasks.findIndex((item) => item.prompt === userPrompt);
223
+ if (index === -1) {
224
+ return false;
225
+ }
226
+ const taskRes = aiTasks.splice(index, 1)[0];
227
+ if ((taskRes == null ? void 0 : taskRes.type) === "locate" && !((_a = taskRes.response) == null ? void 0 : _a.elements.every((element) => {
228
+ const findIndex = pageContext.content.findIndex(
229
+ (contentElement) => contentElement.id === element.id
230
+ );
231
+ if (findIndex === -1) {
232
+ return false;
233
+ }
234
+ return true;
235
+ }))) {
236
+ return false;
237
+ }
238
+ if (taskRes && taskRes.type === type && taskRes.prompt === userPrompt && this.pageContextEqual(taskRes.pageContext, pageContext)) {
239
+ return taskRes.response;
240
+ }
241
+ }
242
+ return false;
243
+ }
244
+ saveCache(cache) {
245
+ var _a;
246
+ if (cache) {
247
+ (_a = this.newCache) == null ? void 0 : _a.aiTasks.push(cache);
248
+ }
249
+ }
250
+ pageContextEqual(taskPageContext, pageContext) {
251
+ return taskPageContext.size.width === pageContext.size.width && taskPageContext.size.height === pageContext.size.height;
252
+ }
253
+ /**
254
+ * Generate task cache data.
255
+ * This method is mainly used to create or obtain some cached data for tasks, and it returns a new cache object.
256
+ * In the cache object, it may contain task-related information, states, or other necessary data.
257
+ * It is assumed that the `newCache` property already exists in the current class or object and is a data structure used to store task cache.
258
+ * @returns {Object} Returns a new cache object, which may include task cache data.
259
+ */
260
+ generateTaskCache() {
261
+ return this.newCache;
262
+ }
263
+ };
264
+
265
+ // src/common/tasks.ts
266
+ var PageTaskExecutor = class {
267
+ constructor(page, opts) {
159
268
  this.page = page;
160
269
  this.insight = new import_core.default(async () => {
161
- return await parseContextFromPlaywrightPage(page);
270
+ return await parseContextFromWebPage(page);
162
271
  });
163
- this.executor = new import_core.Executor((opt == null ? void 0 : opt.taskName) || "MidScene - PlayWrightAI");
272
+ this.taskCache = new TaskCache(opts);
164
273
  }
165
274
  async recordScreenshot(timing) {
166
- const file = (0, import_utils.getTmpFile)("jpeg");
167
- await this.page.screenshot(__spreadProps(__spreadValues({}, import_utils.commonScreenshotParam), {
275
+ const file = (0, import_utils4.getTmpFile)("jpeg");
276
+ await this.page.screenshot(__spreadProps(__spreadValues({}, import_utils4.commonScreenshotParam), {
168
277
  path: file
169
278
  }));
170
279
  const item = {
@@ -185,7 +294,7 @@ var PlayWrightActionAgent = class {
185
294
  recorder.push(shot);
186
295
  const result = await taskApply.executor(param, context, ...args);
187
296
  if (taskApply.type === "Action") {
188
- await (0, import_utils.sleep)(1e3);
297
+ await (0, import_utils4.sleep)(1e3);
189
298
  const shot2 = await this.recordScreenshot("after Action");
190
299
  recorder.push(shot2);
191
300
  }
@@ -209,14 +318,41 @@ var PlayWrightActionAgent = class {
209
318
  insightDump = dump;
210
319
  };
211
320
  this.insight.onceDumpUpdatedFn = dumpCollector;
212
- const element = await this.insight.locate(param.prompt);
321
+ const pageContext = await this.insight.contextRetrieverFn();
322
+ const locateCache = this.taskCache.readCache(pageContext, "locate", param.prompt);
323
+ let locateResult;
324
+ const callAI = this.insight.aiVendorFn;
325
+ const element = await this.insight.locate(param.prompt, {
326
+ callAI: async (message) => {
327
+ if (locateCache) {
328
+ locateResult = locateCache;
329
+ return Promise.resolve(locateCache);
330
+ }
331
+ locateResult = await callAI(message);
332
+ return locateResult;
333
+ }
334
+ });
213
335
  (0, import_assert2.default)(element, `Element not found: ${param.prompt}`);
336
+ if (locateResult) {
337
+ this.taskCache.saveCache({
338
+ type: "locate",
339
+ pageContext: {
340
+ url: pageContext.url,
341
+ size: pageContext.size
342
+ },
343
+ prompt: param.prompt,
344
+ response: locateResult
345
+ });
346
+ }
214
347
  return {
215
348
  output: {
216
349
  element
217
350
  },
218
351
  log: {
219
352
  dump: insightDump
353
+ },
354
+ cache: {
355
+ hit: Boolean(locateResult)
220
356
  }
221
357
  };
222
358
  }
@@ -302,43 +438,66 @@ var PlayWrightActionAgent = class {
302
438
  return tasks;
303
439
  }
304
440
  async action(userPrompt) {
305
- this.executor.description = userPrompt;
306
- const pageContext = await this.insight.contextRetrieverFn();
441
+ const taskExecutor = new import_core.Executor(userPrompt);
442
+ taskExecutor.description = userPrompt;
307
443
  let plans = [];
308
444
  const planningTask = {
309
445
  type: "Planning",
310
446
  param: {
311
447
  userPrompt
312
448
  },
313
- async executor(param) {
314
- const planResult = await (0, import_core.plan)(pageContext, param.userPrompt);
449
+ executor: async (param) => {
450
+ const pageContext = await this.insight.contextRetrieverFn();
451
+ let planResult;
452
+ const planCache = this.taskCache.readCache(pageContext, "plan", userPrompt);
453
+ if (planCache) {
454
+ planResult = planCache;
455
+ } else {
456
+ planResult = await (0, import_core.plan)(param.userPrompt, {
457
+ context: pageContext
458
+ });
459
+ }
315
460
  (0, import_assert2.default)(planResult.plans.length > 0, "No plans found");
316
461
  plans = planResult.plans;
462
+ this.taskCache.saveCache({
463
+ type: "plan",
464
+ pageContext: {
465
+ url: pageContext.url,
466
+ size: pageContext.size
467
+ },
468
+ prompt: userPrompt,
469
+ response: planResult
470
+ });
317
471
  return {
318
- output: planResult
472
+ output: planResult,
473
+ cache: {
474
+ hint: Boolean(planCache)
475
+ }
319
476
  };
320
477
  }
321
478
  };
322
479
  try {
323
- await this.executor.append(this.wrapExecutorWithScreenshot(planningTask));
324
- await this.executor.flush();
325
- this.actionDump = this.executor.dump();
480
+ await taskExecutor.append(this.wrapExecutorWithScreenshot(planningTask));
481
+ await taskExecutor.flush();
482
+ this.executionDump = taskExecutor.dump();
326
483
  const executables = await this.convertPlanToExecutable(plans);
327
- await this.executor.append(executables);
328
- await this.executor.flush();
329
- this.actionDump = this.executor.dump();
484
+ await taskExecutor.append(executables);
485
+ await taskExecutor.flush();
486
+ this.executionDump = taskExecutor.dump();
330
487
  (0, import_assert2.default)(
331
- this.executor.status !== "error",
332
- `failed to execute tasks: ${this.executor.status}, msg: ${this.executor.errorMsg || ""}`
488
+ taskExecutor.status !== "error",
489
+ `failed to execute tasks: ${taskExecutor.status}, msg: ${taskExecutor.errorMsg || ""}`
333
490
  );
334
491
  } catch (e) {
335
- this.actionDump = this.executor.dump();
492
+ this.executionDump = taskExecutor.dump();
336
493
  const err = new Error(e.message, { cause: e });
337
494
  throw err;
338
495
  }
339
496
  }
340
497
  async query(demand) {
341
- this.executor.description = JSON.stringify(demand);
498
+ const description = JSON.stringify(demand);
499
+ const taskExecutor = new import_core.Executor(description);
500
+ taskExecutor.description = description;
342
501
  let data;
343
502
  const queryTask = {
344
503
  type: "Insight",
@@ -360,11 +519,11 @@ var PlayWrightActionAgent = class {
360
519
  }
361
520
  };
362
521
  try {
363
- await this.executor.append(this.wrapExecutorWithScreenshot(queryTask));
364
- await this.executor.flush();
365
- this.actionDump = this.executor.dump();
522
+ await taskExecutor.append(this.wrapExecutorWithScreenshot(queryTask));
523
+ await taskExecutor.flush();
524
+ this.executionDump = taskExecutor.dump();
366
525
  } catch (e) {
367
- this.actionDump = this.executor.dump();
526
+ this.executionDump = taskExecutor.dump();
368
527
  const err = new Error(e.message, { cause: e });
369
528
  throw err;
370
529
  }
@@ -372,102 +531,171 @@ var PlayWrightActionAgent = class {
372
531
  }
373
532
  };
374
533
 
375
- // src/playwright/index.ts
376
- var PlaywrightAiFixture = () => {
377
- const dumps = [];
378
- const appendDump = (groupName, execution) => {
379
- let currentDump = dumps.find((dump) => dump.groupName === groupName);
380
- if (!currentDump) {
381
- currentDump = {
382
- groupName,
534
+ // src/common/agent.ts
535
+ var PageAgent = class {
536
+ constructor(page, opts) {
537
+ this.page = page;
538
+ this.dumps = [
539
+ {
540
+ groupName: (opts == null ? void 0 : opts.taskFile) || "unnamed",
383
541
  executions: []
384
- };
385
- dumps.push(currentDump);
386
- }
542
+ }
543
+ ];
544
+ this.testId = (opts == null ? void 0 : opts.testId) || String(process.pid);
545
+ this.actionAgent = new PageTaskExecutor(this.page, {
546
+ cache: (opts == null ? void 0 : opts.cache) || { aiTasks: [] }
547
+ });
548
+ }
549
+ appendDump(execution) {
550
+ const currentDump = this.dumps[0];
387
551
  currentDump.executions.push(execution);
388
- };
389
- const writeOutActionDumps = () => {
390
- (0, import_utils3.writeDumpFile)(`playwright-${process.pid}`, import_utils3.groupedActionDumpFileExt, JSON.stringify(dumps));
391
- };
392
- const groupAndCaseForTest = (testInfo) => {
393
- let groupName;
394
- let caseName;
395
- const titlePath = [...testInfo.titlePath];
396
- if (titlePath.length > 1) {
397
- caseName = titlePath.pop();
398
- groupName = titlePath.join(" > ");
399
- } else if (titlePath.length === 1) {
400
- caseName = titlePath[0];
401
- groupName = caseName;
402
- } else {
403
- caseName = "unnamed";
404
- groupName = "unnamed";
405
- }
406
- return { groupName, caseName };
407
- };
408
- const aiAction = async (page, testInfo, taskPrompt) => {
409
- const { groupName, caseName } = groupAndCaseForTest(testInfo);
410
- const actionAgent = new PlayWrightActionAgent(page, { taskName: caseName });
552
+ }
553
+ writeOutActionDumps() {
554
+ this.dumpFile = (0, import_utils6.writeDumpFile)({
555
+ fileName: `playwright-${this.testId}`,
556
+ fileExt: import_utils6.groupedActionDumpFileExt,
557
+ fileContent: JSON.stringify(this.dumps)
558
+ });
559
+ }
560
+ async aiAction(taskPrompt) {
411
561
  let error;
412
562
  try {
413
- await actionAgent.action(taskPrompt);
563
+ await this.actionAgent.action(taskPrompt);
414
564
  } catch (e) {
415
565
  error = e;
416
566
  }
417
- if (actionAgent.actionDump) {
418
- appendDump(groupName, actionAgent.actionDump);
419
- writeOutActionDumps();
567
+ if (this.actionAgent.executionDump) {
568
+ this.appendDump(this.actionAgent.executionDump);
569
+ this.writeOutActionDumps();
420
570
  }
421
571
  if (error) {
422
572
  console.error(error);
423
573
  throw new Error(error.message, { cause: error });
424
574
  }
425
- };
426
- const aiQuery = async (page, testInfo, demand) => {
427
- const { groupName, caseName } = groupAndCaseForTest(testInfo);
428
- const actionAgent = new PlayWrightActionAgent(page, { taskName: caseName });
575
+ }
576
+ async aiQuery(demand) {
429
577
  let error;
430
578
  let result;
431
579
  try {
432
- result = await actionAgent.query(demand);
580
+ result = await this.actionAgent.query(demand);
433
581
  } catch (e) {
434
582
  error = e;
435
583
  }
436
- if (actionAgent.actionDump) {
437
- appendDump(groupName, actionAgent.actionDump);
438
- writeOutActionDumps();
584
+ if (this.actionAgent.executionDump) {
585
+ this.appendDump(this.actionAgent.executionDump);
586
+ this.writeOutActionDumps();
439
587
  }
440
588
  if (error) {
441
589
  console.error(error);
442
590
  throw new Error(error.message, { cause: error });
443
591
  }
444
592
  return result;
593
+ }
594
+ async ai(taskPrompt, type = "action") {
595
+ if (type === "action") {
596
+ return this.aiAction(taskPrompt);
597
+ } else if (type === "query") {
598
+ return this.aiQuery(taskPrompt);
599
+ }
600
+ throw new Error(`Unknown or Unsupported task type: ${type}, only support 'action' or 'query'`);
601
+ }
602
+ };
603
+
604
+ // src/playwright/index.ts
605
+ var groupAndCaseForTest = (testInfo) => {
606
+ let taskFile;
607
+ let taskTitle;
608
+ const titlePath = [...testInfo.titlePath];
609
+ if (titlePath.length > 1) {
610
+ taskTitle = titlePath.pop();
611
+ taskFile = `${titlePath.join(" > ")}:${testInfo.line}`;
612
+ } else if (titlePath.length === 1) {
613
+ taskTitle = titlePath[0];
614
+ taskFile = `${taskTitle}:${testInfo.line}`;
615
+ } else {
616
+ taskTitle = "unnamed";
617
+ taskFile = "unnamed";
618
+ }
619
+ return { taskFile, taskTitle };
620
+ };
621
+ var midSceneAgentKeyId = "_midSceneAgentId";
622
+ var PlaywrightAiFixture = () => {
623
+ const pageAgentMap = {};
624
+ const agentForPage = (page, opts) => {
625
+ let idForPage = page[midSceneAgentKeyId];
626
+ if (!idForPage) {
627
+ idForPage = (0, import_crypto.randomUUID)();
628
+ page[midSceneAgentKeyId] = idForPage;
629
+ const testCase = readTestCache(opts.taskFile, opts.taskTitle) || { aiTasks: [] };
630
+ pageAgentMap[idForPage] = new PageAgent(page, {
631
+ testId: `${opts.testId}-${idForPage}`,
632
+ taskFile: opts.taskFile,
633
+ cache: testCase
634
+ });
635
+ }
636
+ return pageAgentMap[idForPage];
445
637
  };
446
638
  return {
447
- // shortcut
448
639
  ai: async ({ page }, use, testInfo) => {
449
- await use(async (taskPrompt, type = "action") => {
450
- if (type === "action") {
451
- return aiAction(page, testInfo, taskPrompt);
452
- } else if (type === "query") {
453
- return aiQuery(page, testInfo, taskPrompt);
454
- }
455
- throw new Error(`Unknown or Unsupported task type: ${type}, only support 'action' or 'query'`);
640
+ const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
641
+ const agent = agentForPage(page, { testId: testInfo.testId, taskFile, taskTitle });
642
+ await use(async (taskPrompt, opts) => {
643
+ await page.waitForLoadState("networkidle");
644
+ const actionType = (opts == null ? void 0 : opts.type) || "action";
645
+ const result = await agent.ai(taskPrompt, actionType);
646
+ return result;
456
647
  });
648
+ const taskCacheJson = agent.actionAgent.taskCache.generateTaskCache();
649
+ writeTestCache(taskFile, taskTitle, taskCacheJson);
650
+ if (agent.dumpFile) {
651
+ testInfo.annotations.push({
652
+ type: "MIDSCENE_AI_ACTION",
653
+ description: JSON.stringify({
654
+ testId: testInfo.testId,
655
+ dumpPath: agent.dumpFile
656
+ })
657
+ });
658
+ }
457
659
  },
458
660
  aiAction: async ({ page }, use, testInfo) => {
661
+ const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
662
+ const agent = agentForPage(page, { testId: testInfo.testId, taskFile, taskTitle });
459
663
  await use(async (taskPrompt) => {
460
- await aiAction(page, testInfo, taskPrompt);
664
+ await page.waitForLoadState("networkidle");
665
+ await agent.aiAction(taskPrompt);
461
666
  });
667
+ if (agent.dumpFile) {
668
+ testInfo.annotations.push({
669
+ type: "MIDSCENE_AI_ACTION",
670
+ description: JSON.stringify({
671
+ testId: testInfo.testId,
672
+ dumpPath: agent.dumpFile
673
+ })
674
+ });
675
+ }
462
676
  },
463
677
  aiQuery: async ({ page }, use, testInfo) => {
678
+ const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
679
+ const agent = agentForPage(page, { testId: testInfo.testId, taskFile, taskTitle });
464
680
  await use(async function(demand) {
465
- return aiQuery(page, testInfo, demand);
681
+ await page.waitForLoadState("networkidle");
682
+ const result = await agent.aiQuery(demand);
683
+ return result;
466
684
  });
685
+ if (agent.dumpFile) {
686
+ testInfo.annotations.push({
687
+ type: "MIDSCENE_AI_ACTION",
688
+ description: JSON.stringify({
689
+ testId: testInfo.testId,
690
+ dumpPath: agent.dumpFile
691
+ })
692
+ });
693
+ }
467
694
  }
468
695
  };
469
696
  };
470
697
  // Annotate the CommonJS export names for ESM import in node:
471
698
  0 && (module.exports = {
472
- PlaywrightAiFixture
699
+ PlaywrightAiFixture,
700
+ PuppeteerAgent
473
701
  });