From beeebba0fcd153b4d74b794afe871e38fd006b78 Mon Sep 17 00:00:00 2001 From: yuyutaotao <167746126+yuyutaotao@users.noreply.github.com> Date: Sun, 28 Jul 2024 08:49:57 +0800 Subject: [PATCH] feat(web): export puppeteer integration (#11) * feat: puppeteer integration * feat: export puppeteer agent * fix: bug of insight visualizer --- apps/site/docs/visualization/index.mdx | 2 +- packages/midscene/src/utils.ts | 4 +- packages/midscene/tests/fixtures/heytea.jpeg | Bin 33373 -> 31228 bytes packages/midscene/tests/utils.ts | 29 ---- packages/visualizer/docs/index.tsx | 1 + packages/visualizer/src/component/common.less | 2 +- .../visualizer/src/component/detail-panel.tsx | 4 +- .../visualizer/src/component/detail-side.less | 6 +- .../visualizer/src/component/detail-side.tsx | 8 +- packages/visualizer/src/component/sidebar.tsx | 19 ++- packages/visualizer/src/component/store.tsx | 8 +- .../visualizer/src/component/timeline.tsx | 2 +- packages/visualizer/src/index.less | 2 +- packages/visualizer/src/index.tsx | 6 +- .../web-integration/modern.inspect.config.ts | 2 +- packages/web-integration/src/common/agent.ts | 80 +++++++++++ .../src/{playwright => common}/cdp.ts | 0 packages/web-integration/src/common/page.d.ts | 5 + .../actions.ts => common/tasks.ts} | 56 ++++---- .../src/{playwright => common}/utils.ts | 18 +-- .../{html-element => extractor}/constants.ts | 0 .../src/{html-element => extractor}/debug.ts | 0 .../{html-element => extractor}/dom-util.ts | 0 .../extractInfo.ts => extractor/extractor.ts} | 10 +- .../web-integration/src/extractor/index.ts | 1 + .../src/{html-element => extractor}/util.ts | 0 .../web-integration/src/html-element/index.ts | 1 - packages/web-integration/src/img/img.ts | 12 +- packages/web-integration/src/img/util.ts | 14 +- packages/web-integration/src/index.ts | 2 + .../web-integration/src/playwright/index.ts | 132 ++++++------------ .../web-integration/src/puppeteer/element.ts | 49 ------- .../web-integration/src/puppeteer/index.ts | 7 +- .../web-integration/src/puppeteer/utils.ts | 116 --------------- .../{playwright/element.ts => web-element.ts} | 24 +--- .../tests/e2e/ai-xicha.spec.ts | 13 +- packages/web-integration/tests/e2e/tool.ts | 6 +- .../tests/puppeteer/bing.spec.ts | 23 +++ .../web-integration/tests/puppeteer/utils.ts | 35 +++++ packages/web-integration/vitest.config.ts | 3 +- 40 files changed, 303 insertions(+), 399 deletions(-) create mode 100644 packages/web-integration/src/common/agent.ts rename packages/web-integration/src/{playwright => common}/cdp.ts (100%) create mode 100644 packages/web-integration/src/common/page.d.ts rename packages/web-integration/src/{playwright/actions.ts => common/tasks.ts} (83%) rename packages/web-integration/src/{playwright => common}/utils.ts (81%) rename packages/web-integration/src/{html-element => extractor}/constants.ts (100%) rename packages/web-integration/src/{html-element => extractor}/debug.ts (100%) rename packages/web-integration/src/{html-element => extractor}/dom-util.ts (100%) rename packages/web-integration/src/{html-element/extractInfo.ts => extractor/extractor.ts} (96%) create mode 100644 packages/web-integration/src/extractor/index.ts rename packages/web-integration/src/{html-element => extractor}/util.ts (100%) delete mode 100644 packages/web-integration/src/html-element/index.ts delete mode 100644 packages/web-integration/src/puppeteer/element.ts delete mode 100644 packages/web-integration/src/puppeteer/utils.ts rename packages/web-integration/src/{playwright/element.ts => web-element.ts} (66%) create mode 100644 packages/web-integration/tests/puppeteer/bing.spec.ts create mode 100644 packages/web-integration/tests/puppeteer/utils.ts diff --git a/apps/site/docs/visualization/index.mdx b/apps/site/docs/visualization/index.mdx index 15a329775..65d049882 100644 --- a/apps/site/docs/visualization/index.mdx +++ b/apps/site/docs/visualization/index.mdx @@ -3,4 +3,4 @@ pageType: custom --- import Visualizer from '@midscene/visualizer'; - + diff --git a/packages/midscene/src/utils.ts b/packages/midscene/src/utils.ts index 10f6b3f1d..2fcbbbc0b 100644 --- a/packages/midscene/src/utils.ts +++ b/packages/midscene/src/utils.ts @@ -87,8 +87,8 @@ export function getTmpDir() { return path; } -export function getTmpFile(fileExt: string) { - const filename = `${randomUUID()}.${fileExt}`; +export function getTmpFile(fileExtWithoutDot: string) { + const filename = `${randomUUID()}.${fileExtWithoutDot}`; return join(getTmpDir(), filename); } diff --git a/packages/midscene/tests/fixtures/heytea.jpeg b/packages/midscene/tests/fixtures/heytea.jpeg index 9d2ba092deb282a020dce459eb99bf957aa8d5d7..2f07f0298f41203cd0fd0a4fc5193b19a1a5174a 100644 GIT binary patch literal 31228 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 N{&E=h*o6IA`adaKaD)H= diff --git a/packages/midscene/tests/utils.ts b/packages/midscene/tests/utils.ts index 674cd55d2..7823825f1 100644 --- a/packages/midscene/tests/utils.ts +++ b/packages/midscene/tests/utils.ts @@ -23,35 +23,6 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -// export async function launch(url: string, opt?: { -// viewport?: Viewport, -// }) { -// const browser = await puppeteer.launch(); - -// const page = (await browser.pages())[0]; -// const viewportConfig = { -// width: opt?.viewport?.pixelWidth || 1920, -// height: opt?.viewport?.pixelHeight || 1080, -// deviceScaleFactor: opt?.viewport?.dpr || 1, -// } -// await page.setViewport(viewportConfig); -// await Promise.all([ -// page.waitForNavigation({ -// timeout: 20 * 1000, -// waitUntil: 'networkidle0', -// }), -// (async () => { -// const response = await page.goto(url); -// if (response?.status) { -// assert(response.status() <= 399, `Page load failed: ${response.status()}`); -// } -// })(), -// ]); -// await sleep(2 * 1000); - -// return browser; -// } - export function fakeInsight(content: string) { const screenshot = getFixture('baidu.png'); const basicContext = { diff --git a/packages/visualizer/docs/index.tsx b/packages/visualizer/docs/index.tsx index 14394bc9a..1d1f80190 100644 --- a/packages/visualizer/docs/index.tsx +++ b/packages/visualizer/docs/index.tsx @@ -2,5 +2,6 @@ import React from 'react'; import Tool from '@/index'; export default () => { + // return ; return ; }; diff --git a/packages/visualizer/src/component/common.less b/packages/visualizer/src/component/common.less index caf79cf24..ef044842e 100644 --- a/packages/visualizer/src/component/common.less +++ b/packages/visualizer/src/component/common.less @@ -3,7 +3,7 @@ @main-orange: #F9483E; -@side-bg: #ECECEC; +@side-bg: #f7f7f7; @title-bg: #DDDDDD; @border-color: #CCCCCC; @heavy-border-color: #888; diff --git a/packages/visualizer/src/component/detail-panel.tsx b/packages/visualizer/src/component/detail-panel.tsx index b8b15b262..9fe74a048 100644 --- a/packages/visualizer/src/component/detail-panel.tsx +++ b/packages/visualizer/src/component/detail-panel.tsx @@ -18,15 +18,15 @@ const ScreenshotItem = (props: { time: string; img: string }) => { ); }; +const VIEW_TYPE_BLACKBOARD = 'blackboard'; const VIEW_TYPE_SCREENSHOT = 'screenshot'; const VIEW_TYPE_JSON = 'json'; -const VIEW_TYPE_BLACKBOARD = 'blackboard'; const DetailPanel = (): JSX.Element => { const dumpId = useInsightDump((store) => store._loadId); const blackboardViewAvailable = Boolean(dumpId); const activeTask = useExecutionDump((store) => store.activeTask); - const [preferredViewType, setViewType] = useState(dumpId ? VIEW_TYPE_BLACKBOARD : VIEW_TYPE_SCREENSHOT); + const [preferredViewType, setViewType] = useState(VIEW_TYPE_BLACKBOARD); const viewType = preferredViewType === VIEW_TYPE_BLACKBOARD && !dumpId ? VIEW_TYPE_SCREENSHOT : preferredViewType; diff --git a/packages/visualizer/src/component/detail-side.less b/packages/visualizer/src/component/detail-side.less index 275a35746..a4be658dd 100644 --- a/packages/visualizer/src/component/detail-side.less +++ b/packages/visualizer/src/component/detail-side.less @@ -119,10 +119,8 @@ } } - .context { - pre { - text-wrap: balance; - } + pre { + text-wrap: balance; } .item-list-space-up { diff --git a/packages/visualizer/src/component/detail-side.tsx b/packages/visualizer/src/component/detail-side.tsx index 8abc9d49a..7167a3ac9 100644 --- a/packages/visualizer/src/component/detail-side.tsx +++ b/packages/visualizer/src/component/detail-side.tsx @@ -313,8 +313,14 @@ const DetailSide = (): JSX.Element => { ) : null; const dataCard = dump?.data ? ( - {kv(dump.data)}}> + {JSON.stringify(dump.data, undefined, 2)}} + > ) : null; + console.log('dump is', dump); const plans = (task as ExecutionTaskPlanning)?.output?.plans; let timelineData: TimelineItemProps[] = []; diff --git a/packages/visualizer/src/component/sidebar.tsx b/packages/visualizer/src/component/sidebar.tsx index 089e2b815..47a205fc7 100644 --- a/packages/visualizer/src/component/sidebar.tsx +++ b/packages/visualizer/src/component/sidebar.tsx @@ -8,7 +8,7 @@ import { LogoutOutlined, MinusOutlined, } from '@ant-design/icons'; -import { ExecutionTask } from '@midscene/core'; +import { ExecutionTask, ExecutionTaskInsightQuery } from '@midscene/core'; import { Button } from 'antd'; import PanelTitle from './panel-title'; import { timeCostStrElement } from './misc'; @@ -43,8 +43,17 @@ const SideItem = (props: { statusText = timeCostStrElement(task.timing.cost); } - const contentRow = - task.type === 'Planning' ?

{task.param?.userPrompt}
: null; + let contentRow: JSX.Element | undefined; + if (task.type === 'Planning') { + contentRow =
{task.param?.userPrompt}
; + } else if (task.type === 'Insight' && task.subType === 'Query') { + // debugger; + const demand = (task as ExecutionTaskInsightQuery).param?.dataDemand; + const contentToShow = typeof demand === 'string' ? demand : JSON.stringify(demand); + contentRow =
{contentToShow}
; + } else { + // debugger; + } // add hover listener return (
{ +const Sidebar = (props: { hideLogo?: boolean }): JSX.Element => { const groupedDumps = useExecutionDump((store) => store.dump); const setActiveTask = useExecutionDump((store) => store.setActiveTask); const activeTask = useExecutionDump((store) => store.activeTask); @@ -177,7 +186,7 @@ const Sidebar = (): JSX.Element => { return (
-
+
Logo void; reset: () => void; -}>((set) => { +}>((set, get) => { const initData = { dump: null, activeTask: null, @@ -62,14 +63,15 @@ export const useExecutionDump = create<{ // set the first one as selected for (const item of dump) { if (item.executions.length > 0 && item.executions[0].tasks.length > 0) { - set({ activeTask: item.executions[0].tasks[0] }); + get().setActiveTask(item.executions[0].tasks[0]); break; } } }, setActiveTask(task: ExecutionTask) { set({ activeTask: task }); - if ((task as ExecutionTaskInsightLocate).log?.dump?.matchedElement) { + console.log('task set', task); + if (task.type === 'Insight') { syncToInsightDump((task as ExecutionTaskInsightLocate).log!.dump!); } else { resetInsightDump(); diff --git a/packages/visualizer/src/component/timeline.tsx b/packages/visualizer/src/component/timeline.tsx index 626f36861..0ebdb4c76 100644 --- a/packages/visualizer/src/component/timeline.tsx +++ b/packages/visualizer/src/component/timeline.tsx @@ -67,7 +67,7 @@ const TimelineWidget = (props: { const sizeRatio = 2; const titleBg = 0xdddddd; // @title-bg - const sideBg = 0xececec; + const sideBg = 0xf7f7f7; // @side-bg const gridTextColor = 0; const shotBorderColor = 0x777777; const gridLineColor = 0xcccccc; // @border-color diff --git a/packages/visualizer/src/index.less b/packages/visualizer/src/index.less index 10988249b..126b8007a 100644 --- a/packages/visualizer/src/index.less +++ b/packages/visualizer/src/index.less @@ -88,7 +88,7 @@ footer.mt-8{ .main-canvas-container { flex-grow: 1; height: 100%; - background: #F5F5F5; + background: #ffffff; overflow-x: hidden; overflow-y: scroll; border-left: 1px solid @border-color; diff --git a/packages/visualizer/src/index.tsx b/packages/visualizer/src/index.tsx index d8f356713..a1460ed68 100644 --- a/packages/visualizer/src/index.tsx +++ b/packages/visualizer/src/index.tsx @@ -13,7 +13,7 @@ import DetailSide from '@/component/detail-side'; import Sidebar from '@/component/sidebar'; const { Dragger } = Upload; -const Index = (): JSX.Element => { +const Index = (props: { hideLogo?: boolean }): JSX.Element => { const executionDump = useExecutionDump((store) => store.dump); const setGroupedDump = useExecutionDump((store) => store.setGroupedDump); const reset = useExecutionDump((store) => store.reset); @@ -148,8 +148,8 @@ const Index = (): JSX.Element => { } }} > - - + + { diff --git a/packages/web-integration/modern.inspect.config.ts b/packages/web-integration/modern.inspect.config.ts index aafec190f..2e56939b2 100644 --- a/packages/web-integration/modern.inspect.config.ts +++ b/packages/web-integration/modern.inspect.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ buildType: 'bundle', format: 'iife', input: { - htmlElement:'src/html-element/index.ts', + htmlElement:'src/extractor/index.ts', }, outDir: 'dist/script', esbuildOptions: options => { diff --git a/packages/web-integration/src/common/agent.ts b/packages/web-integration/src/common/agent.ts new file mode 100644 index 000000000..76d60e200 --- /dev/null +++ b/packages/web-integration/src/common/agent.ts @@ -0,0 +1,80 @@ +import { ExecutionDump, GroupedActionDump } from '@midscene/core'; +import { groupedActionDumpFileExt, writeDumpFile } from '@midscene/core/utils'; +import { PageTaskExecutor } from '../common/tasks'; +import { WebPage } from '@/common/page'; + +export class PageAgent { + page: WebPage; + + dumps: GroupedActionDump[]; + + constructor(page: WebPage) { + this.page = page; + this.dumps = []; + } + + appendDump(groupName: string, execution: ExecutionDump) { + let currentDump = this.dumps.find((dump) => dump.groupName === groupName); + if (!currentDump) { + currentDump = { + groupName, + executions: [], + }; + this.dumps.push(currentDump); + } + currentDump.executions.push(execution); + } + + writeOutActionDumps() { + writeDumpFile(`playwright-${process.pid}`, groupedActionDumpFileExt, JSON.stringify(this.dumps)); + } + + async aiAction(taskPrompt: string, dumpCaseName = 'AI Action', dumpGroupName = 'MidScene / Web') { + const actionAgent = new PageTaskExecutor(this.page, { taskName: dumpCaseName }); + let error: Error | undefined; + try { + await actionAgent.action(taskPrompt); + } catch (e: any) { + error = e; + } + if (actionAgent.executionDump) { + this.appendDump(dumpGroupName, actionAgent.executionDump); + this.writeOutActionDumps(); + } + if (error) { + // playwright cli won't print error cause, so we print it here + console.error(error); + throw new Error(error.message, { cause: error }); + } + } + + async aiQuery(demand: any, dumpCaseName = 'AI Query', dumpGroupName = 'MidScene / Web') { + const actionAgent = new PageTaskExecutor(this.page, { taskName: dumpCaseName }); + let error: Error | undefined; + let result: any; + try { + result = await actionAgent.query(demand); + } catch (e: any) { + error = e; + } + if (actionAgent.executionDump) { + this.appendDump(dumpGroupName, actionAgent.executionDump); + this.writeOutActionDumps(); + } + if (error) { + // playwright cli won't print error cause, so we print it here + console.error(error); + throw new Error(error.message, { cause: error }); + } + return result; + } + + async ai(taskPrompt: string, type = 'action', dumpCaseName = 'AI', dumpGroupName = 'MidScene / Web') { + if (type === 'action') { + return this.aiAction(taskPrompt, dumpCaseName, dumpGroupName); + } else if (type === 'query') { + return this.aiQuery(taskPrompt, dumpCaseName, dumpGroupName); + } + throw new Error(`Unknown or Unsupported task type: ${type}, only support 'action' or 'query'`); + } +} diff --git a/packages/web-integration/src/playwright/cdp.ts b/packages/web-integration/src/common/cdp.ts similarity index 100% rename from packages/web-integration/src/playwright/cdp.ts rename to packages/web-integration/src/common/cdp.ts diff --git a/packages/web-integration/src/common/page.d.ts b/packages/web-integration/src/common/page.d.ts new file mode 100644 index 000000000..442939ff4 --- /dev/null +++ b/packages/web-integration/src/common/page.d.ts @@ -0,0 +1,5 @@ +import type { Page as PlaywrightPage } from 'playwright'; +import type { Page as PuppeteerPage, KeyInput } from 'puppeteer'; + +export type WebPage = PlaywrightPage | PuppeteerPage; +export type WebKeyInput = KeyInput; diff --git a/packages/web-integration/src/playwright/actions.ts b/packages/web-integration/src/common/tasks.ts similarity index 83% rename from packages/web-integration/src/playwright/actions.ts rename to packages/web-integration/src/common/tasks.ts index fe4d7e543..0c266977b 100644 --- a/packages/web-integration/src/playwright/actions.ts +++ b/packages/web-integration/src/common/tasks.ts @@ -1,5 +1,4 @@ import assert from 'assert'; -import type { Page as PlaywrightPage } from 'playwright'; import Insight, { DumpSubscriber, ExecutionDump, @@ -21,24 +20,26 @@ import Insight, { } from '@midscene/core'; import { commonScreenshotParam, getTmpFile, sleep } from '@midscene/core/utils'; import { base64Encoded } from '@midscene/core/image'; -import { parseContextFromPlaywrightPage } from './utils'; -import { WebElementInfo } from './element'; +import type { KeyInput, Page as PuppeteerPage } from 'puppeteer'; +import { WebElementInfo } from '../web-element'; +import { parseContextFromWebPage } from './utils'; +import { WebPage } from '@/common/page'; -export class PlayWrightActionAgent { - page: PlaywrightPage; +export class PageTaskExecutor { + page: WebPage; insight: Insight; - executor: Executor; + taskExecutor: Executor; - actionDump?: ExecutionDump; + executionDump?: ExecutionDump; - constructor(page: PlaywrightPage, opt?: { taskName?: string }) { + constructor(page: WebPage, opt?: { taskName?: string }) { this.page = page; this.insight = new Insight(async () => { - return await parseContextFromPlaywrightPage(page); + return await parseContextFromWebPage(page); }); - this.executor = new Executor(opt?.taskName || 'MidScene - PlayWrightAI'); + this.taskExecutor = new Executor(opt?.taskName || 'MidScene - PlayWrightAI'); } private async recordScreenshot(timing: ExecutionRecorderItem['timing']) { @@ -117,7 +118,6 @@ export class PlayWrightActionAgent { await this.page.keyboard.type(taskParam.value); }, }; - // TODO: return a recorder Object return taskActionInput; } else if (plan.type === 'KeyboardPress') { const taskActionKeyboardPress: ExecutionTaskActionApply = { @@ -126,7 +126,7 @@ export class PlayWrightActionAgent { param: plan.param, executor: async (taskParam) => { assert(taskParam.value, 'No key to press'); - await this.page.keyboard.press(taskParam.value); + await this.page.keyboard.press(taskParam.value as KeyInput); }, }; return taskActionKeyboardPress; @@ -158,7 +158,7 @@ export class PlayWrightActionAgent { param: plan.param, executor: async (taskParam) => { const scrollToEventName = taskParam.scrollType; - const innerHeight = await this.page.evaluate(() => window.innerHeight); + const innerHeight = await (this.page as PuppeteerPage).evaluate(() => window.innerHeight); switch (scrollToEventName) { case 'ScrollUntilTop': @@ -193,7 +193,7 @@ export class PlayWrightActionAgent { } async action(userPrompt: string /* , actionInfo?: { actionType?: EventActions[number]['action'] } */) { - this.executor.description = userPrompt; + this.taskExecutor.description = userPrompt; const pageContext = await this.insight.contextRetrieverFn(); let plans: PlanningAction[] = []; @@ -215,32 +215,32 @@ export class PlayWrightActionAgent { try { // plan - await this.executor.append(this.wrapExecutorWithScreenshot(planningTask)); - await this.executor.flush(); - this.actionDump = this.executor.dump(); + await this.taskExecutor.append(this.wrapExecutorWithScreenshot(planningTask)); + await this.taskExecutor.flush(); + this.executionDump = this.taskExecutor.dump(); // append tasks const executables = await this.convertPlanToExecutable(plans); - await this.executor.append(executables); + await this.taskExecutor.append(executables); // flush actions - await this.executor.flush(); - this.actionDump = this.executor.dump(); + await this.taskExecutor.flush(); + this.executionDump = this.taskExecutor.dump(); assert( - this.executor.status !== 'error', - `failed to execute tasks: ${this.executor.status}, msg: ${this.executor.errorMsg || ''}`, + this.taskExecutor.status !== 'error', + `failed to execute tasks: ${this.taskExecutor.status}, msg: ${this.taskExecutor.errorMsg || ''}`, ); } catch (e: any) { // keep the dump before throwing - this.actionDump = this.executor.dump(); + this.executionDump = this.taskExecutor.dump(); const err = new Error(e.message, { cause: e }); throw err; } } async query(demand: InsightExtractParam) { - this.executor.description = JSON.stringify(demand); + this.taskExecutor.description = JSON.stringify(demand); let data: any; const queryTask: ExecutionTaskInsightQueryApply = { type: 'Insight', @@ -262,12 +262,12 @@ export class PlayWrightActionAgent { }, }; try { - await this.executor.append(this.wrapExecutorWithScreenshot(queryTask)); - await this.executor.flush(); - this.actionDump = this.executor.dump(); + await this.taskExecutor.append(this.wrapExecutorWithScreenshot(queryTask)); + await this.taskExecutor.flush(); + this.executionDump = this.taskExecutor.dump(); } catch (e: any) { // keep the dump before throwing - this.actionDump = this.executor.dump(); + this.executionDump = this.taskExecutor.dump(); const err = new Error(e.message, { cause: e }); throw err; } diff --git a/packages/web-integration/src/playwright/utils.ts b/packages/web-integration/src/common/utils.ts similarity index 81% rename from packages/web-integration/src/playwright/utils.ts rename to packages/web-integration/src/common/utils.ts index 8f1d4cbd1..1c8a5e820 100644 --- a/packages/web-integration/src/playwright/utils.ts +++ b/packages/web-integration/src/common/utils.ts @@ -2,19 +2,19 @@ import fs, { readFileSync } from 'fs'; import assert from 'assert'; import { Buffer } from 'buffer'; import path from 'path'; -import type { Page as PlaywrightPage } from 'playwright'; -import { Page } from 'puppeteer'; import { UIContext, PlaywrightParserOpt } from '@midscene/core'; -import { alignCoordByTrim, base64Encoded, imageInfo, imageInfoOfBase64 } from '@midscene/core/image'; +import { alignCoordByTrim, base64Encoded, imageInfoOfBase64 } from '@midscene/core/image'; import { getTmpFile } from '@midscene/core/utils'; -import { WebElementInfo, WebElementInfoType } from './element'; +import { WebElementInfo, WebElementInfoType } from '../web-element'; +import { WebPage } from './page'; -export async function parseContextFromPlaywrightPage( - page: PlaywrightPage, +export async function parseContextFromWebPage( + page: WebPage, _opt?: PlaywrightParserOpt, ): Promise> { assert(page, 'page is required'); - const file = '/Users/bytedance/workspace/midscene/packages/midscene/tests/fixtures/heytea.jpeg'; // getTmpFile('jpeg'); + + const file = getTmpFile('jpeg'); await page.screenshot({ path: file, type: 'jpeg', quality: 75 }); const screenshotBuffer = readFileSync(file); const screenshotBase64 = base64Encoded(file); @@ -30,7 +30,7 @@ export async function parseContextFromPlaywrightPage( }; } -export async function getElementInfosFromPage(page: Page | PlaywrightPage) { +export async function getElementInfosFromPage(page: WebPage) { const pathDir = findNearestPackageJson(__dirname); assert(pathDir, `can't find pathDir, with ${__dirname}`); const scriptPath = path.join(pathDir, './dist/script/htmlElement.js'); @@ -44,7 +44,7 @@ export async function getElementInfosFromPage(page: Page | PlaywrightPage) { async function alignElements( screenshotBuffer: Buffer, elements: WebElementInfoType[], - page: PlaywrightPage, + page: WebPage, ): Promise { const textsAligned: WebElementInfo[] = []; for (const item of elements) { diff --git a/packages/web-integration/src/html-element/constants.ts b/packages/web-integration/src/extractor/constants.ts similarity index 100% rename from packages/web-integration/src/html-element/constants.ts rename to packages/web-integration/src/extractor/constants.ts diff --git a/packages/web-integration/src/html-element/debug.ts b/packages/web-integration/src/extractor/debug.ts similarity index 100% rename from packages/web-integration/src/html-element/debug.ts rename to packages/web-integration/src/extractor/debug.ts diff --git a/packages/web-integration/src/html-element/dom-util.ts b/packages/web-integration/src/extractor/dom-util.ts similarity index 100% rename from packages/web-integration/src/html-element/dom-util.ts rename to packages/web-integration/src/extractor/dom-util.ts diff --git a/packages/web-integration/src/html-element/extractInfo.ts b/packages/web-integration/src/extractor/extractor.ts similarity index 96% rename from packages/web-integration/src/html-element/extractInfo.ts rename to packages/web-integration/src/extractor/extractor.ts index 208cda4b1..5f5082a7d 100644 --- a/packages/web-integration/src/html-element/extractInfo.ts +++ b/packages/web-integration/src/extractor/extractor.ts @@ -11,7 +11,7 @@ import { isButtonElement, isImgElement, isInputElement } from './dom-util'; interface NodeDescriptor { node: Node; - childrens: NodeDescriptor[]; + children: NodeDescriptor[]; } export interface ElementInfo { @@ -39,7 +39,7 @@ function generateId(numberId: number) { export function extractTextWithPositionDFS(initNode: Node = container): ElementInfo[] { const elementInfoArray: ElementInfo[] = []; - const nodeMapTree: NodeDescriptor = { node: initNode, childrens: [] }; + const nodeMapTree: NodeDescriptor = { node: initNode, children: [] }; let nodeIndex = 1; function dfs(node: Node, parentNode: NodeDescriptor | null = null): void { @@ -47,9 +47,9 @@ export function extractTextWithPositionDFS(initNode: Node = container): ElementI return; } - const currentNodeDes: NodeDescriptor = { node, childrens: [] }; - if (parentNode?.childrens) { - parentNode.childrens.push(currentNodeDes); + const currentNodeDes: NodeDescriptor = { node, children: [] }; + if (parentNode?.children) { + parentNode.children.push(currentNodeDes); } collectElementInfo(node); diff --git a/packages/web-integration/src/extractor/index.ts b/packages/web-integration/src/extractor/index.ts new file mode 100644 index 000000000..8d5a8a219 --- /dev/null +++ b/packages/web-integration/src/extractor/index.ts @@ -0,0 +1 @@ +export { extractTextWithPositionDFS } from './extractor'; diff --git a/packages/web-integration/src/html-element/util.ts b/packages/web-integration/src/extractor/util.ts similarity index 100% rename from packages/web-integration/src/html-element/util.ts rename to packages/web-integration/src/extractor/util.ts diff --git a/packages/web-integration/src/html-element/index.ts b/packages/web-integration/src/html-element/index.ts deleted file mode 100644 index a9a124c14..000000000 --- a/packages/web-integration/src/html-element/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { extractTextWithPositionDFS } from './extractInfo'; diff --git a/packages/web-integration/src/img/img.ts b/packages/web-integration/src/img/img.ts index 8b274a348..f53ba5c14 100644 --- a/packages/web-integration/src/img/img.ts +++ b/packages/web-integration/src/img/img.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { Buffer } from 'node:buffer'; import sharp from 'sharp'; -import { NodeType } from '@/html-element/constants'; +import { NodeType } from '@/extractor/constants'; // Define picture path type ElementType = { @@ -80,8 +80,8 @@ const createSvgOverlay = (elements: Array, imageWidth: number, imag export const processImageElementInfo = async (options: { inputImgBase64: string; - elementsPostionInfo: Array; - elementsPostionInfoWithoutText: Array; + elementsPositionInfo: Array; + elementsPositionInfoWithoutText: Array; }) => { // Get the size of the original image const base64Image = options.inputImgBase64.split(';base64,').pop(); @@ -93,8 +93,8 @@ export const processImageElementInfo = async (options: { if (width && height) { // Create svg overlay - const svgOverlay = createSvgOverlay(options.elementsPostionInfo, width, height); - const svgOverlayWithoutText = createSvgOverlay(options.elementsPostionInfoWithoutText, width, height); + const svgOverlay = createSvgOverlay(options.elementsPositionInfo, width, height); + const svgOverlayWithoutText = createSvgOverlay(options.elementsPositionInfoWithoutText, width, height); // Composite picture const compositeElementInfoImgBase64 = await sharp(imageBuffer) @@ -109,7 +109,7 @@ export const processImageElementInfo = async (options: { throw err; }); - // Composite picture withtoutText + // Composite picture withoutText const compositeElementInfoImgWithoutTextBase64 = await sharp(imageBuffer) // .resize(newDimensions.width, newDimensions.height) .composite([{ input: svgOverlayWithoutText, blend: 'over' }]) diff --git a/packages/web-integration/src/img/util.ts b/packages/web-integration/src/img/util.ts index 6082e8b4a..432773140 100644 --- a/packages/web-integration/src/img/util.ts +++ b/packages/web-integration/src/img/util.ts @@ -1,10 +1,10 @@ -import { getElementInfosFromPage } from '../playwright/utils'; -import { NodeType } from '@/html-element/constants'; -import { ElementInfo } from '@/html-element/extractInfo'; +import { getElementInfosFromPage } from '../common/utils'; +import { NodeType } from '@/extractor/constants'; +import { ElementInfo } from '@/extractor/extractor'; export async function getElementInfos(page: any) { const captureElementSnapshot: Array = await getElementInfosFromPage(page); - const elementsPostionInfo = captureElementSnapshot.map((elementInfo) => { + const elementsPositionInfo = captureElementSnapshot.map((elementInfo) => { return { label: elementInfo.id.toString(), x: elementInfo.rect.left, @@ -14,15 +14,15 @@ export async function getElementInfos(page: any) { attributes: elementInfo.attributes, }; }); - const elementsPostionInfoWithoutText = elementsPostionInfo.filter((elementInfo) => { + const elementsPositionInfoWithoutText = elementsPositionInfo.filter((elementInfo) => { if (elementInfo.attributes.nodeType === NodeType.TEXT) { return false; } return true; }); return { - elementsPostionInfo, + elementsPositionInfo, captureElementSnapshot, - elementsPostionInfoWithoutText, + elementsPositionInfoWithoutText, }; } diff --git a/packages/web-integration/src/index.ts b/packages/web-integration/src/index.ts index e2d10b9b6..508505357 100644 --- a/packages/web-integration/src/index.ts +++ b/packages/web-integration/src/index.ts @@ -1,2 +1,4 @@ export { PlaywrightAiFixture } from './playwright'; export type { PlayWrightAiFixtureType } from './playwright'; + +export { PuppeteerAgent } from './puppeteer'; diff --git a/packages/web-integration/src/playwright/index.ts b/packages/web-integration/src/playwright/index.ts index 8938096df..4861fb545 100644 --- a/packages/web-integration/src/playwright/index.ts +++ b/packages/web-integration/src/playwright/index.ts @@ -1,113 +1,63 @@ +import { randomUUID } from 'crypto'; import { TestInfo, TestType } from '@playwright/test'; -import { ExecutionDump, GroupedActionDump } from '@midscene/core'; -import { groupedActionDumpFileExt, writeDumpFile } from '@midscene/core/utils'; -import { PlayWrightActionAgent } from './actions'; - -export { PlayWrightActionAgent } from './actions'; +import { PageTaskExecutor } from '../common/tasks'; +import { WebPage } from '@/common/page'; +import { PageAgent } from '@/common/agent'; export type APITestType = Pick, 'step'>; -export const PlaywrightAiFixture = () => { - const dumps: GroupedActionDump[] = []; - - const appendDump = (groupName: string, execution: ExecutionDump) => { - let currentDump = dumps.find((dump) => dump.groupName === groupName); - if (!currentDump) { - currentDump = { - groupName, - executions: [], - }; - dumps.push(currentDump); - } - currentDump.executions.push(execution); - }; - - const writeOutActionDumps = () => { - writeDumpFile(`playwright-${process.pid}`, groupedActionDumpFileExt, JSON.stringify(dumps)); - }; - - const groupAndCaseForTest = (testInfo: TestInfo) => { - let groupName: string; - let caseName: string; - const titlePath = [...testInfo.titlePath]; - - if (titlePath.length > 1) { - caseName = titlePath.pop()!; - groupName = titlePath.join(' > '); - } else if (titlePath.length === 1) { - caseName = titlePath[0]; - groupName = caseName; - } else { - caseName = 'unnamed'; - groupName = 'unnamed'; - } - return { groupName, caseName }; - }; - - const aiAction = async (page: any, testInfo: TestInfo, taskPrompt: string) => { - const { groupName, caseName } = groupAndCaseForTest(testInfo); - - const actionAgent = new PlayWrightActionAgent(page, { taskName: caseName }); - let error: Error | undefined; - try { - await actionAgent.action(taskPrompt); - } catch (e: any) { - error = e; - } - if (actionAgent.actionDump) { - appendDump(groupName, actionAgent.actionDump); - writeOutActionDumps(); - } - if (error) { - // playwright cli won't print error cause, so we print it here - console.error(error); - throw new Error(error.message, { cause: error }); - } - }; +const groupAndCaseForTest = (testInfo: TestInfo) => { + let groupName: string; + let caseName: string; + const titlePath = [...testInfo.titlePath]; - const aiQuery = async (page: any, testInfo: TestInfo, demand: any) => { - const { groupName, caseName } = groupAndCaseForTest(testInfo); + if (titlePath.length > 1) { + caseName = titlePath.pop()!; + groupName = titlePath.join(' > '); + } else if (titlePath.length === 1) { + caseName = titlePath[0]; + groupName = caseName; + } else { + caseName = 'unnamed'; + groupName = 'unnamed'; + } + return { groupName, caseName }; +}; - const actionAgent = new PlayWrightActionAgent(page, { taskName: caseName }); - let error: Error | undefined; - let result: any; - try { - result = await actionAgent.query(demand); - } catch (e: any) { - error = e; - } - if (actionAgent.actionDump) { - appendDump(groupName, actionAgent.actionDump); - writeOutActionDumps(); - } - if (error) { - // playwright cli won't print error cause, so we print it here - console.error(error); - throw new Error(error.message, { cause: error }); +const midSceneAgentKeyId = '_midSceneAgentId'; +export const PlaywrightAiFixture = () => { + const pageAgentMap: Record = {}; + const agentForPage = (page: WebPage) => { + let idForPage = (page as any)[midSceneAgentKeyId]; + if (!idForPage) { + idForPage = randomUUID(); + (page as any)[midSceneAgentKeyId] = idForPage; + pageAgentMap[idForPage] = new PageAgent(page); } - return result; + return pageAgentMap[idForPage]; }; return { - // shortcut ai: async ({ page }: any, use: any, testInfo: TestInfo) => { await use(async (taskPrompt: string, type = 'action') => { - if (type === 'action') { - return aiAction(page, testInfo, taskPrompt); - } else if (type === 'query') { - return aiQuery(page, testInfo, taskPrompt); - } - throw new Error(`Unknown or Unsupported task type: ${type}, only support 'action' or 'query'`); + const { groupName, caseName } = groupAndCaseForTest(testInfo); + const agent = agentForPage(page); + return agent.ai(taskPrompt, type, caseName, groupName); }); }, aiAction: async ({ page }: any, use: any, testInfo: TestInfo) => { await use(async (taskPrompt: string) => { - await aiAction(page, testInfo, taskPrompt); + const agent = agentForPage(page); + + const { groupName, caseName } = groupAndCaseForTest(testInfo); + await agent.aiAction(taskPrompt, caseName, groupName); }); }, aiQuery: async ({ page }: any, use: any, testInfo: TestInfo) => { await use(async function (demand: any) { - return aiQuery(page, testInfo, demand); + const agent = agentForPage(page); + const { groupName, caseName } = groupAndCaseForTest(testInfo); + return agent.aiQuery(demand, caseName, groupName); }); }, }; @@ -115,6 +65,6 @@ export const PlaywrightAiFixture = () => { export type PlayWrightAiFixtureType = { ai: (prompt: string, type?: 'action' | 'query') => Promise; - aiAction: (taskPrompt: string) => ReturnType; + aiAction: (taskPrompt: string) => ReturnType; aiQuery: (demand: any) => Promise; }; diff --git a/packages/web-integration/src/puppeteer/element.ts b/packages/web-integration/src/puppeteer/element.ts deleted file mode 100644 index 5bd470f15..000000000 --- a/packages/web-integration/src/puppeteer/element.ts +++ /dev/null @@ -1,49 +0,0 @@ -// import { Page } from 'puppeteer'; -// import { BaseElement, Rect } from '@/types'; - -// export class Element implements BaseElement { -// id: string; - -// attributes: Record; - -// nodeType: string; - -// content: string; - -// locator: string; - -// rect: Rect; - -// center: [number, number]; - -// page: Page; - -// constructor(options: { -// id: string, attributes: Record, nodeType: string, content: string, rect: Rect, page: Page, locator: string -// }) { -// this.id = options.id; -// this.attributes = options.attributes; -// this.nodeType = options.nodeType; -// this.content = options.content; -// this.rect = options.rect; -// this.center = [Math.floor(options.rect.left + options.rect.width / 2), Math.floor(options.rect.top + options.rect.height / 2)]; -// this.page = options.page; -// this.locator = options.locator; -// } - -// async tap() { -// await this.page.mouse.click(this.center[0], this.center[1]); -// } - -// async hover() { -// console.log('hover'); -// } - -// async type(text: string) { -// await this.page.keyboard.type(text, { delay: 100 }); -// } - -// async press(key: string) { -// await this.page.keyboard.press(key as any, { delay: 100 }); -// } -// } diff --git a/packages/web-integration/src/puppeteer/index.ts b/packages/web-integration/src/puppeteer/index.ts index 3cb866c85..ca0787bff 100644 --- a/packages/web-integration/src/puppeteer/index.ts +++ b/packages/web-integration/src/puppeteer/index.ts @@ -1,6 +1 @@ -// export { Element } from './element'; -// export { -// parseContextFromPuppeteerBrowser, -// parseContextFromPuppeteerPage, -// parseContextFromPlaywrightPage, -// } from './utils'; +export { PageAgent as PuppeteerAgent } from '@/common/agent'; diff --git a/packages/web-integration/src/puppeteer/utils.ts b/packages/web-integration/src/puppeteer/utils.ts deleted file mode 100644 index ae1623307..000000000 --- a/packages/web-integration/src/puppeteer/utils.ts +++ /dev/null @@ -1,116 +0,0 @@ -// import { readFileSync } from 'fs'; -// import { Buffer } from 'buffer'; -// import assert from 'assert'; -// import type { Browser, Page } from 'puppeteer'; -// import type { Page as PlaywrightPage } from 'playwright'; -// import { Element } from './index'; -// import { alignCoordByTrim, base64Encoded, imageInfoOfBase64 } from '@/image'; -// import { UIContext, PuppeteerParserOpt, PlaywrightParserOpt, Rect, BaseElement } from '@/types'; -// import { getTmpFile } from '@/utils'; -// import { pageScriptToGetTexts } from '@/query'; -// import { describeUserPage } from '@/insight/prompt'; - -// export interface TextElement { -// content: string; -// rect: Rect; -// center: [number, number]; // center coordinates as [rect.left + rect.width/2, rect.top + rect.height/2], use this for better control of page -// locator: string; -// } - -// export async function alignTextElements( -// screenshotBuffer: Buffer, -// elements: TextElement[], -// ): Promise { -// const textsAligned: TextElement[] = []; -// for (const item of elements) { -// const { rect } = item; -// const aligned = await alignCoordByTrim(screenshotBuffer, rect); -// item.rect = aligned; -// item.center = [ -// Math.round(aligned.left + aligned.width / 2), -// Math.round(aligned.top + aligned.height / 2), -// ]; -// textsAligned.push(item); -// } -// return textsAligned; -// } - -// async function extractDataFromPage(page: Page, opt?: PuppeteerParserOpt): Promise> { -// assert(page, 'page is required'); -// const file = getTmpFile('jpeg'); -// await page.screenshot({ path: file, type: 'jpeg', quality: 75 }); -// const screenshotBuffer = readFileSync(file); -// const screenshotBase64 = base64Encoded(file); -// const size = await imageInfoOfBase64(screenshotBase64); - -// const scripts = pageScriptToGetTexts(opt?.selector); -// const texts = (await page.evaluate(scripts)) as BaseElement[]; - -// // align texts -// const textsAligned = await alignTextElements(screenshotBuffer, texts); - -// const baseElements = textsAligned.map((item) => { -// const { center, ...res } = item; -// return new Element(res); -// }); - -// const basicContext = { -// screenshotBase64, -// size, -// content: baseElements, -// }; - -// return { -// ...basicContext, -// describer: async () => { -// return describeUserPage(basicContext); -// }, -// }; -// } - -// export async function parseContextFromPuppeteerPage( -// page: Page, -// opt?: PuppeteerParserOpt, -// ): Promise> { -// return extractDataFromPage(page, opt); -// } - -// export async function parseContextFromPuppeteerBrowser(browser: Browser): Promise> { -// const pages = await browser.pages(); -// let visiblePage: Page; -// if (!pages.length) { -// throw new Error('No page found in the puppeteer browser'); -// } else if (pages.length === 1) { -// visiblePage = pages[0]; - -// // filter a visible page, otherwise use the last one -// } else { -// const candidates = []; -// for (const page of pages) { -// // eslint-disable-next-line @typescript-eslint/no-loop-func -// const isVisible = await page.evaluate(() => document.visibilityState === 'visible'); -// if (isVisible) { -// candidates.push(page); -// } -// } -// if (candidates.length === 0) { -// const lastUrl = pages[pages.length - 1].url(); -// console.warn(`There are no visible pages, use the last one (${lastUrl})`); -// visiblePage = candidates[candidates.length - 1]; -// } else if (candidates.length === 1) { -// visiblePage = candidates[0]; -// } else { -// const lastUrl = pages[pages.length - 1].url(); -// console.warn(`Multiple visible pages found, use the last one (${lastUrl})`); -// visiblePage = candidates[candidates.length - 1]; -// } -// } -// return parseContextFromPuppeteerPage(visiblePage); -// } - -// export async function parseContextFromPlaywrightPage( -// page: PlaywrightPage, -// opt?: PlaywrightParserOpt, -// ): Promise> { -// return extractDataFromPage(page as any as Page, opt); // seems key APIs are the same ? -// } diff --git a/packages/web-integration/src/playwright/element.ts b/packages/web-integration/src/web-element.ts similarity index 66% rename from packages/web-integration/src/playwright/element.ts rename to packages/web-integration/src/web-element.ts index 5ffd6c55f..2f4e3807c 100644 --- a/packages/web-integration/src/playwright/element.ts +++ b/packages/web-integration/src/web-element.ts @@ -1,6 +1,6 @@ -import { Page } from 'playwright'; import { BaseElement, Rect } from '@midscene/core'; -import { NodeType } from '../html-element/constants'; +import { NodeType } from './extractor/constants'; +import { WebPage } from './common/page'; export interface WebElementInfoType extends BaseElement { id: string; @@ -20,7 +20,7 @@ export class WebElementInfo implements BaseElement { center: [number, number]; - page: Page; + page: WebPage; id: string; @@ -39,7 +39,7 @@ export class WebElementInfo implements BaseElement { }: { content: string; rect: Rect; - page: Page; + page: WebPage; locator: string; id: string; attributes: { @@ -55,20 +55,4 @@ export class WebElementInfo implements BaseElement { this.id = id; this.attributes = attributes; } - - async tap() { - await this.page.mouse.click(this.center[0], this.center[1]); - } - - async hover() { - await this.page.mouse.move(this.center[0], this.center[1]); - } - - async type(text: string) { - await this.page.keyboard.type(text); - } - - async press(key: Parameters[0]) { - await this.page.keyboard.press(key); - } } diff --git a/packages/web-integration/tests/e2e/ai-xicha.spec.ts b/packages/web-integration/tests/e2e/ai-xicha.spec.ts index e094f0ddf..a855cec2e 100644 --- a/packages/web-integration/tests/e2e/ai-xicha.spec.ts +++ b/packages/web-integration/tests/e2e/ai-xicha.spec.ts @@ -1,3 +1,4 @@ +import { expect } from 'playwright/test'; import { test } from './fixture'; test.beforeEach(async ({ page }) => { @@ -6,12 +7,12 @@ test.beforeEach(async ({ page }) => { await page.waitForLoadState('networkidle'); }); -test('ai order', async ({ ai }) => { +test('ai order', async ({ ai, aiQuery }) => { await ai('点击左上角语言切换按钮(英文、中文),在弹出的下拉列表中点击中文'); await ai('在向下滚动一屏'); await ai('直接点击多肉葡萄的规格按钮'); await ai('点击不使用吸管、点击冰沙推荐、点击正常冰推荐'); - await ai('在向下滚动一屏'); + await ai('向下滚动一屏'); await ai('点击标准甜、点击绿妍(推荐)、点击标准口味'); await ai('滚动到最下面'); await ai('点击选好了按钮'); @@ -20,6 +21,14 @@ test('ai order', async ({ ai }) => { // 随便滚动一下 await ai('滚动到最下面'); + const cardDetail = await aiQuery({ + productName: '商品名称,在价格上面', + productPrice: '商品价格, string', + productDescription: '商品描述(饮品的各种参数,吸管、冰沙等),在价格下面', + }); + + expect(cardDetail.productName.indexOf('多肉葡萄')).toBeGreaterThanOrEqual(0); + // const content = await aiQuery(query('购物车商品详情', { // productName: "商品名称,在价格上面", // productPrice: "商品价格", diff --git a/packages/web-integration/tests/e2e/tool.ts b/packages/web-integration/tests/e2e/tool.ts index 4fa2af32a..74582b188 100644 --- a/packages/web-integration/tests/e2e/tool.ts +++ b/packages/web-integration/tests/e2e/tool.ts @@ -6,7 +6,7 @@ import { getElementInfos } from '@/img/util'; import { processImageElementInfo } from '@/img/img'; export async function generateTestData(page: PlaywrightPage, targetDir: string, inputImgBase64: string) { - const { elementsPostionInfo, captureElementSnapshot, elementsPostionInfoWithoutText } = + const { elementsPositionInfo, captureElementSnapshot, elementsPositionInfoWithoutText } = await getElementInfos(page); const inputImagePath = path.join(targetDir, 'input.png'); @@ -17,8 +17,8 @@ export async function generateTestData(page: PlaywrightPage, targetDir: string, const { compositeElementInfoImgBase64, compositeElementInfoImgWithoutTextBase64 } = await processImageElementInfo({ - elementsPostionInfo, - elementsPostionInfoWithoutText, + elementsPositionInfo, + elementsPositionInfoWithoutText, inputImgBase64, }); diff --git a/packages/web-integration/tests/puppeteer/bing.spec.ts b/packages/web-integration/tests/puppeteer/bing.spec.ts new file mode 100644 index 000000000..e4e4a168b --- /dev/null +++ b/packages/web-integration/tests/puppeteer/bing.spec.ts @@ -0,0 +1,23 @@ +import { it, describe, expect, vi } from 'vitest'; +import { sleep } from '@midscene/core/utils'; +import { launchPage } from './utils'; +import { PuppeteerAgent } from '@/puppeteer'; + +vi.setConfig({ + testTimeout: 60 * 1000, +}); + +describe('puppeteer integration', () => { + it('basic launch', async () => { + const page = await launchPage('https://www.bing.com'); + + const agent = new PuppeteerAgent(page); + + await agent.aiAction('type "how much is the ferry ticket in Shanghai" in search box, hit Enter'); + await sleep(5000); + + const relatedSearch = await agent.aiQuery('string[], related search keywords on the right'); + console.log('related search', relatedSearch); + expect(relatedSearch.length).toBeGreaterThan(3); + }); +}); diff --git a/packages/web-integration/tests/puppeteer/utils.ts b/packages/web-integration/tests/puppeteer/utils.ts new file mode 100644 index 000000000..a8a1c4513 --- /dev/null +++ b/packages/web-integration/tests/puppeteer/utils.ts @@ -0,0 +1,35 @@ +import assert from 'assert'; +import puppeteer, { Viewport } from 'puppeteer'; + +export async function launchPage( + url: string, + opt?: { + viewport?: Viewport; + }, +) { + const browser = await puppeteer.launch({ + headless: false, + }); + + const page = (await browser.pages())[0]; + const viewportConfig = { + width: opt?.viewport?.width || 1920, + height: opt?.viewport?.height || 1080, + deviceScaleFactor: opt?.viewport?.deviceScaleFactor || 1, + }; + await page.setViewport(viewportConfig); + await Promise.all([ + page.waitForNavigation({ + timeout: 20 * 1000, + waitUntil: 'networkidle0', + }), + (async () => { + const response = await page.goto(url); + if (response?.status) { + assert(response.status() <= 399, `Page load failed: ${response.status()}`); + } + })(), + ]); + + return page; +} diff --git a/packages/web-integration/vitest.config.ts b/packages/web-integration/vitest.config.ts index 6749bb413..71508d36e 100644 --- a/packages/web-integration/vitest.config.ts +++ b/packages/web-integration/vitest.config.ts @@ -8,7 +8,6 @@ export default defineConfig({ }, }, test: { - // 你的其他配置... - include: [], + include: ['./tests/puppeteer/**/*.spec.ts'], }, });