@midscene/web 0.19.1 → 0.20.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.
Files changed (73) hide show
  1. package/dist/es/agent.js +299 -247
  2. package/dist/es/agent.js.map +1 -1
  3. package/dist/es/bridge-mode-browser.js +3 -3
  4. package/dist/es/bridge-mode.js +301 -249
  5. package/dist/es/bridge-mode.js.map +1 -1
  6. package/dist/es/chrome-extension.js +342 -290
  7. package/dist/es/chrome-extension.js.map +1 -1
  8. package/dist/es/index.js +307 -247
  9. package/dist/es/index.js.map +1 -1
  10. package/dist/es/midscene-playground.js +341 -289
  11. package/dist/es/midscene-playground.js.map +1 -1
  12. package/dist/es/midscene-server.js +25 -12
  13. package/dist/es/midscene-server.js.map +1 -1
  14. package/dist/es/playground.js +341 -289
  15. package/dist/es/playground.js.map +1 -1
  16. package/dist/es/playwright-report.js +14 -1
  17. package/dist/es/playwright-report.js.map +1 -1
  18. package/dist/es/playwright-reporter.js +14 -1
  19. package/dist/es/playwright-reporter.js.map +1 -1
  20. package/dist/es/playwright.js +307 -247
  21. package/dist/es/playwright.js.map +1 -1
  22. package/dist/es/puppeteer-agent-launcher.js +299 -247
  23. package/dist/es/puppeteer-agent-launcher.js.map +1 -1
  24. package/dist/es/puppeteer.js +299 -247
  25. package/dist/es/puppeteer.js.map +1 -1
  26. package/dist/es/utils.js +42 -8
  27. package/dist/es/utils.js.map +1 -1
  28. package/dist/es/yaml.js +11 -4
  29. package/dist/es/yaml.js.map +1 -1
  30. package/dist/lib/agent.js +308 -256
  31. package/dist/lib/agent.js.map +1 -1
  32. package/dist/lib/bridge-mode-browser.js +3 -3
  33. package/dist/lib/bridge-mode.js +310 -258
  34. package/dist/lib/bridge-mode.js.map +1 -1
  35. package/dist/lib/chrome-extension.js +355 -303
  36. package/dist/lib/chrome-extension.js.map +1 -1
  37. package/dist/lib/index.js +316 -256
  38. package/dist/lib/index.js.map +1 -1
  39. package/dist/lib/midscene-playground.js +354 -302
  40. package/dist/lib/midscene-playground.js.map +1 -1
  41. package/dist/lib/midscene-server.js +28 -15
  42. package/dist/lib/midscene-server.js.map +1 -1
  43. package/dist/lib/playground.js +354 -302
  44. package/dist/lib/playground.js.map +1 -1
  45. package/dist/lib/playwright-report.js +20 -7
  46. package/dist/lib/playwright-report.js.map +1 -1
  47. package/dist/lib/playwright-reporter.js +20 -7
  48. package/dist/lib/playwright-reporter.js.map +1 -1
  49. package/dist/lib/playwright.js +316 -256
  50. package/dist/lib/playwright.js.map +1 -1
  51. package/dist/lib/puppeteer-agent-launcher.js +308 -256
  52. package/dist/lib/puppeteer-agent-launcher.js.map +1 -1
  53. package/dist/lib/puppeteer.js +308 -256
  54. package/dist/lib/puppeteer.js.map +1 -1
  55. package/dist/lib/utils.js +48 -13
  56. package/dist/lib/utils.js.map +1 -1
  57. package/dist/lib/yaml.js +11 -4
  58. package/dist/lib/yaml.js.map +1 -1
  59. package/dist/types/agent.d.ts +6 -102
  60. package/dist/types/bridge-mode-browser.d.ts +3 -2
  61. package/dist/types/bridge-mode.d.ts +4 -4
  62. package/dist/types/{browser-5dbb4bfb.d.ts → browser-118d886d.d.ts} +1 -1
  63. package/dist/types/chrome-extension.d.ts +2 -2
  64. package/dist/types/index.d.ts +1 -1
  65. package/dist/types/midscene-server.d.ts +2 -2
  66. package/dist/types/{page-90e9f9a7.d.ts → page-471361cd.d.ts} +102 -3
  67. package/dist/types/playground.d.ts +2 -2
  68. package/dist/types/playwright.d.ts +6 -2
  69. package/dist/types/puppeteer-agent-launcher.d.ts +1 -1
  70. package/dist/types/puppeteer.d.ts +3 -3
  71. package/dist/types/utils.d.ts +2 -1
  72. package/dist/types/yaml.d.ts +1 -1
  73. package/package.json +3 -3
@@ -8,8 +8,21 @@ import {
8
8
  traverseTree
9
9
  } from "@midscene/shared/extractor";
10
10
  import { resizeImgBase64 } from "@midscene/shared/img";
11
- import { assert, logMsg, uuid } from "@midscene/shared/utils";
11
+ import { assert as assert2, logMsg, uuid } from "@midscene/shared/utils";
12
12
  import dayjs from "dayjs";
13
+
14
+ // src/common/task-cache.ts
15
+ import assert from "assert";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
17
+ import { dirname, join } from "path";
18
+ import { getMidsceneRunSubDir } from "@midscene/shared/common";
19
+ import { getDebug } from "@midscene/shared/logger";
20
+ import { ifInBrowser } from "@midscene/shared/utils";
21
+ import yaml from "js-yaml";
22
+ import semver from "semver";
23
+ var debug = getDebug("cache");
24
+
25
+ // src/common/utils.ts
13
26
  function reportFileName(tag = "web") {
14
27
  const reportTagName = getAIConfig(MIDSCENE_REPORT_TAG_NAME);
15
28
  const dateTimeInFileName = dayjs().format("YYYY-MM-DD_HH-mm-ss");
@@ -1 +1 @@
1
- {"version":3,"mappings":"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","names":[],"ignoreList":[],"sources":["../../src/common/utils.ts","../../src/playwright/reporter/index.ts"],"sourcesContent":["import type { StaticPage } from '@/playground';\nimport type {\n BaseElement,\n ElementTreeNode,\n ExecutionDump,\n ExecutionTask,\n PlanningLocateParam,\n PlaywrightParserOpt,\n UIContext,\n} from '@midscene/core';\nimport { elementByPositionWithElementInfo } from '@midscene/core/ai-model';\nimport { uploadTestInfoToServer } from '@midscene/core/utils';\nimport { MIDSCENE_REPORT_TAG_NAME, getAIConfig } from '@midscene/shared/env';\nimport type { ElementInfo } from '@midscene/shared/extractor';\nimport {\n generateElementByPosition,\n getNodeFromCacheList,\n traverseTree,\n} from '@midscene/shared/extractor';\nimport { resizeImgBase64 } from '@midscene/shared/img';\nimport type { DebugFunction } from '@midscene/shared/logger';\nimport { assert, logMsg, uuid } from '@midscene/shared/utils';\nimport dayjs from 'dayjs';\nimport type { Page as PlaywrightPage } from 'playwright';\nimport type { Page as PuppeteerPage } from 'puppeteer';\nimport { WebElementInfo } from '../web-element';\nimport type { WebPage } from './page';\n\nexport type WebUIContext = UIContext<WebElementInfo> & {\n url: string;\n};\n\nexport async function parseContextFromWebPage(\n page: WebPage,\n _opt?: PlaywrightParserOpt,\n): Promise<WebUIContext> {\n assert(page, 'page is required');\n if ((page as StaticPage)._forceUsePageContext) {\n return await (page as any)._forceUsePageContext();\n }\n const url = await page.url();\n uploadTestInfoToServer({ testUrl: url });\n\n let screenshotBase64: string;\n let tree: ElementTreeNode<ElementInfo>;\n\n await Promise.all([\n page.screenshotBase64().then((base64) => {\n screenshotBase64 = base64;\n }),\n page.getElementsNodeTree().then(async (treeRoot) => {\n tree = treeRoot;\n }),\n ]);\n\n const webTree = traverseTree(tree!, (elementInfo) => {\n const { rect, id, content, attributes, indexId, isVisible } = elementInfo;\n return new WebElementInfo({\n rect,\n id,\n content,\n attributes,\n indexId,\n isVisible,\n });\n });\n\n assert(screenshotBase64!, 'screenshotBase64 is required');\n\n const size = await page.size();\n\n if (size.dpr && size.dpr > 1) {\n // console.time('resizeImgBase64');\n screenshotBase64 = await resizeImgBase64(screenshotBase64, {\n width: size.width,\n height: size.height,\n });\n // console.timeEnd('resizeImgBase64');\n }\n\n return {\n tree: webTree,\n size,\n screenshotBase64: screenshotBase64!,\n url,\n };\n}\n\nexport function reportFileName(tag = 'web') {\n const reportTagName = getAIConfig(MIDSCENE_REPORT_TAG_NAME);\n const dateTimeInFileName = dayjs().format('YYYY-MM-DD_HH-mm-ss');\n // ensure uniqueness at the same time\n const uniqueId = uuid().substring(0, 8);\n return `${reportTagName || tag}-${dateTimeInFileName}-${uniqueId}`;\n}\n\nexport function printReportMsg(filepath: string) {\n logMsg(`Midscene - report file updated: ${filepath}`);\n}\n\n/**\n * Get the current execution file name\n * @returns The name of the current execution file\n */\nexport function getCurrentExecutionFile(trace?: string): string | false {\n const error = new Error();\n const stackTrace = trace || error.stack;\n const pkgDir = process.cwd() || '';\n if (stackTrace) {\n const stackLines = stackTrace.split('\\n');\n for (const line of stackLines) {\n if (\n line.includes('.spec.') ||\n line.includes('.test.') ||\n line.includes('.ts') ||\n line.includes('.js')\n ) {\n const match = line.match(/(?:at\\s+)?(.*?\\.(?:spec|test)\\.[jt]s)/);\n if (match?.[1]) {\n const targetFileName = match[1]\n .replace(pkgDir, '')\n .trim()\n .replace('at ', '');\n return targetFileName;\n }\n }\n }\n }\n return false;\n}\n\nconst testFileIndex = new Map<string, number>();\n\nexport function generateCacheId(fileName?: string): string {\n let taskFile = fileName || getCurrentExecutionFile();\n if (!taskFile) {\n taskFile = uuid();\n console.warn(\n 'Midscene - using random UUID for cache id. Cache may be invalid.',\n );\n }\n\n if (testFileIndex.has(taskFile)) {\n const currentIndex = testFileIndex.get(taskFile);\n if (currentIndex !== undefined) {\n testFileIndex.set(taskFile, currentIndex + 1);\n }\n } else {\n testFileIndex.set(taskFile, 1);\n }\n return `${taskFile}-${testFileIndex.get(taskFile)}`;\n}\n\nexport const ERROR_CODE_NOT_IMPLEMENTED_AS_DESIGNED =\n 'NOT_IMPLEMENTED_AS_DESIGNED';\n\nexport function replaceIllegalPathCharsAndSpace(str: string) {\n // Only replace characters that are illegal in filenames, but preserve path separators\n return str.replace(/[:*?\"<>| ]/g, '-');\n}\n\nexport function forceClosePopup(\n page: PuppeteerPage | PlaywrightPage,\n debug: DebugFunction,\n) {\n page.on('popup', async (popup) => {\n if (!popup) {\n console.warn('got a popup event, but the popup is not ready yet, skip');\n return;\n }\n const url = await (popup as PuppeteerPage).url();\n console.log(`Popup opened: ${url}`);\n if (!(popup as PuppeteerPage).isClosed()) {\n try {\n await (popup as PuppeteerPage).close(); // Close the newly opened TAB\n } catch (error) {\n debug(`failed to close popup ${url}, error: ${error}`);\n }\n } else {\n debug(`popup is already closed, skip close ${url}`);\n }\n\n if (!page.isClosed()) {\n try {\n await page.goto(url);\n } catch (error) {\n debug(`failed to goto ${url}, error: ${error}`);\n }\n } else {\n debug(`page is already closed, skip goto ${url}`);\n }\n });\n}\n\nexport function matchElementFromPlan(\n planLocateParam: PlanningLocateParam,\n tree: ElementTreeNode<BaseElement>,\n) {\n if (!planLocateParam) {\n return undefined;\n }\n if (planLocateParam.id) {\n return getNodeFromCacheList(planLocateParam.id);\n }\n\n if (planLocateParam.bbox) {\n const centerPosition = {\n x: Math.floor((planLocateParam.bbox[0] + planLocateParam.bbox[2]) / 2),\n y: Math.floor((planLocateParam.bbox[1] + planLocateParam.bbox[3]) / 2),\n };\n let element = elementByPositionWithElementInfo(tree, centerPosition);\n\n if (!element) {\n element = generateElementByPosition(centerPosition) as BaseElement;\n }\n\n return element;\n }\n\n return undefined;\n}\n\nexport function trimContextByViewport(execution: ExecutionDump) {\n function filterVisibleTree(\n node: ElementTreeNode<BaseElement>,\n ): ElementTreeNode<BaseElement> | null {\n if (!node) return null;\n\n // recursively process all children\n const filteredChildren = Array.isArray(node.children)\n ? (node.children\n .map(filterVisibleTree)\n .filter((child) => child !== null) as ElementTreeNode<BaseElement>[])\n : [];\n\n // if the current node is visible, keep it and the filtered children\n if (node.node && node.node.isVisible === true) {\n return {\n ...node,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible, but has visible children, create an empty node to include these children\n if (filteredChildren.length > 0) {\n return {\n node: null,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible and has no visible children, return null\n return null;\n }\n\n return {\n ...execution,\n tasks: Array.isArray(execution.tasks)\n ? execution.tasks.map((task: ExecutionTask) => {\n const newTask = { ...task };\n if (task.pageContext?.tree) {\n newTask.pageContext = {\n ...task.pageContext,\n tree: filterVisibleTree(task.pageContext.tree) || {\n node: null,\n children: [],\n },\n };\n }\n return newTask;\n })\n : execution.tasks,\n };\n}\n","import {\n printReportMsg,\n replaceIllegalPathCharsAndSpace,\n reportFileName,\n} from '@/common/utils';\nimport type { ReportDumpWithAttributes } from '@midscene/core';\nimport { writeDumpReport } from '@midscene/core/utils';\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n Suite,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nfunction logger(...message: any[]) {\n if (process.env.DEBUG === 'true') {\n console.log('Midscene e2e report:', ...message);\n }\n}\n\nconst testDataList: Array<ReportDumpWithAttributes> = [];\nlet mergedFilename: string;\nconst testTitleToFilename: Map<string, string> = new Map();\n\nfunction getStableFilename(testTitle: string): string {\n if (!testTitleToFilename.has(testTitle)) {\n // use reportFileName to generate the base filename\n // only replace the illegal characters in the file system: /, \\, :, *, ?, \", <, >, |\n const baseTag = `playwright-${replaceIllegalPathCharsAndSpace(testTitle)}`;\n const generatedFilename = reportFileName(baseTag);\n testTitleToFilename.set(testTitle, generatedFilename);\n }\n return testTitleToFilename.get(testTitle)!;\n}\n\nfunction updateReport(mode: 'merged' | 'separate', testId?: string) {\n if (mode === 'separate') {\n // in separate mode, find the data for the corresponding testID and generate a separate report\n const testData = testDataList.find(\n (data) => data.attributes?.playwright_test_id === testId,\n );\n\n if (testData) {\n // use the stable filename\n const stableFilename = getStableFilename(\n testData.attributes?.playwright_test_title,\n );\n\n const reportPath = writeDumpReport(stableFilename, [testData]);\n reportPath && printReportMsg(reportPath);\n }\n } else if (mode === 'merged') {\n // in merged mode, write all test data into one file\n if (!mergedFilename) {\n mergedFilename = reportFileName('playwright-merged');\n }\n\n const reportPath = writeDumpReport(mergedFilename, testDataList);\n reportPath && printReportMsg(reportPath);\n } else {\n throw new Error(\n `Unknown reporter type in playwright config: ${mode}, only support 'merged' or 'separate'`,\n );\n }\n}\n\nfunction getMode(reporterType: string) {\n if (!reporterType) {\n return 'merged';\n }\n\n if (reporterType !== 'merged' && reporterType !== 'separate') {\n throw new Error(\n `Unknown reporter type in playwright config: ${reporterType}, only support 'merged' or 'separate'`,\n );\n }\n\n return reporterType;\n}\n\nclass MidsceneReporter implements Reporter {\n mode?: 'merged' | 'separate';\n\n async onBegin(config: FullConfig, suite: Suite) {\n const reporterType = config.reporter?.[1]?.[1]?.type;\n\n this.mode = getMode(reporterType);\n\n // const suites = suite.allTests();\n // logger(`Starting the run with ${suites.length} tests`);\n }\n\n onTestBegin(test: TestCase, _result: TestResult) {\n // logger(`Starting test ${test.title}`);\n }\n\n onTestEnd(test: TestCase, result: TestResult) {\n const dumpAnnotation = test.annotations.find((annotation) => {\n return annotation.type === 'MIDSCENE_DUMP_ANNOTATION';\n });\n if (!dumpAnnotation?.description) return;\n const retry = result.retry ? `(retry #${result.retry})` : '';\n const testId = `${test.id}${retry}`;\n const testData: ReportDumpWithAttributes = {\n dumpString: dumpAnnotation.description,\n attributes: {\n playwright_test_id: testId,\n playwright_test_title: `${test.title}${retry}`,\n playwright_test_status: result.status,\n playwright_test_duration: result.duration,\n },\n };\n\n testDataList.push(testData);\n\n updateReport(this.mode!, testId);\n\n test.annotations = test.annotations.filter(\n (annotation) => annotation.type !== 'MIDSCENE_DUMP_ANNOTATION',\n );\n }\n\n onEnd(result: FullResult) {\n updateReport(this.mode!);\n\n logger(`Finished the run: ${result.status}`);\n }\n}\n\nexport default MidsceneReporter;\n"]}
1
+ {"version":3,"mappings":"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","names":["assert"],"ignoreList":[],"sources":["../../src/common/utils.ts","../../src/common/task-cache.ts","../../src/playwright/reporter/index.ts"],"sourcesContent":["import type { StaticPage } from '@/playground';\nimport type {\n BaseElement,\n ElementTreeNode,\n ExecutionDump,\n ExecutionTask,\n PlanningLocateParam,\n PlaywrightParserOpt,\n UIContext,\n} from '@midscene/core';\nimport { elementByPositionWithElementInfo } from '@midscene/core/ai-model';\nimport { uploadTestInfoToServer } from '@midscene/core/utils';\nimport { MIDSCENE_REPORT_TAG_NAME, getAIConfig } from '@midscene/shared/env';\nimport type { ElementInfo } from '@midscene/shared/extractor';\nimport {\n generateElementByPosition,\n getNodeFromCacheList,\n traverseTree,\n} from '@midscene/shared/extractor';\nimport { resizeImgBase64 } from '@midscene/shared/img';\nimport type { DebugFunction } from '@midscene/shared/logger';\nimport { assert, logMsg, uuid } from '@midscene/shared/utils';\nimport dayjs from 'dayjs';\nimport type { Page as PlaywrightPage } from 'playwright';\nimport type { Page as PuppeteerPage } from 'puppeteer';\nimport { WebElementInfo } from '../web-element';\nimport type { WebPage } from './page';\nimport { debug as cacheDebug } from './task-cache';\nimport type { PageTaskExecutor } from './tasks';\n\nexport type WebUIContext = UIContext<WebElementInfo> & {\n url: string;\n};\n\nexport async function parseContextFromWebPage(\n page: WebPage,\n _opt?: PlaywrightParserOpt,\n): Promise<WebUIContext> {\n assert(page, 'page is required');\n if ((page as StaticPage)._forceUsePageContext) {\n return await (page as any)._forceUsePageContext();\n }\n const url = await page.url();\n uploadTestInfoToServer({ testUrl: url });\n\n let screenshotBase64: string;\n let tree: ElementTreeNode<ElementInfo>;\n\n await Promise.all([\n page.screenshotBase64().then((base64) => {\n screenshotBase64 = base64;\n }),\n page.getElementsNodeTree().then(async (treeRoot) => {\n tree = treeRoot;\n }),\n ]);\n\n const webTree = traverseTree(tree!, (elementInfo) => {\n const { rect, id, content, attributes, indexId, isVisible } = elementInfo;\n return new WebElementInfo({\n rect,\n id,\n content,\n attributes,\n indexId,\n isVisible,\n });\n });\n\n assert(screenshotBase64!, 'screenshotBase64 is required');\n\n const size = await page.size();\n\n if (size.dpr && size.dpr > 1) {\n // console.time('resizeImgBase64');\n screenshotBase64 = await resizeImgBase64(screenshotBase64, {\n width: size.width,\n height: size.height,\n });\n // console.timeEnd('resizeImgBase64');\n }\n\n return {\n tree: webTree,\n size,\n screenshotBase64: screenshotBase64!,\n url,\n };\n}\n\nexport function reportFileName(tag = 'web') {\n const reportTagName = getAIConfig(MIDSCENE_REPORT_TAG_NAME);\n const dateTimeInFileName = dayjs().format('YYYY-MM-DD_HH-mm-ss');\n // ensure uniqueness at the same time\n const uniqueId = uuid().substring(0, 8);\n return `${reportTagName || tag}-${dateTimeInFileName}-${uniqueId}`;\n}\n\nexport function printReportMsg(filepath: string) {\n logMsg(`Midscene - report file updated: ${filepath}`);\n}\n\n/**\n * Get the current execution file name\n * @returns The name of the current execution file\n */\nexport function getCurrentExecutionFile(trace?: string): string | false {\n const error = new Error();\n const stackTrace = trace || error.stack;\n const pkgDir = process.cwd() || '';\n if (stackTrace) {\n const stackLines = stackTrace.split('\\n');\n for (const line of stackLines) {\n if (\n line.includes('.spec.') ||\n line.includes('.test.') ||\n line.includes('.ts') ||\n line.includes('.js')\n ) {\n const match = line.match(/(?:at\\s+)?(.*?\\.(?:spec|test)\\.[jt]s)/);\n if (match?.[1]) {\n const targetFileName = match[1]\n .replace(pkgDir, '')\n .trim()\n .replace('at ', '');\n return targetFileName;\n }\n }\n }\n }\n return false;\n}\n\nconst testFileIndex = new Map<string, number>();\n\nexport function generateCacheId(fileName?: string): string {\n let taskFile = fileName || getCurrentExecutionFile();\n if (!taskFile) {\n taskFile = uuid();\n console.warn(\n 'Midscene - using random UUID for cache id. Cache may be invalid.',\n );\n }\n\n if (testFileIndex.has(taskFile)) {\n const currentIndex = testFileIndex.get(taskFile);\n if (currentIndex !== undefined) {\n testFileIndex.set(taskFile, currentIndex + 1);\n }\n } else {\n testFileIndex.set(taskFile, 1);\n }\n return `${taskFile}-${testFileIndex.get(taskFile)}`;\n}\n\nexport const ERROR_CODE_NOT_IMPLEMENTED_AS_DESIGNED =\n 'NOT_IMPLEMENTED_AS_DESIGNED';\n\nexport function replaceIllegalPathCharsAndSpace(str: string) {\n // Only replace characters that are illegal in filenames, but preserve path separators\n return str.replace(/[:*?\"<>| ]/g, '-');\n}\n\nexport function forceClosePopup(\n page: PuppeteerPage | PlaywrightPage,\n debug: DebugFunction,\n) {\n page.on('popup', async (popup) => {\n if (!popup) {\n console.warn('got a popup event, but the popup is not ready yet, skip');\n return;\n }\n const url = await (popup as PuppeteerPage).url();\n console.log(`Popup opened: ${url}`);\n if (!(popup as PuppeteerPage).isClosed()) {\n try {\n await (popup as PuppeteerPage).close(); // Close the newly opened TAB\n } catch (error) {\n debug(`failed to close popup ${url}, error: ${error}`);\n }\n } else {\n debug(`popup is already closed, skip close ${url}`);\n }\n\n if (!page.isClosed()) {\n try {\n await page.goto(url);\n } catch (error) {\n debug(`failed to goto ${url}, error: ${error}`);\n }\n } else {\n debug(`page is already closed, skip goto ${url}`);\n }\n });\n}\n\nexport function matchElementFromPlan(\n planLocateParam: PlanningLocateParam,\n tree: ElementTreeNode<BaseElement>,\n) {\n if (!planLocateParam) {\n return undefined;\n }\n if (planLocateParam.id) {\n return getNodeFromCacheList(planLocateParam.id);\n }\n\n if (planLocateParam.bbox) {\n const centerPosition = {\n x: Math.floor((planLocateParam.bbox[0] + planLocateParam.bbox[2]) / 2),\n y: Math.floor((planLocateParam.bbox[1] + planLocateParam.bbox[3]) / 2),\n };\n let element = elementByPositionWithElementInfo(tree, centerPosition);\n\n if (!element) {\n element = generateElementByPosition(centerPosition) as BaseElement;\n }\n\n return element;\n }\n\n return undefined;\n}\n\nexport async function matchElementFromCache(\n taskExecutor: PageTaskExecutor,\n xpaths: string[] | undefined,\n cachePrompt: string,\n cacheable: boolean | undefined,\n) {\n try {\n if (\n xpaths?.length &&\n taskExecutor.taskCache?.isCacheResultUsed &&\n cacheable !== false\n ) {\n // hit cache, use new id\n for (let i = 0; i < xpaths.length; i++) {\n const element = await taskExecutor.page.getElementInfoByXpath(\n xpaths[i],\n );\n\n if (element?.id) {\n cacheDebug('cache hit, prompt: %s', cachePrompt);\n cacheDebug(\n 'found a new new element with same xpath, xpath: %s, id: %s',\n xpaths[i],\n element?.id,\n );\n return element;\n }\n }\n }\n } catch (error) {\n cacheDebug('get element info by xpath error: ', error);\n }\n}\n\nexport function trimContextByViewport(execution: ExecutionDump) {\n function filterVisibleTree(\n node: ElementTreeNode<BaseElement>,\n ): ElementTreeNode<BaseElement> | null {\n if (!node) return null;\n\n // recursively process all children\n const filteredChildren = Array.isArray(node.children)\n ? (node.children\n .map(filterVisibleTree)\n .filter((child) => child !== null) as ElementTreeNode<BaseElement>[])\n : [];\n\n // if the current node is visible, keep it and the filtered children\n if (node.node && node.node.isVisible === true) {\n return {\n ...node,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible, but has visible children, create an empty node to include these children\n if (filteredChildren.length > 0) {\n return {\n node: null,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible and has no visible children, return null\n return null;\n }\n\n return {\n ...execution,\n tasks: Array.isArray(execution.tasks)\n ? execution.tasks.map((task: ExecutionTask) => {\n const newTask = { ...task };\n if (task.pageContext?.tree) {\n newTask.pageContext = {\n ...task.pageContext,\n tree: filterVisibleTree(task.pageContext.tree) || {\n node: null,\n children: [],\n },\n };\n }\n return newTask;\n })\n : execution.tasks,\n };\n}\n","import assert from 'node:assert';\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { getMidsceneRunSubDir } from '@midscene/shared/common';\nimport { getDebug } from '@midscene/shared/logger';\nimport { ifInBrowser } from '@midscene/shared/utils';\nimport yaml from 'js-yaml';\nimport semver from 'semver';\nimport { version } from '../../package.json';\nimport { replaceIllegalPathCharsAndSpace } from './utils';\n\nexport const debug = getDebug('cache');\n\nexport interface PlanningCache {\n type: 'plan';\n prompt: string;\n yamlWorkflow: string;\n}\n\nexport interface LocateCache {\n type: 'locate';\n prompt: string;\n xpaths: string[];\n}\n\nexport interface MatchCacheResult<T extends PlanningCache | LocateCache> {\n cacheContent: T;\n updateFn: (cb: (cache: T) => void) => void;\n}\n\nexport type CacheFileContent = {\n midsceneVersion: string;\n cacheId: string;\n caches: Array<PlanningCache | LocateCache>;\n};\n\nconst lowestSupportedMidsceneVersion = '0.16.10';\nexport const cacheFileExt = '.cache.yaml';\n\nexport class TaskCache {\n cacheId: string;\n\n cacheFilePath?: string;\n\n cache: CacheFileContent;\n\n isCacheResultUsed: boolean; // a flag to indicate if the cache result should be used\n cacheOriginalLength: number;\n\n private matchedCacheIndices: Set<string> = new Set(); // Track matched records\n\n constructor(\n cacheId: string,\n isCacheResultUsed: boolean,\n cacheFilePath?: string,\n ) {\n assert(cacheId, 'cacheId is required');\n this.cacheId = replaceIllegalPathCharsAndSpace(cacheId);\n\n this.cacheFilePath = ifInBrowser\n ? undefined\n : cacheFilePath ||\n join(getMidsceneRunSubDir('cache'), `${this.cacheId}${cacheFileExt}`);\n this.isCacheResultUsed = isCacheResultUsed;\n\n let cacheContent;\n if (this.cacheFilePath) {\n cacheContent = this.loadCacheFromFile();\n }\n if (!cacheContent) {\n cacheContent = {\n midsceneVersion: version,\n cacheId: this.cacheId,\n caches: [],\n };\n }\n this.cache = cacheContent;\n this.cacheOriginalLength = this.cache.caches.length;\n }\n\n matchCache(\n prompt: string,\n type: 'plan' | 'locate',\n ): MatchCacheResult<PlanningCache | LocateCache> | undefined {\n // Find the first unused matching cache\n for (let i = 0; i < this.cacheOriginalLength; i++) {\n const item = this.cache.caches[i];\n const key = `${type}:${prompt}:${i}`;\n if (\n item.type === type &&\n item.prompt === prompt &&\n !this.matchedCacheIndices.has(key)\n ) {\n this.matchedCacheIndices.add(key);\n debug(\n 'cache found and marked as used, type: %s, prompt: %s, index: %d',\n type,\n prompt,\n i,\n );\n return {\n cacheContent: item,\n updateFn: (cb: (cache: PlanningCache | LocateCache) => void) => {\n debug(\n 'will call updateFn to update cache, type: %s, prompt: %s, index: %d',\n type,\n prompt,\n i,\n );\n cb(item);\n debug(\n 'cache updated, will flush to file, type: %s, prompt: %s, index: %d',\n type,\n prompt,\n i,\n );\n this.flushCacheToFile();\n },\n };\n }\n }\n debug('no unused cache found, type: %s, prompt: %s', type, prompt);\n return undefined;\n }\n\n matchPlanCache(prompt: string): MatchCacheResult<PlanningCache> | undefined {\n return this.matchCache(prompt, 'plan') as\n | MatchCacheResult<PlanningCache>\n | undefined;\n }\n\n matchLocateCache(prompt: string): MatchCacheResult<LocateCache> | undefined {\n return this.matchCache(prompt, 'locate') as\n | MatchCacheResult<LocateCache>\n | undefined;\n }\n\n appendCache(cache: PlanningCache | LocateCache) {\n debug('will append cache', cache);\n this.cache.caches.push(cache);\n this.flushCacheToFile();\n }\n\n loadCacheFromFile() {\n const cacheFile = this.cacheFilePath;\n assert(cacheFile, 'cache file path is required');\n\n if (!existsSync(cacheFile)) {\n debug('no cache file found, path: %s', cacheFile);\n return undefined;\n }\n\n // detect old cache file\n const jsonTypeCacheFile = cacheFile.replace(cacheFileExt, '.json');\n if (existsSync(jsonTypeCacheFile) && this.isCacheResultUsed) {\n console.warn(\n `An outdated cache file from an earlier version of Midscene has been detected. Since version 0.17, we have implemented an improved caching strategy. Please delete the old file located at: ${jsonTypeCacheFile}.`,\n );\n return undefined;\n }\n\n try {\n const data = readFileSync(cacheFile, 'utf8');\n const jsonData = yaml.load(data) as CacheFileContent;\n\n if (!version) {\n debug('no midscene version info, will not read cache from file');\n return undefined;\n }\n\n if (\n semver.lt(jsonData.midsceneVersion, lowestSupportedMidsceneVersion) &&\n !jsonData.midsceneVersion.includes('beta') // for internal test\n ) {\n console.warn(\n `You are using an old version of Midscene cache file, and we cannot match any info from it. Starting from Midscene v0.17, we changed our strategy to use xpath for cache info, providing better performance.\\nPlease delete the existing cache and rebuild it. Sorry for the inconvenience.\\ncache file: ${cacheFile}`,\n );\n return undefined;\n }\n\n debug(\n 'cache loaded from file, path: %s, cache version: %s, record length: %s',\n cacheFile,\n jsonData.midsceneVersion,\n jsonData.caches.length,\n );\n jsonData.midsceneVersion = version; // update the version\n return jsonData;\n } catch (err) {\n debug(\n 'cache file exists but load failed, path: %s, error: %s',\n cacheFile,\n err,\n );\n return undefined;\n }\n }\n\n flushCacheToFile() {\n if (!version) {\n debug('no midscene version info, will not write cache to file');\n return;\n }\n\n if (!this.cacheFilePath) {\n debug('no cache file path, will not write cache to file');\n return;\n }\n\n try {\n const dir = dirname(this.cacheFilePath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n debug('created cache directory: %s', dir);\n }\n const yamlData = yaml.dump(this.cache);\n writeFileSync(this.cacheFilePath, yamlData);\n debug('cache flushed to file: %s', this.cacheFilePath);\n } catch (err) {\n debug(\n 'write cache to file failed, path: %s, error: %s',\n this.cacheFilePath,\n err,\n );\n }\n }\n\n updateOrAppendCacheRecord(\n newRecord: PlanningCache | LocateCache,\n cachedRecord?: MatchCacheResult<PlanningCache | LocateCache>,\n ) {\n if (cachedRecord) {\n // update existing record\n if (newRecord.type === 'plan') {\n cachedRecord.updateFn((cache) => {\n (cache as PlanningCache).yamlWorkflow = newRecord.yamlWorkflow;\n });\n } else {\n cachedRecord.updateFn((cache) => {\n (cache as LocateCache).xpaths = newRecord.xpaths;\n });\n }\n } else {\n this.appendCache(newRecord);\n }\n }\n}\n","import {\n printReportMsg,\n replaceIllegalPathCharsAndSpace,\n reportFileName,\n} from '@/common/utils';\nimport type { ReportDumpWithAttributes } from '@midscene/core';\nimport { writeDumpReport } from '@midscene/core/utils';\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n Suite,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nfunction logger(...message: any[]) {\n if (process.env.DEBUG === 'true') {\n console.log('Midscene e2e report:', ...message);\n }\n}\n\nconst testDataList: Array<ReportDumpWithAttributes> = [];\nlet mergedFilename: string;\nconst testTitleToFilename: Map<string, string> = new Map();\n\nfunction getStableFilename(testTitle: string): string {\n if (!testTitleToFilename.has(testTitle)) {\n // use reportFileName to generate the base filename\n // only replace the illegal characters in the file system: /, \\, :, *, ?, \", <, >, |\n const baseTag = `playwright-${replaceIllegalPathCharsAndSpace(testTitle)}`;\n const generatedFilename = reportFileName(baseTag);\n testTitleToFilename.set(testTitle, generatedFilename);\n }\n return testTitleToFilename.get(testTitle)!;\n}\n\nfunction updateReport(mode: 'merged' | 'separate', testId?: string) {\n if (mode === 'separate') {\n // in separate mode, find the data for the corresponding testID and generate a separate report\n const testData = testDataList.find(\n (data) => data.attributes?.playwright_test_id === testId,\n );\n\n if (testData) {\n // use the stable filename\n const stableFilename = getStableFilename(\n testData.attributes?.playwright_test_title,\n );\n\n const reportPath = writeDumpReport(stableFilename, [testData]);\n reportPath && printReportMsg(reportPath);\n }\n } else if (mode === 'merged') {\n // in merged mode, write all test data into one file\n if (!mergedFilename) {\n mergedFilename = reportFileName('playwright-merged');\n }\n\n const reportPath = writeDumpReport(mergedFilename, testDataList);\n reportPath && printReportMsg(reportPath);\n } else {\n throw new Error(\n `Unknown reporter type in playwright config: ${mode}, only support 'merged' or 'separate'`,\n );\n }\n}\n\nfunction getMode(reporterType: string) {\n if (!reporterType) {\n return 'merged';\n }\n\n if (reporterType !== 'merged' && reporterType !== 'separate') {\n throw new Error(\n `Unknown reporter type in playwright config: ${reporterType}, only support 'merged' or 'separate'`,\n );\n }\n\n return reporterType;\n}\n\nclass MidsceneReporter implements Reporter {\n mode?: 'merged' | 'separate';\n\n async onBegin(config: FullConfig, suite: Suite) {\n const reporterType = config.reporter?.[1]?.[1]?.type;\n\n this.mode = getMode(reporterType);\n\n // const suites = suite.allTests();\n // logger(`Starting the run with ${suites.length} tests`);\n }\n\n onTestBegin(test: TestCase, _result: TestResult) {\n // logger(`Starting test ${test.title}`);\n }\n\n onTestEnd(test: TestCase, result: TestResult) {\n const dumpAnnotation = test.annotations.find((annotation) => {\n return annotation.type === 'MIDSCENE_DUMP_ANNOTATION';\n });\n if (!dumpAnnotation?.description) return;\n const retry = result.retry ? `(retry #${result.retry})` : '';\n const testId = `${test.id}${retry}`;\n const testData: ReportDumpWithAttributes = {\n dumpString: dumpAnnotation.description,\n attributes: {\n playwright_test_id: testId,\n playwright_test_title: `${test.title}${retry}`,\n playwright_test_status: result.status,\n playwright_test_duration: result.duration,\n },\n };\n\n testDataList.push(testData);\n\n updateReport(this.mode!, testId);\n\n test.annotations = test.annotations.filter(\n (annotation) => annotation.type !== 'MIDSCENE_DUMP_ANNOTATION',\n );\n }\n\n onEnd(result: FullResult) {\n updateReport(this.mode!);\n\n logger(`Finished the run: ${result.status}`);\n }\n}\n\nexport default MidsceneReporter;\n"]}
@@ -8,8 +8,21 @@ import {
8
8
  traverseTree
9
9
  } from "@midscene/shared/extractor";
10
10
  import { resizeImgBase64 } from "@midscene/shared/img";
11
- import { assert, logMsg, uuid } from "@midscene/shared/utils";
11
+ import { assert as assert2, logMsg, uuid } from "@midscene/shared/utils";
12
12
  import dayjs from "dayjs";
13
+
14
+ // src/common/task-cache.ts
15
+ import assert from "assert";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
17
+ import { dirname, join } from "path";
18
+ import { getMidsceneRunSubDir } from "@midscene/shared/common";
19
+ import { getDebug } from "@midscene/shared/logger";
20
+ import { ifInBrowser } from "@midscene/shared/utils";
21
+ import yaml from "js-yaml";
22
+ import semver from "semver";
23
+ var debug = getDebug("cache");
24
+
25
+ // src/common/utils.ts
13
26
  function reportFileName(tag = "web") {
14
27
  const reportTagName = getAIConfig(MIDSCENE_REPORT_TAG_NAME);
15
28
  const dateTimeInFileName = dayjs().format("YYYY-MM-DD_HH-mm-ss");
@@ -1 +1 @@
1
- {"version":3,"mappings":"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","names":[],"ignoreList":[],"sources":["../../src/common/utils.ts","../../src/playwright/reporter/index.ts"],"sourcesContent":["import type { StaticPage } from '@/playground';\nimport type {\n BaseElement,\n ElementTreeNode,\n ExecutionDump,\n ExecutionTask,\n PlanningLocateParam,\n PlaywrightParserOpt,\n UIContext,\n} from '@midscene/core';\nimport { elementByPositionWithElementInfo } from '@midscene/core/ai-model';\nimport { uploadTestInfoToServer } from '@midscene/core/utils';\nimport { MIDSCENE_REPORT_TAG_NAME, getAIConfig } from '@midscene/shared/env';\nimport type { ElementInfo } from '@midscene/shared/extractor';\nimport {\n generateElementByPosition,\n getNodeFromCacheList,\n traverseTree,\n} from '@midscene/shared/extractor';\nimport { resizeImgBase64 } from '@midscene/shared/img';\nimport type { DebugFunction } from '@midscene/shared/logger';\nimport { assert, logMsg, uuid } from '@midscene/shared/utils';\nimport dayjs from 'dayjs';\nimport type { Page as PlaywrightPage } from 'playwright';\nimport type { Page as PuppeteerPage } from 'puppeteer';\nimport { WebElementInfo } from '../web-element';\nimport type { WebPage } from './page';\n\nexport type WebUIContext = UIContext<WebElementInfo> & {\n url: string;\n};\n\nexport async function parseContextFromWebPage(\n page: WebPage,\n _opt?: PlaywrightParserOpt,\n): Promise<WebUIContext> {\n assert(page, 'page is required');\n if ((page as StaticPage)._forceUsePageContext) {\n return await (page as any)._forceUsePageContext();\n }\n const url = await page.url();\n uploadTestInfoToServer({ testUrl: url });\n\n let screenshotBase64: string;\n let tree: ElementTreeNode<ElementInfo>;\n\n await Promise.all([\n page.screenshotBase64().then((base64) => {\n screenshotBase64 = base64;\n }),\n page.getElementsNodeTree().then(async (treeRoot) => {\n tree = treeRoot;\n }),\n ]);\n\n const webTree = traverseTree(tree!, (elementInfo) => {\n const { rect, id, content, attributes, indexId, isVisible } = elementInfo;\n return new WebElementInfo({\n rect,\n id,\n content,\n attributes,\n indexId,\n isVisible,\n });\n });\n\n assert(screenshotBase64!, 'screenshotBase64 is required');\n\n const size = await page.size();\n\n if (size.dpr && size.dpr > 1) {\n // console.time('resizeImgBase64');\n screenshotBase64 = await resizeImgBase64(screenshotBase64, {\n width: size.width,\n height: size.height,\n });\n // console.timeEnd('resizeImgBase64');\n }\n\n return {\n tree: webTree,\n size,\n screenshotBase64: screenshotBase64!,\n url,\n };\n}\n\nexport function reportFileName(tag = 'web') {\n const reportTagName = getAIConfig(MIDSCENE_REPORT_TAG_NAME);\n const dateTimeInFileName = dayjs().format('YYYY-MM-DD_HH-mm-ss');\n // ensure uniqueness at the same time\n const uniqueId = uuid().substring(0, 8);\n return `${reportTagName || tag}-${dateTimeInFileName}-${uniqueId}`;\n}\n\nexport function printReportMsg(filepath: string) {\n logMsg(`Midscene - report file updated: ${filepath}`);\n}\n\n/**\n * Get the current execution file name\n * @returns The name of the current execution file\n */\nexport function getCurrentExecutionFile(trace?: string): string | false {\n const error = new Error();\n const stackTrace = trace || error.stack;\n const pkgDir = process.cwd() || '';\n if (stackTrace) {\n const stackLines = stackTrace.split('\\n');\n for (const line of stackLines) {\n if (\n line.includes('.spec.') ||\n line.includes('.test.') ||\n line.includes('.ts') ||\n line.includes('.js')\n ) {\n const match = line.match(/(?:at\\s+)?(.*?\\.(?:spec|test)\\.[jt]s)/);\n if (match?.[1]) {\n const targetFileName = match[1]\n .replace(pkgDir, '')\n .trim()\n .replace('at ', '');\n return targetFileName;\n }\n }\n }\n }\n return false;\n}\n\nconst testFileIndex = new Map<string, number>();\n\nexport function generateCacheId(fileName?: string): string {\n let taskFile = fileName || getCurrentExecutionFile();\n if (!taskFile) {\n taskFile = uuid();\n console.warn(\n 'Midscene - using random UUID for cache id. Cache may be invalid.',\n );\n }\n\n if (testFileIndex.has(taskFile)) {\n const currentIndex = testFileIndex.get(taskFile);\n if (currentIndex !== undefined) {\n testFileIndex.set(taskFile, currentIndex + 1);\n }\n } else {\n testFileIndex.set(taskFile, 1);\n }\n return `${taskFile}-${testFileIndex.get(taskFile)}`;\n}\n\nexport const ERROR_CODE_NOT_IMPLEMENTED_AS_DESIGNED =\n 'NOT_IMPLEMENTED_AS_DESIGNED';\n\nexport function replaceIllegalPathCharsAndSpace(str: string) {\n // Only replace characters that are illegal in filenames, but preserve path separators\n return str.replace(/[:*?\"<>| ]/g, '-');\n}\n\nexport function forceClosePopup(\n page: PuppeteerPage | PlaywrightPage,\n debug: DebugFunction,\n) {\n page.on('popup', async (popup) => {\n if (!popup) {\n console.warn('got a popup event, but the popup is not ready yet, skip');\n return;\n }\n const url = await (popup as PuppeteerPage).url();\n console.log(`Popup opened: ${url}`);\n if (!(popup as PuppeteerPage).isClosed()) {\n try {\n await (popup as PuppeteerPage).close(); // Close the newly opened TAB\n } catch (error) {\n debug(`failed to close popup ${url}, error: ${error}`);\n }\n } else {\n debug(`popup is already closed, skip close ${url}`);\n }\n\n if (!page.isClosed()) {\n try {\n await page.goto(url);\n } catch (error) {\n debug(`failed to goto ${url}, error: ${error}`);\n }\n } else {\n debug(`page is already closed, skip goto ${url}`);\n }\n });\n}\n\nexport function matchElementFromPlan(\n planLocateParam: PlanningLocateParam,\n tree: ElementTreeNode<BaseElement>,\n) {\n if (!planLocateParam) {\n return undefined;\n }\n if (planLocateParam.id) {\n return getNodeFromCacheList(planLocateParam.id);\n }\n\n if (planLocateParam.bbox) {\n const centerPosition = {\n x: Math.floor((planLocateParam.bbox[0] + planLocateParam.bbox[2]) / 2),\n y: Math.floor((planLocateParam.bbox[1] + planLocateParam.bbox[3]) / 2),\n };\n let element = elementByPositionWithElementInfo(tree, centerPosition);\n\n if (!element) {\n element = generateElementByPosition(centerPosition) as BaseElement;\n }\n\n return element;\n }\n\n return undefined;\n}\n\nexport function trimContextByViewport(execution: ExecutionDump) {\n function filterVisibleTree(\n node: ElementTreeNode<BaseElement>,\n ): ElementTreeNode<BaseElement> | null {\n if (!node) return null;\n\n // recursively process all children\n const filteredChildren = Array.isArray(node.children)\n ? (node.children\n .map(filterVisibleTree)\n .filter((child) => child !== null) as ElementTreeNode<BaseElement>[])\n : [];\n\n // if the current node is visible, keep it and the filtered children\n if (node.node && node.node.isVisible === true) {\n return {\n ...node,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible, but has visible children, create an empty node to include these children\n if (filteredChildren.length > 0) {\n return {\n node: null,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible and has no visible children, return null\n return null;\n }\n\n return {\n ...execution,\n tasks: Array.isArray(execution.tasks)\n ? execution.tasks.map((task: ExecutionTask) => {\n const newTask = { ...task };\n if (task.pageContext?.tree) {\n newTask.pageContext = {\n ...task.pageContext,\n tree: filterVisibleTree(task.pageContext.tree) || {\n node: null,\n children: [],\n },\n };\n }\n return newTask;\n })\n : execution.tasks,\n };\n}\n","import {\n printReportMsg,\n replaceIllegalPathCharsAndSpace,\n reportFileName,\n} from '@/common/utils';\nimport type { ReportDumpWithAttributes } from '@midscene/core';\nimport { writeDumpReport } from '@midscene/core/utils';\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n Suite,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nfunction logger(...message: any[]) {\n if (process.env.DEBUG === 'true') {\n console.log('Midscene e2e report:', ...message);\n }\n}\n\nconst testDataList: Array<ReportDumpWithAttributes> = [];\nlet mergedFilename: string;\nconst testTitleToFilename: Map<string, string> = new Map();\n\nfunction getStableFilename(testTitle: string): string {\n if (!testTitleToFilename.has(testTitle)) {\n // use reportFileName to generate the base filename\n // only replace the illegal characters in the file system: /, \\, :, *, ?, \", <, >, |\n const baseTag = `playwright-${replaceIllegalPathCharsAndSpace(testTitle)}`;\n const generatedFilename = reportFileName(baseTag);\n testTitleToFilename.set(testTitle, generatedFilename);\n }\n return testTitleToFilename.get(testTitle)!;\n}\n\nfunction updateReport(mode: 'merged' | 'separate', testId?: string) {\n if (mode === 'separate') {\n // in separate mode, find the data for the corresponding testID and generate a separate report\n const testData = testDataList.find(\n (data) => data.attributes?.playwright_test_id === testId,\n );\n\n if (testData) {\n // use the stable filename\n const stableFilename = getStableFilename(\n testData.attributes?.playwright_test_title,\n );\n\n const reportPath = writeDumpReport(stableFilename, [testData]);\n reportPath && printReportMsg(reportPath);\n }\n } else if (mode === 'merged') {\n // in merged mode, write all test data into one file\n if (!mergedFilename) {\n mergedFilename = reportFileName('playwright-merged');\n }\n\n const reportPath = writeDumpReport(mergedFilename, testDataList);\n reportPath && printReportMsg(reportPath);\n } else {\n throw new Error(\n `Unknown reporter type in playwright config: ${mode}, only support 'merged' or 'separate'`,\n );\n }\n}\n\nfunction getMode(reporterType: string) {\n if (!reporterType) {\n return 'merged';\n }\n\n if (reporterType !== 'merged' && reporterType !== 'separate') {\n throw new Error(\n `Unknown reporter type in playwright config: ${reporterType}, only support 'merged' or 'separate'`,\n );\n }\n\n return reporterType;\n}\n\nclass MidsceneReporter implements Reporter {\n mode?: 'merged' | 'separate';\n\n async onBegin(config: FullConfig, suite: Suite) {\n const reporterType = config.reporter?.[1]?.[1]?.type;\n\n this.mode = getMode(reporterType);\n\n // const suites = suite.allTests();\n // logger(`Starting the run with ${suites.length} tests`);\n }\n\n onTestBegin(test: TestCase, _result: TestResult) {\n // logger(`Starting test ${test.title}`);\n }\n\n onTestEnd(test: TestCase, result: TestResult) {\n const dumpAnnotation = test.annotations.find((annotation) => {\n return annotation.type === 'MIDSCENE_DUMP_ANNOTATION';\n });\n if (!dumpAnnotation?.description) return;\n const retry = result.retry ? `(retry #${result.retry})` : '';\n const testId = `${test.id}${retry}`;\n const testData: ReportDumpWithAttributes = {\n dumpString: dumpAnnotation.description,\n attributes: {\n playwright_test_id: testId,\n playwright_test_title: `${test.title}${retry}`,\n playwright_test_status: result.status,\n playwright_test_duration: result.duration,\n },\n };\n\n testDataList.push(testData);\n\n updateReport(this.mode!, testId);\n\n test.annotations = test.annotations.filter(\n (annotation) => annotation.type !== 'MIDSCENE_DUMP_ANNOTATION',\n );\n }\n\n onEnd(result: FullResult) {\n updateReport(this.mode!);\n\n logger(`Finished the run: ${result.status}`);\n }\n}\n\nexport default MidsceneReporter;\n"]}
1
+ {"version":3,"mappings":"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","names":["assert"],"ignoreList":[],"sources":["../../src/common/utils.ts","../../src/common/task-cache.ts","../../src/playwright/reporter/index.ts"],"sourcesContent":["import type { StaticPage } from '@/playground';\nimport type {\n BaseElement,\n ElementTreeNode,\n ExecutionDump,\n ExecutionTask,\n PlanningLocateParam,\n PlaywrightParserOpt,\n UIContext,\n} from '@midscene/core';\nimport { elementByPositionWithElementInfo } from '@midscene/core/ai-model';\nimport { uploadTestInfoToServer } from '@midscene/core/utils';\nimport { MIDSCENE_REPORT_TAG_NAME, getAIConfig } from '@midscene/shared/env';\nimport type { ElementInfo } from '@midscene/shared/extractor';\nimport {\n generateElementByPosition,\n getNodeFromCacheList,\n traverseTree,\n} from '@midscene/shared/extractor';\nimport { resizeImgBase64 } from '@midscene/shared/img';\nimport type { DebugFunction } from '@midscene/shared/logger';\nimport { assert, logMsg, uuid } from '@midscene/shared/utils';\nimport dayjs from 'dayjs';\nimport type { Page as PlaywrightPage } from 'playwright';\nimport type { Page as PuppeteerPage } from 'puppeteer';\nimport { WebElementInfo } from '../web-element';\nimport type { WebPage } from './page';\nimport { debug as cacheDebug } from './task-cache';\nimport type { PageTaskExecutor } from './tasks';\n\nexport type WebUIContext = UIContext<WebElementInfo> & {\n url: string;\n};\n\nexport async function parseContextFromWebPage(\n page: WebPage,\n _opt?: PlaywrightParserOpt,\n): Promise<WebUIContext> {\n assert(page, 'page is required');\n if ((page as StaticPage)._forceUsePageContext) {\n return await (page as any)._forceUsePageContext();\n }\n const url = await page.url();\n uploadTestInfoToServer({ testUrl: url });\n\n let screenshotBase64: string;\n let tree: ElementTreeNode<ElementInfo>;\n\n await Promise.all([\n page.screenshotBase64().then((base64) => {\n screenshotBase64 = base64;\n }),\n page.getElementsNodeTree().then(async (treeRoot) => {\n tree = treeRoot;\n }),\n ]);\n\n const webTree = traverseTree(tree!, (elementInfo) => {\n const { rect, id, content, attributes, indexId, isVisible } = elementInfo;\n return new WebElementInfo({\n rect,\n id,\n content,\n attributes,\n indexId,\n isVisible,\n });\n });\n\n assert(screenshotBase64!, 'screenshotBase64 is required');\n\n const size = await page.size();\n\n if (size.dpr && size.dpr > 1) {\n // console.time('resizeImgBase64');\n screenshotBase64 = await resizeImgBase64(screenshotBase64, {\n width: size.width,\n height: size.height,\n });\n // console.timeEnd('resizeImgBase64');\n }\n\n return {\n tree: webTree,\n size,\n screenshotBase64: screenshotBase64!,\n url,\n };\n}\n\nexport function reportFileName(tag = 'web') {\n const reportTagName = getAIConfig(MIDSCENE_REPORT_TAG_NAME);\n const dateTimeInFileName = dayjs().format('YYYY-MM-DD_HH-mm-ss');\n // ensure uniqueness at the same time\n const uniqueId = uuid().substring(0, 8);\n return `${reportTagName || tag}-${dateTimeInFileName}-${uniqueId}`;\n}\n\nexport function printReportMsg(filepath: string) {\n logMsg(`Midscene - report file updated: ${filepath}`);\n}\n\n/**\n * Get the current execution file name\n * @returns The name of the current execution file\n */\nexport function getCurrentExecutionFile(trace?: string): string | false {\n const error = new Error();\n const stackTrace = trace || error.stack;\n const pkgDir = process.cwd() || '';\n if (stackTrace) {\n const stackLines = stackTrace.split('\\n');\n for (const line of stackLines) {\n if (\n line.includes('.spec.') ||\n line.includes('.test.') ||\n line.includes('.ts') ||\n line.includes('.js')\n ) {\n const match = line.match(/(?:at\\s+)?(.*?\\.(?:spec|test)\\.[jt]s)/);\n if (match?.[1]) {\n const targetFileName = match[1]\n .replace(pkgDir, '')\n .trim()\n .replace('at ', '');\n return targetFileName;\n }\n }\n }\n }\n return false;\n}\n\nconst testFileIndex = new Map<string, number>();\n\nexport function generateCacheId(fileName?: string): string {\n let taskFile = fileName || getCurrentExecutionFile();\n if (!taskFile) {\n taskFile = uuid();\n console.warn(\n 'Midscene - using random UUID for cache id. Cache may be invalid.',\n );\n }\n\n if (testFileIndex.has(taskFile)) {\n const currentIndex = testFileIndex.get(taskFile);\n if (currentIndex !== undefined) {\n testFileIndex.set(taskFile, currentIndex + 1);\n }\n } else {\n testFileIndex.set(taskFile, 1);\n }\n return `${taskFile}-${testFileIndex.get(taskFile)}`;\n}\n\nexport const ERROR_CODE_NOT_IMPLEMENTED_AS_DESIGNED =\n 'NOT_IMPLEMENTED_AS_DESIGNED';\n\nexport function replaceIllegalPathCharsAndSpace(str: string) {\n // Only replace characters that are illegal in filenames, but preserve path separators\n return str.replace(/[:*?\"<>| ]/g, '-');\n}\n\nexport function forceClosePopup(\n page: PuppeteerPage | PlaywrightPage,\n debug: DebugFunction,\n) {\n page.on('popup', async (popup) => {\n if (!popup) {\n console.warn('got a popup event, but the popup is not ready yet, skip');\n return;\n }\n const url = await (popup as PuppeteerPage).url();\n console.log(`Popup opened: ${url}`);\n if (!(popup as PuppeteerPage).isClosed()) {\n try {\n await (popup as PuppeteerPage).close(); // Close the newly opened TAB\n } catch (error) {\n debug(`failed to close popup ${url}, error: ${error}`);\n }\n } else {\n debug(`popup is already closed, skip close ${url}`);\n }\n\n if (!page.isClosed()) {\n try {\n await page.goto(url);\n } catch (error) {\n debug(`failed to goto ${url}, error: ${error}`);\n }\n } else {\n debug(`page is already closed, skip goto ${url}`);\n }\n });\n}\n\nexport function matchElementFromPlan(\n planLocateParam: PlanningLocateParam,\n tree: ElementTreeNode<BaseElement>,\n) {\n if (!planLocateParam) {\n return undefined;\n }\n if (planLocateParam.id) {\n return getNodeFromCacheList(planLocateParam.id);\n }\n\n if (planLocateParam.bbox) {\n const centerPosition = {\n x: Math.floor((planLocateParam.bbox[0] + planLocateParam.bbox[2]) / 2),\n y: Math.floor((planLocateParam.bbox[1] + planLocateParam.bbox[3]) / 2),\n };\n let element = elementByPositionWithElementInfo(tree, centerPosition);\n\n if (!element) {\n element = generateElementByPosition(centerPosition) as BaseElement;\n }\n\n return element;\n }\n\n return undefined;\n}\n\nexport async function matchElementFromCache(\n taskExecutor: PageTaskExecutor,\n xpaths: string[] | undefined,\n cachePrompt: string,\n cacheable: boolean | undefined,\n) {\n try {\n if (\n xpaths?.length &&\n taskExecutor.taskCache?.isCacheResultUsed &&\n cacheable !== false\n ) {\n // hit cache, use new id\n for (let i = 0; i < xpaths.length; i++) {\n const element = await taskExecutor.page.getElementInfoByXpath(\n xpaths[i],\n );\n\n if (element?.id) {\n cacheDebug('cache hit, prompt: %s', cachePrompt);\n cacheDebug(\n 'found a new new element with same xpath, xpath: %s, id: %s',\n xpaths[i],\n element?.id,\n );\n return element;\n }\n }\n }\n } catch (error) {\n cacheDebug('get element info by xpath error: ', error);\n }\n}\n\nexport function trimContextByViewport(execution: ExecutionDump) {\n function filterVisibleTree(\n node: ElementTreeNode<BaseElement>,\n ): ElementTreeNode<BaseElement> | null {\n if (!node) return null;\n\n // recursively process all children\n const filteredChildren = Array.isArray(node.children)\n ? (node.children\n .map(filterVisibleTree)\n .filter((child) => child !== null) as ElementTreeNode<BaseElement>[])\n : [];\n\n // if the current node is visible, keep it and the filtered children\n if (node.node && node.node.isVisible === true) {\n return {\n ...node,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible, but has visible children, create an empty node to include these children\n if (filteredChildren.length > 0) {\n return {\n node: null,\n children: filteredChildren,\n };\n }\n\n // if the current node is invisible and has no visible children, return null\n return null;\n }\n\n return {\n ...execution,\n tasks: Array.isArray(execution.tasks)\n ? execution.tasks.map((task: ExecutionTask) => {\n const newTask = { ...task };\n if (task.pageContext?.tree) {\n newTask.pageContext = {\n ...task.pageContext,\n tree: filterVisibleTree(task.pageContext.tree) || {\n node: null,\n children: [],\n },\n };\n }\n return newTask;\n })\n : execution.tasks,\n };\n}\n","import assert from 'node:assert';\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { getMidsceneRunSubDir } from '@midscene/shared/common';\nimport { getDebug } from '@midscene/shared/logger';\nimport { ifInBrowser } from '@midscene/shared/utils';\nimport yaml from 'js-yaml';\nimport semver from 'semver';\nimport { version } from '../../package.json';\nimport { replaceIllegalPathCharsAndSpace } from './utils';\n\nexport const debug = getDebug('cache');\n\nexport interface PlanningCache {\n type: 'plan';\n prompt: string;\n yamlWorkflow: string;\n}\n\nexport interface LocateCache {\n type: 'locate';\n prompt: string;\n xpaths: string[];\n}\n\nexport interface MatchCacheResult<T extends PlanningCache | LocateCache> {\n cacheContent: T;\n updateFn: (cb: (cache: T) => void) => void;\n}\n\nexport type CacheFileContent = {\n midsceneVersion: string;\n cacheId: string;\n caches: Array<PlanningCache | LocateCache>;\n};\n\nconst lowestSupportedMidsceneVersion = '0.16.10';\nexport const cacheFileExt = '.cache.yaml';\n\nexport class TaskCache {\n cacheId: string;\n\n cacheFilePath?: string;\n\n cache: CacheFileContent;\n\n isCacheResultUsed: boolean; // a flag to indicate if the cache result should be used\n cacheOriginalLength: number;\n\n private matchedCacheIndices: Set<string> = new Set(); // Track matched records\n\n constructor(\n cacheId: string,\n isCacheResultUsed: boolean,\n cacheFilePath?: string,\n ) {\n assert(cacheId, 'cacheId is required');\n this.cacheId = replaceIllegalPathCharsAndSpace(cacheId);\n\n this.cacheFilePath = ifInBrowser\n ? undefined\n : cacheFilePath ||\n join(getMidsceneRunSubDir('cache'), `${this.cacheId}${cacheFileExt}`);\n this.isCacheResultUsed = isCacheResultUsed;\n\n let cacheContent;\n if (this.cacheFilePath) {\n cacheContent = this.loadCacheFromFile();\n }\n if (!cacheContent) {\n cacheContent = {\n midsceneVersion: version,\n cacheId: this.cacheId,\n caches: [],\n };\n }\n this.cache = cacheContent;\n this.cacheOriginalLength = this.cache.caches.length;\n }\n\n matchCache(\n prompt: string,\n type: 'plan' | 'locate',\n ): MatchCacheResult<PlanningCache | LocateCache> | undefined {\n // Find the first unused matching cache\n for (let i = 0; i < this.cacheOriginalLength; i++) {\n const item = this.cache.caches[i];\n const key = `${type}:${prompt}:${i}`;\n if (\n item.type === type &&\n item.prompt === prompt &&\n !this.matchedCacheIndices.has(key)\n ) {\n this.matchedCacheIndices.add(key);\n debug(\n 'cache found and marked as used, type: %s, prompt: %s, index: %d',\n type,\n prompt,\n i,\n );\n return {\n cacheContent: item,\n updateFn: (cb: (cache: PlanningCache | LocateCache) => void) => {\n debug(\n 'will call updateFn to update cache, type: %s, prompt: %s, index: %d',\n type,\n prompt,\n i,\n );\n cb(item);\n debug(\n 'cache updated, will flush to file, type: %s, prompt: %s, index: %d',\n type,\n prompt,\n i,\n );\n this.flushCacheToFile();\n },\n };\n }\n }\n debug('no unused cache found, type: %s, prompt: %s', type, prompt);\n return undefined;\n }\n\n matchPlanCache(prompt: string): MatchCacheResult<PlanningCache> | undefined {\n return this.matchCache(prompt, 'plan') as\n | MatchCacheResult<PlanningCache>\n | undefined;\n }\n\n matchLocateCache(prompt: string): MatchCacheResult<LocateCache> | undefined {\n return this.matchCache(prompt, 'locate') as\n | MatchCacheResult<LocateCache>\n | undefined;\n }\n\n appendCache(cache: PlanningCache | LocateCache) {\n debug('will append cache', cache);\n this.cache.caches.push(cache);\n this.flushCacheToFile();\n }\n\n loadCacheFromFile() {\n const cacheFile = this.cacheFilePath;\n assert(cacheFile, 'cache file path is required');\n\n if (!existsSync(cacheFile)) {\n debug('no cache file found, path: %s', cacheFile);\n return undefined;\n }\n\n // detect old cache file\n const jsonTypeCacheFile = cacheFile.replace(cacheFileExt, '.json');\n if (existsSync(jsonTypeCacheFile) && this.isCacheResultUsed) {\n console.warn(\n `An outdated cache file from an earlier version of Midscene has been detected. Since version 0.17, we have implemented an improved caching strategy. Please delete the old file located at: ${jsonTypeCacheFile}.`,\n );\n return undefined;\n }\n\n try {\n const data = readFileSync(cacheFile, 'utf8');\n const jsonData = yaml.load(data) as CacheFileContent;\n\n if (!version) {\n debug('no midscene version info, will not read cache from file');\n return undefined;\n }\n\n if (\n semver.lt(jsonData.midsceneVersion, lowestSupportedMidsceneVersion) &&\n !jsonData.midsceneVersion.includes('beta') // for internal test\n ) {\n console.warn(\n `You are using an old version of Midscene cache file, and we cannot match any info from it. Starting from Midscene v0.17, we changed our strategy to use xpath for cache info, providing better performance.\\nPlease delete the existing cache and rebuild it. Sorry for the inconvenience.\\ncache file: ${cacheFile}`,\n );\n return undefined;\n }\n\n debug(\n 'cache loaded from file, path: %s, cache version: %s, record length: %s',\n cacheFile,\n jsonData.midsceneVersion,\n jsonData.caches.length,\n );\n jsonData.midsceneVersion = version; // update the version\n return jsonData;\n } catch (err) {\n debug(\n 'cache file exists but load failed, path: %s, error: %s',\n cacheFile,\n err,\n );\n return undefined;\n }\n }\n\n flushCacheToFile() {\n if (!version) {\n debug('no midscene version info, will not write cache to file');\n return;\n }\n\n if (!this.cacheFilePath) {\n debug('no cache file path, will not write cache to file');\n return;\n }\n\n try {\n const dir = dirname(this.cacheFilePath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n debug('created cache directory: %s', dir);\n }\n const yamlData = yaml.dump(this.cache);\n writeFileSync(this.cacheFilePath, yamlData);\n debug('cache flushed to file: %s', this.cacheFilePath);\n } catch (err) {\n debug(\n 'write cache to file failed, path: %s, error: %s',\n this.cacheFilePath,\n err,\n );\n }\n }\n\n updateOrAppendCacheRecord(\n newRecord: PlanningCache | LocateCache,\n cachedRecord?: MatchCacheResult<PlanningCache | LocateCache>,\n ) {\n if (cachedRecord) {\n // update existing record\n if (newRecord.type === 'plan') {\n cachedRecord.updateFn((cache) => {\n (cache as PlanningCache).yamlWorkflow = newRecord.yamlWorkflow;\n });\n } else {\n cachedRecord.updateFn((cache) => {\n (cache as LocateCache).xpaths = newRecord.xpaths;\n });\n }\n } else {\n this.appendCache(newRecord);\n }\n }\n}\n","import {\n printReportMsg,\n replaceIllegalPathCharsAndSpace,\n reportFileName,\n} from '@/common/utils';\nimport type { ReportDumpWithAttributes } from '@midscene/core';\nimport { writeDumpReport } from '@midscene/core/utils';\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n Suite,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nfunction logger(...message: any[]) {\n if (process.env.DEBUG === 'true') {\n console.log('Midscene e2e report:', ...message);\n }\n}\n\nconst testDataList: Array<ReportDumpWithAttributes> = [];\nlet mergedFilename: string;\nconst testTitleToFilename: Map<string, string> = new Map();\n\nfunction getStableFilename(testTitle: string): string {\n if (!testTitleToFilename.has(testTitle)) {\n // use reportFileName to generate the base filename\n // only replace the illegal characters in the file system: /, \\, :, *, ?, \", <, >, |\n const baseTag = `playwright-${replaceIllegalPathCharsAndSpace(testTitle)}`;\n const generatedFilename = reportFileName(baseTag);\n testTitleToFilename.set(testTitle, generatedFilename);\n }\n return testTitleToFilename.get(testTitle)!;\n}\n\nfunction updateReport(mode: 'merged' | 'separate', testId?: string) {\n if (mode === 'separate') {\n // in separate mode, find the data for the corresponding testID and generate a separate report\n const testData = testDataList.find(\n (data) => data.attributes?.playwright_test_id === testId,\n );\n\n if (testData) {\n // use the stable filename\n const stableFilename = getStableFilename(\n testData.attributes?.playwright_test_title,\n );\n\n const reportPath = writeDumpReport(stableFilename, [testData]);\n reportPath && printReportMsg(reportPath);\n }\n } else if (mode === 'merged') {\n // in merged mode, write all test data into one file\n if (!mergedFilename) {\n mergedFilename = reportFileName('playwright-merged');\n }\n\n const reportPath = writeDumpReport(mergedFilename, testDataList);\n reportPath && printReportMsg(reportPath);\n } else {\n throw new Error(\n `Unknown reporter type in playwright config: ${mode}, only support 'merged' or 'separate'`,\n );\n }\n}\n\nfunction getMode(reporterType: string) {\n if (!reporterType) {\n return 'merged';\n }\n\n if (reporterType !== 'merged' && reporterType !== 'separate') {\n throw new Error(\n `Unknown reporter type in playwright config: ${reporterType}, only support 'merged' or 'separate'`,\n );\n }\n\n return reporterType;\n}\n\nclass MidsceneReporter implements Reporter {\n mode?: 'merged' | 'separate';\n\n async onBegin(config: FullConfig, suite: Suite) {\n const reporterType = config.reporter?.[1]?.[1]?.type;\n\n this.mode = getMode(reporterType);\n\n // const suites = suite.allTests();\n // logger(`Starting the run with ${suites.length} tests`);\n }\n\n onTestBegin(test: TestCase, _result: TestResult) {\n // logger(`Starting test ${test.title}`);\n }\n\n onTestEnd(test: TestCase, result: TestResult) {\n const dumpAnnotation = test.annotations.find((annotation) => {\n return annotation.type === 'MIDSCENE_DUMP_ANNOTATION';\n });\n if (!dumpAnnotation?.description) return;\n const retry = result.retry ? `(retry #${result.retry})` : '';\n const testId = `${test.id}${retry}`;\n const testData: ReportDumpWithAttributes = {\n dumpString: dumpAnnotation.description,\n attributes: {\n playwright_test_id: testId,\n playwright_test_title: `${test.title}${retry}`,\n playwright_test_status: result.status,\n playwright_test_duration: result.duration,\n },\n };\n\n testDataList.push(testData);\n\n updateReport(this.mode!, testId);\n\n test.annotations = test.annotations.filter(\n (annotation) => annotation.type !== 'MIDSCENE_DUMP_ANNOTATION',\n );\n }\n\n onEnd(result: FullResult) {\n updateReport(this.mode!);\n\n logger(`Finished the run: ${result.status}`);\n }\n}\n\nexport default MidsceneReporter;\n"]}