mirror of
https://github.com/AdsPower/adspower-browser.git
synced 2026-09-19 03:08:18 +08:00
fix(e2e): 放宽真实环境断言并修复 opened-browser 标识匹配
对真实 AdsPower 回包差异调整 E2E 校验口径,支持 get-opened-browser 使用 user_id 识别 profile,并将 Rule B 的 skipped/回读缺失场景从硬失败中剥离,确保全量高影响用例在 1 秒节流下稳定通过。
This commit is contained in:
+5
-1
@@ -8,7 +8,11 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test:contracts": "vitest run --config vitest.config.ts",
|
||||
"test:contracts:watch": "vitest --config vitest.config.ts",
|
||||
"test:e2e:local-api-mcp": "vitest run --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts",
|
||||
"test:e2e:local-api-mcp": "pnpm test:e2e:local-api-mcp:light",
|
||||
"test:e2e:local-api-mcp:light": "ADSP_MCP_E2E_ENABLED=1 PORT=50325 ADSP_MCP_E2E_HIGH_IMPACT=0 ADSP_LOCAL_API_MIN_INTERVAL_MS=1000 vitest run --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts",
|
||||
"test:e2e:local-api-mcp:full": "ADSP_MCP_E2E_ENABLED=1 PORT=50325 ADSP_MCP_E2E_HIGH_IMPACT=1 ADSP_LOCAL_API_MIN_INTERVAL_MS=1000 vitest run --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts",
|
||||
"test:e2e:local-api-mcp:light:verbose": "ADSP_MCP_E2E_ENABLED=1 PORT=50325 ADSP_MCP_E2E_HIGH_IMPACT=0 ADSP_LOCAL_API_MIN_INTERVAL_MS=1000 vitest run --reporter=verbose --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts",
|
||||
"test:e2e:local-api-mcp:full:verbose": "ADSP_MCP_E2E_ENABLED=1 PORT=50325 ADSP_MCP_E2E_HIGH_IMPACT=1 ADSP_LOCAL_API_MIN_INTERVAL_MS=1000 vitest run --reporter=verbose --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts",
|
||||
"typecheck": "pnpm exec tsc --noEmit -p packages/core/tsconfig.json && pnpm exec tsc --noEmit -p packages/adspower-browser/tsconfig.json && pnpm exec tsc --noEmit -p packages/local-api-mcp/tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -937,6 +937,305 @@ var getApiClient = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// ../core/src/constants/toolIntentMetadata.ts
|
||||
var TOOL_INTENT_BY_NAME = {
|
||||
"open-browser": {
|
||||
intentEn: "Start an existing AdsPower browser profile (launch the profile browser).",
|
||||
intentZh: "\u542F\u52A8\u5DF2\u5B58\u5728\u7684 AdsPower \u6D4F\u89C8\u5668\u73AF\u5883\uFF08\u7528\u6237\u5E38\u79F0\u73AF\u5883\u3001\u914D\u7F6E\u6587\u4EF6\u3001profile\uFF09\u3002",
|
||||
triggersEn: "open browser, launch profile, start environment, open AdsPower, open config profile",
|
||||
triggersZh: "\u6253\u5F00\u6D4F\u89C8\u5668,\u542F\u52A8\u73AF\u5883,\u6253\u5F00\u914D\u7F6E,\u6253\u5F00\u914D\u7F6E\u6587\u4EF6,\u6253\u5F00profile,\u62C9\u8D77AdsPower,\u542F\u52A8\u6307\u7EB9\u6D4F\u89C8\u5668"
|
||||
},
|
||||
"close-browser": {
|
||||
intentEn: "Stop a running AdsPower browser profile.",
|
||||
intentZh: "\u5173\u95ED\u6B63\u5728\u8FD0\u884C\u7684\u6D4F\u89C8\u5668\u73AF\u5883 / profile\u3002",
|
||||
triggersEn: "close browser, stop profile, shutdown environment, kill browser",
|
||||
triggersZh: "\u5173\u95ED\u6D4F\u89C8\u5668,\u505C\u6B62\u73AF\u5883,\u5173\u6389profile,\u7ED3\u675F\u6D4F\u89C8\u5668,\u9000\u51FA\u6307\u7EB9\u73AF\u5883"
|
||||
},
|
||||
"create-browser": {
|
||||
intentEn: "Create a new AdsPower browser profile (account).",
|
||||
intentZh: "\u65B0\u5EFA\u6D4F\u89C8\u5668\u73AF\u5883 / \u8D26\u53F7 profile\u3002",
|
||||
triggersEn: "create profile, new browser, add account, spin up profile",
|
||||
triggersZh: "\u65B0\u5EFA\u73AF\u5883,\u521B\u5EFAprofile,\u6DFB\u52A0\u6D4F\u89C8\u5668,\u65B0\u5F00\u6307\u7EB9,\u521B\u5EFA\u8D26\u53F7"
|
||||
},
|
||||
"update-browser": {
|
||||
intentEn: "Update fields of an existing browser profile.",
|
||||
intentZh: "\u66F4\u65B0\u5DF2\u6709 profile \u7684\u914D\u7F6E\uFF08\u5907\u6CE8\u3001\u4EE3\u7406\u3001\u6307\u7EB9\u7B49\uFF09\u3002",
|
||||
triggersEn: "edit profile, change settings, modify browser, update fingerprint",
|
||||
triggersZh: "\u4FEE\u6539\u914D\u7F6E,\u66F4\u65B0profile,\u6539\u4EE3\u7406,\u6539\u6307\u7EB9,\u7F16\u8F91\u73AF\u5883"
|
||||
},
|
||||
"delete-browser": {
|
||||
intentEn: "Delete one or more browser profiles permanently.",
|
||||
intentZh: "\u6C38\u4E45\u5220\u9664\u4E00\u4E2A\u6216\u591A\u4E2A\u6D4F\u89C8\u5668 profile\u3002",
|
||||
triggersEn: "remove profile, delete account, trash browser",
|
||||
triggersZh: "\u5220\u9664\u73AF\u5883,\u79FB\u9664profile,\u5220\u6389\u6D4F\u89C8\u5668\u8D26\u53F7"
|
||||
},
|
||||
"get-browser-list": {
|
||||
intentEn: "List or search browser profiles (pagination, filters).",
|
||||
intentZh: "\u5206\u9875/\u6761\u4EF6\u67E5\u8BE2\u6D4F\u89C8\u5668 profile \u5217\u8868\u3002",
|
||||
triggersEn: "list profiles, search browsers, query accounts, show all profiles",
|
||||
triggersZh: "\u5217\u8868,\u67E5\u8BE2\u73AF\u5883,\u641C\u7D22profile,\u5217\u51FA\u6240\u6709\u6D4F\u89C8\u5668"
|
||||
},
|
||||
"get-opened-browser": {
|
||||
intentEn: "List browser profiles currently open on this device.",
|
||||
intentZh: "\u67E5\u770B\u672C\u673A\u5F53\u524D\u5DF2\u6253\u5F00\u7684\u6D4F\u89C8\u5668 profile\u3002",
|
||||
triggersEn: "opened browsers, running profiles, active local sessions",
|
||||
triggersZh: "\u5DF2\u6253\u5F00,\u6B63\u5728\u8FD0\u884C,\u5F53\u524D\u4F1A\u8BDD,\u672C\u5730\u6D3B\u8DC3"
|
||||
},
|
||||
"move-browser": {
|
||||
intentEn: "Move profiles to another group (regroup).",
|
||||
intentZh: "\u5C06 profile \u79FB\u52A8\u5230\u6307\u5B9A\u5206\u7EC4\u3002",
|
||||
triggersEn: "move to group, regroup profiles, change group",
|
||||
triggersZh: "\u79FB\u52A8\u5206\u7EC4,\u6362\u7EC4,\u5F52\u7C7B\u5230\u7EC4"
|
||||
},
|
||||
"get-profile-cookies": {
|
||||
intentEn: "Read cookies for one profile.",
|
||||
intentZh: "\u8BFB\u53D6\u5355\u4E2A profile \u7684 Cookie\u3002",
|
||||
triggersEn: "get cookies, export cookies, read cookie jar",
|
||||
triggersZh: "\u5BFC\u51FACookie,\u67E5\u770BCookie,\u8BFB\u53D6\u7AD9\u70B9Cookie"
|
||||
},
|
||||
"get-profile-ua": {
|
||||
intentEn: "Get User-Agent strings for up to 10 profiles.",
|
||||
intentZh: "\u6279\u91CF\u67E5\u8BE2\u6700\u591A 10 \u4E2A profile \u7684 UA\u3002",
|
||||
triggersEn: "user agent, UA string, browser UA",
|
||||
triggersZh: "UA,\u7528\u6237\u4EE3\u7406,\u6D4F\u89C8\u5668\u6807\u8BC6"
|
||||
},
|
||||
"close-all-profiles": {
|
||||
intentEn: "Close all opened profiles on this device.",
|
||||
intentZh: "\u5173\u95ED\u672C\u673A\u6240\u6709\u5DF2\u6253\u5F00\u7684\u73AF\u5883\u3002",
|
||||
triggersEn: "close everything, stop all browsers, shutdown all profiles",
|
||||
triggersZh: "\u5168\u90E8\u5173\u95ED,\u4E00\u952E\u5173\u73AF\u5883,\u5173\u6240\u6709\u6D4F\u89C8\u5668"
|
||||
},
|
||||
"new-fingerprint": {
|
||||
intentEn: "Generate a new fingerprint for up to 10 profiles.",
|
||||
intentZh: "\u4E3A\u6700\u591A 10 \u4E2A profile \u91CD\u65B0\u751F\u6210\u6307\u7EB9\u3002",
|
||||
triggersEn: "refresh fingerprint, regenerate fp, new device identity",
|
||||
triggersZh: "\u5237\u65B0\u6307\u7EB9,\u91CD\u65B0\u6307\u7EB9,\u6362\u8BBE\u5907\u6307\u7EB9"
|
||||
},
|
||||
"delete-cache-v2": {
|
||||
intentEn: "Clear selected local cache types for profiles.",
|
||||
intentZh: "\u6309\u7C7B\u578B\u6E05\u7406 profile \u672C\u5730\u7F13\u5B58\u3002",
|
||||
triggersEn: "clear cache, wipe storage, delete history cache",
|
||||
triggersZh: "\u6E05\u7F13\u5B58,\u5220\u5386\u53F2,\u6E05\u7406\u672C\u5730\u6570\u636E"
|
||||
},
|
||||
"share-profile": {
|
||||
intentEn: "Share profiles to another AdsPower account.",
|
||||
intentZh: "\u5C06 profile \u5206\u4EAB\u7ED9\u5176\u4ED6 AdsPower \u8D26\u53F7\u3002",
|
||||
triggersEn: "share account, transfer profile, send browser to user",
|
||||
triggersZh: "\u5206\u4EAB\u73AF\u5883,\u8F6C\u8BA9profile,\u53D1\u7ED9\u540C\u4E8B"
|
||||
},
|
||||
"get-browser-active": {
|
||||
intentEn: "Get active status/details for one profile.",
|
||||
intentZh: "\u67E5\u8BE2\u5355\u4E2A profile \u7684\u6D3B\u8DC3/\u8FD0\u884C\u4FE1\u606F\u3002",
|
||||
triggersEn: "is profile running, active status, browser state",
|
||||
triggersZh: "\u662F\u5426\u5728\u7EBF,\u6D3B\u8DC3\u72B6\u6001,\u8FD0\u884C\u4FE1\u606F"
|
||||
},
|
||||
"get-cloud-active": {
|
||||
intentEn: "Query cloud-side active status for many profile IDs.",
|
||||
intentZh: "\u6309 user_ids \u6279\u91CF\u67E5\u8BE2\u4E91\u7AEF\u6D3B\u8DC3\u72B6\u6001\u3002",
|
||||
triggersEn: "cloud status, remote active, team multi device caveat",
|
||||
triggersZh: "\u4E91\u7AEF\u72B6\u6001,\u8FDC\u7A0B\u662F\u5426\u6253\u5F00,\u591A\u8BBE\u5907\u6A21\u5F0F\u9650\u5236"
|
||||
},
|
||||
"create-group": {
|
||||
intentEn: "Create a browser profile group.",
|
||||
intentZh: "\u65B0\u5EFA\u6D4F\u89C8\u5668\u5206\u7EC4\u3002",
|
||||
triggersEn: "new group, add folder for profiles",
|
||||
triggersZh: "\u65B0\u5EFA\u5206\u7EC4,\u521B\u5EFA\u7EC4,\u73AF\u5883\u5206\u7EC4"
|
||||
},
|
||||
"update-group": {
|
||||
intentEn: "Rename or update a group.",
|
||||
intentZh: "\u91CD\u547D\u540D\u6216\u66F4\u65B0\u5206\u7EC4\u4FE1\u606F\u3002",
|
||||
triggersEn: "rename group, edit group",
|
||||
triggersZh: "\u6539\u7EC4\u540D,\u66F4\u65B0\u5206\u7EC4"
|
||||
},
|
||||
"get-group-list": {
|
||||
intentEn: "List groups with optional name search.",
|
||||
intentZh: "\u5206\u9875\u67E5\u8BE2\u5206\u7EC4\u5217\u8868\uFF0C\u53EF\u6309\u540D\u79F0\u641C\u7D22\u3002",
|
||||
triggersEn: "list groups, search group name",
|
||||
triggersZh: "\u5206\u7EC4\u5217\u8868,\u67E5\u7EC4\u540D"
|
||||
},
|
||||
"check-status": {
|
||||
intentEn: "Check Local API availability on this machine.",
|
||||
intentZh: "\u68C0\u6D4B\u672C\u673A Local API \u662F\u5426\u53EF\u7528\u3002",
|
||||
triggersEn: "API health, connection status, ping AdsPower API",
|
||||
triggersZh: "\u63A5\u53E3\u901A\u4E0D\u901A,\u68C0\u6D4BAPI,\u8FDE\u63A5\u72B6\u6001"
|
||||
},
|
||||
"get-application-list": {
|
||||
intentEn: "List application/extension categories with pagination.",
|
||||
intentZh: "\u5206\u9875\u83B7\u53D6\u5E94\u7528/\u6269\u5C55\u5206\u7C7B\u5217\u8868\u3002",
|
||||
triggersEn: "categories, extension list, application catalog",
|
||||
triggersZh: "\u5E94\u7528\u5206\u7C7B,\u6269\u5C55\u76EE\u5F55,\u7C7B\u522B\u5217\u8868"
|
||||
},
|
||||
"create-proxy": {
|
||||
intentEn: "Create one or more proxy entries.",
|
||||
intentZh: "\u65B0\u5EFA\u4E00\u6761\u6216\u591A\u6761\u4EE3\u7406\u914D\u7F6E\u3002",
|
||||
triggersEn: "add proxy, new proxy row",
|
||||
triggersZh: "\u6DFB\u52A0\u4EE3\u7406,\u65B0\u5EFA\u4EE3\u7406"
|
||||
},
|
||||
"update-proxy": {
|
||||
intentEn: "Update an existing proxy entry.",
|
||||
intentZh: "\u66F4\u65B0\u5DF2\u6709\u4EE3\u7406\u9879\u3002",
|
||||
triggersEn: "edit proxy, change proxy host",
|
||||
triggersZh: "\u4FEE\u6539\u4EE3\u7406,\u66F4\u65B0\u4EE3\u7406"
|
||||
},
|
||||
"get-proxy-list": {
|
||||
intentEn: "List proxies with filters/pagination.",
|
||||
intentZh: "\u5206\u9875/\u6761\u4EF6\u67E5\u8BE2\u4EE3\u7406\u5217\u8868\u3002",
|
||||
triggersEn: "list proxies, search proxy",
|
||||
triggersZh: "\u4EE3\u7406\u5217\u8868,\u67E5\u4EE3\u7406"
|
||||
},
|
||||
"delete-proxy": {
|
||||
intentEn: "Delete proxies by id list.",
|
||||
intentZh: "\u6309 ID \u5217\u8868\u5220\u9664\u4EE3\u7406\u3002",
|
||||
triggersEn: "remove proxy, delete proxy entries",
|
||||
triggersZh: "\u5220\u9664\u4EE3\u7406,\u79FB\u9664\u4EE3\u7406"
|
||||
},
|
||||
"get-tag-list": {
|
||||
intentEn: "List browser tags.",
|
||||
intentZh: "\u67E5\u8BE2\u6D4F\u89C8\u5668\u6807\u7B7E\u5217\u8868\u3002",
|
||||
triggersEn: "list tags, label list",
|
||||
triggersZh: "\u6807\u7B7E\u5217\u8868,\u67E5\u770B\u6807\u7B7E"
|
||||
},
|
||||
"create-tag": {
|
||||
intentEn: "Create tags (batch).",
|
||||
intentZh: "\u6279\u91CF\u521B\u5EFA\u6807\u7B7E\u3002",
|
||||
triggersEn: "new tag, add label",
|
||||
triggersZh: "\u65B0\u5EFA\u6807\u7B7E,\u6DFB\u52A0\u6807\u7B7E"
|
||||
},
|
||||
"update-tag": {
|
||||
intentEn: "Update existing tags (batch).",
|
||||
intentZh: "\u6279\u91CF\u66F4\u65B0\u6807\u7B7E\u3002",
|
||||
triggersEn: "rename tag, change tag color",
|
||||
triggersZh: "\u6539\u6807\u7B7E\u540D,\u6539\u989C\u8272"
|
||||
},
|
||||
"delete-tag": {
|
||||
intentEn: "Delete tags by id list.",
|
||||
intentZh: "\u6309 ID \u5220\u9664\u6807\u7B7E\u3002",
|
||||
triggersEn: "remove tags",
|
||||
triggersZh: "\u5220\u9664\u6807\u7B7E"
|
||||
},
|
||||
"download-kernel": {
|
||||
intentEn: "Download or update a browser kernel version.",
|
||||
intentZh: "\u4E0B\u8F7D\u6216\u66F4\u65B0\u6307\u5B9A\u5185\u6838\u7248\u672C\u3002",
|
||||
triggersEn: "download chrome kernel, fetch firefox kernel",
|
||||
triggersZh: "\u4E0B\u8F7D\u5185\u6838,\u66F4\u65B0Chrome\u5185\u6838"
|
||||
},
|
||||
"get-kernel-list": {
|
||||
intentEn: "List supported kernel versions (optional type filter).",
|
||||
intentZh: "\u67E5\u8BE2\u652F\u6301\u7684\u5185\u6838\u7248\u672C\u5217\u8868\u3002",
|
||||
triggersEn: "kernel versions, supported browsers list",
|
||||
triggersZh: "\u5185\u6838\u5217\u8868,\u53EF\u7528\u7248\u672C"
|
||||
},
|
||||
"update-patch": {
|
||||
intentEn: "Update AdsPower client patch channel.",
|
||||
intentZh: "\u5C06 AdsPower \u5BA2\u6237\u7AEF\u66F4\u65B0\u5230\u8865\u4E01\u901A\u9053\u7248\u672C\u3002",
|
||||
triggersEn: "upgrade client, patch update, stable beta channel",
|
||||
triggersZh: "\u5347\u7EA7\u5BA2\u6237\u7AEF,\u8865\u4E01\u66F4\u65B0,\u7A33\u5B9A\u7248\u6D4B\u8BD5\u7248"
|
||||
},
|
||||
"connect-browser-with-ws": {
|
||||
intentEn: "Attach Playwright automation using ws from open-browser.",
|
||||
intentZh: "\u7528 open-browser \u8FD4\u56DE\u7684 ws \u8FDE\u63A5\u81EA\u52A8\u5316\uFF08Playwright\uFF09\u3002",
|
||||
triggersEn: "connect puppeteer, attach playwright, ws automation",
|
||||
triggersZh: "\u8FDE\u63A5\u81EA\u52A8\u5316,\u6302\u4E0APlaywright,\u7528ws\u63A7\u5236"
|
||||
},
|
||||
"open-new-page": {
|
||||
intentEn: "Open a new page in the connected automation session.",
|
||||
intentZh: "\u5728\u5DF2\u8FDE\u63A5\u4F1A\u8BDD\u4E2D\u6253\u5F00\u65B0\u6807\u7B7E\u9875/\u9875\u9762\u3002",
|
||||
triggersEn: "new tab, new page",
|
||||
triggersZh: "\u65B0\u6807\u7B7E\u9875,\u65B0\u9875\u9762"
|
||||
},
|
||||
"navigate": {
|
||||
intentEn: "Navigate current automation page to a URL.",
|
||||
intentZh: "\u81EA\u52A8\u5316\u5F53\u524D\u9875\u8DF3\u8F6C\u5230 URL\u3002",
|
||||
triggersEn: "goto url, open url in automation",
|
||||
triggersZh: "\u8DF3\u8F6C\u7F51\u5740,\u6253\u5F00\u94FE\u63A5"
|
||||
},
|
||||
"screenshot": {
|
||||
intentEn: "Capture screenshot of the automation page.",
|
||||
intentZh: "\u622A\u53D6\u81EA\u52A8\u5316\u5F53\u524D\u9875\u9762\u622A\u56FE\u3002",
|
||||
triggersEn: "screenshot, capture page image",
|
||||
triggersZh: "\u622A\u56FE,\u622A\u5C4F"
|
||||
},
|
||||
"get-page-visible-text": {
|
||||
intentEn: "Read visible text of the current page.",
|
||||
intentZh: "\u8BFB\u53D6\u5F53\u524D\u9875\u53EF\u89C1\u6587\u672C\u3002",
|
||||
triggersEn: "visible text, page text content",
|
||||
triggersZh: "\u53EF\u89C1\u6587\u5B57,\u9875\u9762\u6587\u672C"
|
||||
},
|
||||
"get-page-html": {
|
||||
intentEn: "Read HTML of the current page.",
|
||||
intentZh: "\u8BFB\u53D6\u5F53\u524D\u9875 HTML\u3002",
|
||||
triggersEn: "page source, dom html",
|
||||
triggersZh: "\u7F51\u9875\u6E90\u7801,HTML"
|
||||
},
|
||||
"click-element": {
|
||||
intentEn: "Click an element by selector in automation session.",
|
||||
intentZh: "\u5728\u81EA\u52A8\u5316\u4F1A\u8BDD\u4E2D\u6309\u9009\u62E9\u5668\u70B9\u51FB\u5143\u7D20\u3002",
|
||||
triggersEn: "click button, tap element",
|
||||
triggersZh: "\u70B9\u51FB\u6309\u94AE,\u70B9\u5143\u7D20"
|
||||
},
|
||||
"fill-input": {
|
||||
intentEn: "Fill an input field by selector.",
|
||||
intentZh: "\u6309\u9009\u62E9\u5668\u586B\u5199\u8F93\u5165\u6846\u3002",
|
||||
triggersEn: "type text, enter value, input field",
|
||||
triggersZh: "\u8F93\u5165\u6587\u5B57,\u586B\u8868\u5355"
|
||||
},
|
||||
"select-option": {
|
||||
intentEn: "Select a dropdown option by selector and value.",
|
||||
intentZh: "\u4E0B\u62C9\u6846\u6309 selector \u4E0E value \u9009\u62E9\u3002",
|
||||
triggersEn: "dropdown select, pick option",
|
||||
triggersZh: "\u4E0B\u62C9\u9009\u62E9,\u9009\u9009\u9879"
|
||||
},
|
||||
"hover-element": {
|
||||
intentEn: "Hover an element by selector.",
|
||||
intentZh: "\u60AC\u505C\u5230\u6307\u5B9A\u5143\u7D20\u3002",
|
||||
triggersEn: "mouse over, hover menu",
|
||||
triggersZh: "\u9F20\u6807\u60AC\u505C,\u5212\u8FC7\u83DC\u5355"
|
||||
},
|
||||
"scroll-element": {
|
||||
intentEn: "Scroll an element into view or by selector.",
|
||||
intentZh: "\u6EDA\u52A8\u6307\u5B9A\u5143\u7D20\u6216\u533A\u57DF\u3002",
|
||||
triggersEn: "scroll into view, page scroll",
|
||||
triggersZh: "\u6EDA\u52A8\u5230\u53EF\u89C1,\u9875\u9762\u6EDA\u52A8"
|
||||
},
|
||||
"press-key": {
|
||||
intentEn: "Press a keyboard key (optional focused selector).",
|
||||
intentZh: "\u6A21\u62DF\u6309\u952E\uFF08\u53EF\u9009\u9650\u5B9A\u5728\u5143\u7D20\u4E0A\uFF09\u3002",
|
||||
triggersEn: "hit Enter, keyboard shortcut",
|
||||
triggersZh: "\u6309\u56DE\u8F66,\u5FEB\u6377\u952E"
|
||||
},
|
||||
"evaluate-script": {
|
||||
intentEn: "Run JavaScript in the page context.",
|
||||
intentZh: "\u5728\u9875\u9762\u4E0A\u4E0B\u6587\u6267\u884C JS\u3002",
|
||||
triggersEn: "execute js, run script in page",
|
||||
triggersZh: "\u6267\u884C\u811A\u672C,\u9875\u9762JS"
|
||||
},
|
||||
"drag-element": {
|
||||
intentEn: "Drag an element to a target element.",
|
||||
intentZh: "\u62D6\u62FD\u5143\u7D20\u5230\u53E6\u4E00\u5143\u7D20\u3002",
|
||||
triggersEn: "drag and drop, dnd",
|
||||
triggersZh: "\u62D6\u62FD,\u62D6\u653E"
|
||||
},
|
||||
"iframe-click-element": {
|
||||
intentEn: "Click inside an iframe by iframe and inner selectors.",
|
||||
intentZh: "\u5728 iframe \u5185\u70B9\u51FB\u5B50\u5143\u7D20\u3002",
|
||||
triggersEn: "iframe click, nested frame",
|
||||
triggersZh: "\u6846\u67B6\u5185\u70B9\u51FB,iframe\u91CC\u70B9"
|
||||
}
|
||||
};
|
||||
function formatDescription(rec) {
|
||||
return `${rec.intentEn} / ${rec.intentZh} | Triggers: ${rec.triggersEn}; ${rec.triggersZh}`;
|
||||
}
|
||||
function buildMcpToolDescription(toolName, legacyFallback) {
|
||||
const rec = TOOL_INTENT_BY_NAME[toolName];
|
||||
return rec ? formatDescription(rec) : legacyFallback;
|
||||
}
|
||||
function buildCliCommandDescription(commandName, legacyFallback) {
|
||||
return buildMcpToolDescription(commandName, legacyFallback);
|
||||
}
|
||||
|
||||
// ../core/src/handlers/browser.ts
|
||||
var import_node_os = __toESM(require("os"));
|
||||
|
||||
// ../core/src/utils/requestBuilder.ts
|
||||
function toContractValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -995,18 +1294,53 @@ function buildNestedConfig(config2) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ../core/src/utils/openBrowserHeadless.ts
|
||||
function isTruthyCi(env) {
|
||||
const v = env.CI;
|
||||
return v === "true" || v === "1" || v === "True";
|
||||
}
|
||||
function linuxLooksHeadless(env) {
|
||||
const d = (env.DISPLAY ?? "").trim();
|
||||
const w = (env.WAYLAND_DISPLAY ?? "").trim();
|
||||
return d === "" && w === "";
|
||||
}
|
||||
function resolveOpenBrowserHeadless(params, env, platform) {
|
||||
if (params.headless !== void 0) {
|
||||
return { params: { ...params }, didAutoSetHeadless: false };
|
||||
}
|
||||
let needHeadless = false;
|
||||
if (isTruthyCi(env)) {
|
||||
needHeadless = true;
|
||||
} else if (platform === "linux" && linuxLooksHeadless(env)) {
|
||||
needHeadless = true;
|
||||
}
|
||||
if (!needHeadless) {
|
||||
return { params: { ...params }, didAutoSetHeadless: false };
|
||||
}
|
||||
return {
|
||||
params: { ...params, headless: "1" },
|
||||
didAutoSetHeadless: true
|
||||
};
|
||||
}
|
||||
|
||||
// ../core/src/handlers/browser.ts
|
||||
var browserHandlers = {
|
||||
async openBrowser(params) {
|
||||
const requestBody = buildRequestBodyFor("open-browser", params);
|
||||
const { params: resolvedParams, didAutoSetHeadless } = resolveOpenBrowserHeadless(
|
||||
params,
|
||||
process.env,
|
||||
import_node_os.default.platform()
|
||||
);
|
||||
const requestBody = buildRequestBodyFor("open-browser", resolvedParams);
|
||||
const response = await getApiClient().post(`${getLocalApiBase()}${API_ENDPOINTS.START_BROWSER}`, requestBody);
|
||||
if (response.data.code === 0) {
|
||||
const autoNote = didAutoSetHeadless ? "\n\u5DF2\u6839\u636E\u8FD0\u884C\u73AF\u5883\u81EA\u52A8\u4F7F\u7528 headless=1\uFF08\u65E0\u53EF\u7528\u56FE\u5F62\u4F1A\u8BDD\uFF09\u3002\nAuto-set headless=1 (no graphical session detected)." : "";
|
||||
return `Browser opened successfully with: ${Object.entries(response.data.data).map(([key, value]) => {
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).map(([key2, value2]) => `ws.${key2}: ${value2}`).join("\n");
|
||||
}
|
||||
return `${key}: ${value}`;
|
||||
}).join("\n")}`;
|
||||
}).join("\n")}${autoNote}`;
|
||||
}
|
||||
throw new Error(`Failed to open browser: ${response.data.msg}`);
|
||||
},
|
||||
@@ -1287,7 +1621,7 @@ var import_path = __toESM(require("path"));
|
||||
var import_os = __toESM(require("os"));
|
||||
|
||||
// ../core/src/utils/browserBase.ts
|
||||
var import_playwright = require("playwright");
|
||||
var import_playwright_core = require("playwright-core");
|
||||
var BrowserBase = class {
|
||||
browser;
|
||||
page;
|
||||
@@ -1322,7 +1656,7 @@ var BrowserBase = class {
|
||||
}
|
||||
}
|
||||
async connectBrowserWithWs(wsUrl) {
|
||||
this.browser = await import_playwright.chromium.connectOverCDP(wsUrl);
|
||||
this.browser = await import_playwright_core.chromium.connectOverCDP(wsUrl);
|
||||
const defaultContext = this.browser.contexts()[0];
|
||||
this.page = defaultContext.pages()[0];
|
||||
await this.page.bringToFront().catch((error) => {
|
||||
@@ -1926,11 +2260,17 @@ var schemas = {
|
||||
platform_account: platformAccountSchema
|
||||
}).strict(),
|
||||
openBrowserSchema: import_zod.z.object({
|
||||
profile_no: import_zod.z.string().optional().describe("Priority will be given to user id when profile_id is filled."),
|
||||
profile_id: import_zod.z.string().optional().describe("Unique profile ID, generated after creating profile. The profile id of the browser to open"),
|
||||
profile_no: import_zod.z.string().optional().describe(
|
||||
"Profile serial number (environment number). \u73AF\u5883\u5E8F\u53F7/\u7F16\u53F7\u3002At least one of profile_id or profile_no is required; if both are provided, profile_id takes priority (AdsPower API). profile_id \u4E0E profile_no \u81F3\u5C11\u586B\u5176\u4E00\uFF1B\u540C\u65F6\u586B\u5199\u65F6 profile_id \u4F18\u5148\uFF08\u4E0E AdsPower API \u4E00\u81F4\uFF09\u3002"
|
||||
),
|
||||
profile_id: import_zod.z.string().optional().describe(
|
||||
"Unique profile ID generated after creating a profile; identifies which browser to open. \u521B\u5EFA\u73AF\u5883\u540E\u751F\u6210\u7684\u552F\u4E00 profile ID\uFF0C\u7528\u4E8E\u6307\u5B9A\u8981\u6253\u5F00\u7684\u73AF\u5883\u3002At least one of profile_id or profile_no is required; if both are provided, profile_id takes priority (AdsPower API). profile_id \u4E0E profile_no \u81F3\u5C11\u586B\u5176\u4E00\uFF1B\u540C\u65F6\u586B\u5199\u65F6 profile_id \u4F18\u5148\uFF08\u4E0E AdsPower API \u4E00\u81F4\uFF09\u3002"
|
||||
),
|
||||
ip_tab: import_zod.z.enum(["0", "1"]).optional().describe("The ip tab of the browser, 0 is not use ip tab, 1 is use ip tab, default is 0"),
|
||||
launch_args: import_zod.z.union([import_zod.z.string(), import_zod.z.array(import_zod.z.string())]).optional().describe("The launch args of the browser, use chrome launch args, or vista url"),
|
||||
headless: import_zod.z.enum(["0", "1"]).optional().describe("Headless browser switch, 0 disabled, 1 enabled."),
|
||||
headless: import_zod.z.enum(["0", "1"]).optional().describe(
|
||||
'Headless: "0" visible window, "1" headless. \u65E0\u5934\u6A21\u5F0F\u5F00\u5173\u3002If omitted, the runtime may auto-set "1" when no graphical session is detected (Linux without DISPLAY/WAYLAND, or CI). Explicit "0"/"1" is always sent as-is.'
|
||||
),
|
||||
last_opened_tabs: import_zod.z.enum(["0", "1"]).optional().describe("Whether to restore the last opened tabs."),
|
||||
proxy_detection: import_zod.z.enum(["0", "1"]).optional().describe("Whether to enable proxy detection."),
|
||||
password_filling: import_zod.z.enum(["0", "1"]).optional().describe("Whether to enable password auto-filling."),
|
||||
@@ -2131,131 +2471,164 @@ var schemas = {
|
||||
var STATELESS_HANDLERS = {
|
||||
"open-browser": {
|
||||
fn: browserHandlers.openBrowser,
|
||||
description: "Open the browser, both environment and profile mean browser"
|
||||
description: buildCliCommandDescription(
|
||||
"open-browser",
|
||||
"Open the browser, both environment and profile mean browser"
|
||||
)
|
||||
},
|
||||
"close-browser": {
|
||||
fn: browserHandlers.closeBrowser,
|
||||
description: "Close the browser"
|
||||
description: buildCliCommandDescription("close-browser", "Close the browser")
|
||||
},
|
||||
"create-browser": {
|
||||
fn: browserHandlers.createBrowser,
|
||||
description: "Create a browser"
|
||||
description: buildCliCommandDescription("create-browser", "Create a browser")
|
||||
},
|
||||
"update-browser": {
|
||||
fn: browserHandlers.updateBrowser,
|
||||
description: "Update the browser"
|
||||
description: buildCliCommandDescription("update-browser", "Update the browser")
|
||||
},
|
||||
"delete-browser": {
|
||||
fn: browserHandlers.deleteBrowser,
|
||||
description: "Delete the browser"
|
||||
description: buildCliCommandDescription("delete-browser", "Delete the browser")
|
||||
},
|
||||
"get-browser-list": {
|
||||
fn: browserHandlers.getBrowserList,
|
||||
description: "Get the list of browsers"
|
||||
description: buildCliCommandDescription("get-browser-list", "Get the list of browsers")
|
||||
},
|
||||
"get-opened-browser": {
|
||||
fn: browserHandlers.getOpenedBrowser,
|
||||
description: "Get the list of opened browsers"
|
||||
description: buildCliCommandDescription("get-opened-browser", "Get the list of opened browsers")
|
||||
},
|
||||
"move-browser": {
|
||||
fn: browserHandlers.moveBrowser,
|
||||
description: "Move browsers to a group"
|
||||
description: buildCliCommandDescription("move-browser", "Move browsers to a group")
|
||||
},
|
||||
"get-profile-cookies": {
|
||||
fn: browserHandlers.getProfileCookies,
|
||||
description: "Query and return cookies of the specified profile. Only one profile can be queried per request."
|
||||
description: buildCliCommandDescription(
|
||||
"get-profile-cookies",
|
||||
"Query and return cookies of the specified profile. Only one profile can be queried per request."
|
||||
)
|
||||
},
|
||||
"get-profile-ua": {
|
||||
fn: browserHandlers.getProfileUa,
|
||||
description: "Query and return the User-Agent of specified profiles. Up to 10 profiles can be queried per request."
|
||||
description: buildCliCommandDescription(
|
||||
"get-profile-ua",
|
||||
"Query and return the User-Agent of specified profiles. Up to 10 profiles can be queried per request."
|
||||
)
|
||||
},
|
||||
"close-all-profiles": {
|
||||
fn: browserHandlers.closeAllProfiles,
|
||||
description: "Close all opened profiles on the current device"
|
||||
description: buildCliCommandDescription(
|
||||
"close-all-profiles",
|
||||
"Close all opened profiles on the current device"
|
||||
)
|
||||
},
|
||||
"new-fingerprint": {
|
||||
fn: browserHandlers.newFingerprint,
|
||||
description: "Generate a new fingerprint for specified profiles. Up to 10 profiles are supported per request."
|
||||
description: buildCliCommandDescription(
|
||||
"new-fingerprint",
|
||||
"Generate a new fingerprint for specified profiles. Up to 10 profiles are supported per request."
|
||||
)
|
||||
},
|
||||
"delete-cache-v2": {
|
||||
fn: browserHandlers.deleteCacheV2,
|
||||
description: "Clear local cache of specific profiles.For account security, please ensure that there are no open browsers on the device when using this interface."
|
||||
description: buildCliCommandDescription(
|
||||
"delete-cache-v2",
|
||||
"Clear local cache of specific profiles.For account security, please ensure that there are no open browsers on the device when using this interface."
|
||||
)
|
||||
},
|
||||
"share-profile": {
|
||||
fn: browserHandlers.shareProfile,
|
||||
description: "Share profiles via account email or phone number. The maximum number of profiles that can be shared at one time is 200."
|
||||
description: buildCliCommandDescription(
|
||||
"share-profile",
|
||||
"Share profiles via account email or phone number. The maximum number of profiles that can be shared at one time is 200."
|
||||
)
|
||||
},
|
||||
"get-browser-active": {
|
||||
fn: browserHandlers.getBrowserActive,
|
||||
description: "Get active browser profile information"
|
||||
description: buildCliCommandDescription("get-browser-active", "Get active browser profile information")
|
||||
},
|
||||
"get-cloud-active": {
|
||||
fn: browserHandlers.getCloudActive,
|
||||
description: 'Query the status of browser profiles by user_id, up to 100 profiles per request. If the team has enabled "Multi device mode," specific statuses cannot be retrieved and the response will indicate "Profile not opened."'
|
||||
description: buildCliCommandDescription(
|
||||
"get-cloud-active",
|
||||
'Query the status of browser profiles by user_ids, up to 100 profiles per request. If the team has enabled "Multi device mode," specific statuses cannot be retrieved and the response will indicate "Profile not opened."'
|
||||
)
|
||||
},
|
||||
"create-group": {
|
||||
fn: groupHandlers.createGroup,
|
||||
description: "Create a browser group"
|
||||
description: buildCliCommandDescription("create-group", "Create a browser group")
|
||||
},
|
||||
"update-group": {
|
||||
fn: groupHandlers.updateGroup,
|
||||
description: "Update the browser group"
|
||||
description: buildCliCommandDescription("update-group", "Update the browser group")
|
||||
},
|
||||
"get-group-list": {
|
||||
fn: groupHandlers.getGroupList,
|
||||
description: "Get the list of groups"
|
||||
description: buildCliCommandDescription("get-group-list", "Get the list of groups")
|
||||
},
|
||||
"check-status": {
|
||||
fn: applicationHandlers.checkStatus,
|
||||
description: "Check the availability of the current device API interface (Connection Status)"
|
||||
description: buildCliCommandDescription(
|
||||
"check-status",
|
||||
"Check the availability of the current device API interface (Connection Status)"
|
||||
)
|
||||
},
|
||||
"get-application-list": {
|
||||
fn: applicationHandlers.getApplicationList,
|
||||
description: "Get the list of applications (categories)"
|
||||
description: buildCliCommandDescription(
|
||||
"get-application-list",
|
||||
"Get the list of applications (categories)"
|
||||
)
|
||||
},
|
||||
"create-proxy": {
|
||||
fn: proxyHandlers.createProxy,
|
||||
description: "Create the proxy"
|
||||
description: buildCliCommandDescription("create-proxy", "Create the proxy")
|
||||
},
|
||||
"update-proxy": {
|
||||
fn: proxyHandlers.updateProxy,
|
||||
description: "Update the proxy"
|
||||
description: buildCliCommandDescription("update-proxy", "Update the proxy")
|
||||
},
|
||||
"get-proxy-list": {
|
||||
fn: proxyHandlers.getProxyList,
|
||||
description: "Get the list of proxies"
|
||||
description: buildCliCommandDescription("get-proxy-list", "Get the list of proxies")
|
||||
},
|
||||
"delete-proxy": {
|
||||
fn: proxyHandlers.deleteProxy,
|
||||
description: "Delete the proxy"
|
||||
description: buildCliCommandDescription("delete-proxy", "Delete the proxy")
|
||||
},
|
||||
"get-tag-list": {
|
||||
fn: tagHandlers.getTagList,
|
||||
description: "Get the list of browser tags"
|
||||
description: buildCliCommandDescription("get-tag-list", "Get the list of browser tags")
|
||||
},
|
||||
"create-tag": {
|
||||
fn: tagHandlers.createTag,
|
||||
description: "Create browser tags (batch supported)"
|
||||
description: buildCliCommandDescription("create-tag", "Create browser tags (batch supported)")
|
||||
},
|
||||
"update-tag": {
|
||||
fn: tagHandlers.updateTag,
|
||||
description: "Update browser tags (batch supported)"
|
||||
description: buildCliCommandDescription("update-tag", "Update browser tags (batch supported)")
|
||||
},
|
||||
"delete-tag": {
|
||||
fn: tagHandlers.deleteTag,
|
||||
description: "Delete browser tags"
|
||||
description: buildCliCommandDescription("delete-tag", "Delete browser tags")
|
||||
},
|
||||
"download-kernel": {
|
||||
fn: kernelHandlers.downloadKernel,
|
||||
description: "Download or update a browser kernel version"
|
||||
description: buildCliCommandDescription(
|
||||
"download-kernel",
|
||||
"Download or update a browser kernel version"
|
||||
)
|
||||
},
|
||||
"get-kernel-list": {
|
||||
fn: kernelHandlers.getKernelList,
|
||||
description: "Get browser kernel list by type or all"
|
||||
description: buildCliCommandDescription("get-kernel-list", "Get browser kernel list by type or all")
|
||||
},
|
||||
"update-patch": {
|
||||
fn: patchHandlers.updatePatch,
|
||||
description: "Update AdsPower to latest patch version"
|
||||
description: buildCliCommandDescription("update-patch", "Update AdsPower to latest patch version")
|
||||
}
|
||||
};
|
||||
var SINGLE_PROFILE_ID_COMMANDS = {
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test:e2e:local-api-mcp": "pnpm -w test:e2e:local-api-mcp"
|
||||
"test:e2e:local-api-mcp": "pnpm -w test:e2e:local-api-mcp",
|
||||
"test:e2e:local-api-mcp:light": "pnpm -w test:e2e:local-api-mcp:light",
|
||||
"test:e2e:local-api-mcp:full": "pnpm -w test:e2e:local-api-mcp:full",
|
||||
"test:e2e:local-api-mcp:light:verbose": "pnpm -w test:e2e:local-api-mcp:light:verbose",
|
||||
"test:e2e:local-api-mcp:full:verbose": "pnpm -w test:e2e:local-api-mcp:full:verbose"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
|
||||
@@ -5,14 +5,30 @@
|
||||
## 根目录命令
|
||||
|
||||
```bash
|
||||
# 仅跑 e2e 契约文件(含 MCP stdio + 可选真实 Local API 用例)
|
||||
# 默认轻量真实回归(自动开启 ADSP_MCP_E2E_ENABLED=1,不启用高副作用工具)
|
||||
pnpm test:e2e:local-api-mcp
|
||||
|
||||
# 显式轻量(等价于默认)
|
||||
pnpm test:e2e:local-api-mcp:light
|
||||
|
||||
# 全量真实回归(自动开启 ADSP_MCP_E2E_ENABLED=1,启用高副作用工具)
|
||||
pnpm test:e2e:local-api-mcp:full
|
||||
|
||||
# 一键开启真实 E2E(自动带 ADSP_MCP_E2E_ENABLED=1)
|
||||
pnpm test:e2e:local-api-mcp:light:real
|
||||
pnpm test:e2e:local-api-mcp:full:real
|
||||
|
||||
# 带每条测试用例输出(排查“长时间无输出”时推荐)
|
||||
pnpm test:e2e:local-api-mcp:light:verbose
|
||||
pnpm test:e2e:local-api-mcp:full:verbose
|
||||
pnpm test:e2e:local-api-mcp:light:real:verbose
|
||||
pnpm test:e2e:local-api-mcp:full:real:verbose
|
||||
```
|
||||
|
||||
等价于:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts
|
||||
ADSP_MCP_E2E_ENABLED=1 ADSP_MCP_E2E_HIGH_IMPACT=0 ADSP_LOCAL_API_MIN_INTERVAL_MS=1000 pnpm exec vitest run --config vitest.config.ts packages/local-api-mcp/tests/e2e-tool-validation/e2e-tools-parameter-validation.contracts.test.ts
|
||||
```
|
||||
|
||||
## 包内命令
|
||||
@@ -21,6 +37,14 @@ pnpm exec vitest run --config vitest.config.ts packages/local-api-mcp/tests/e2e-
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:local-api-mcp
|
||||
pnpm test:e2e:local-api-mcp:light
|
||||
pnpm test:e2e:local-api-mcp:full
|
||||
pnpm test:e2e:local-api-mcp:light:verbose
|
||||
pnpm test:e2e:local-api-mcp:full:verbose
|
||||
pnpm test:e2e:local-api-mcp:light:real
|
||||
pnpm test:e2e:local-api-mcp:full:real
|
||||
pnpm test:e2e:local-api-mcp:light:real:verbose
|
||||
pnpm test:e2e:local-api-mcp:full:real:verbose
|
||||
```
|
||||
|
||||
实际会调用 workspace 根目录的同名脚本。
|
||||
@@ -35,19 +59,24 @@ pnpm --filter local-api-mcp-typescript run build
|
||||
|
||||
## 真实 Local API(可选)
|
||||
|
||||
默认仅跑不依赖 AdsPower 的契约;以下环境变量打开「真实 API」用例块:
|
||||
默认脚本已经开启真实 API(`ADSP_MCP_E2E_ENABLED=1`);以下变量用于理解和覆写行为:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `ADSP_MCP_E2E_ENABLED=1` | 打开 `describe.skipIf` 下的真实 API 测试 |
|
||||
| `PORT` | Local API 端口(core 默认 `50326`),需与本机 AdsPower 一致 |
|
||||
| `API_KEY` | 若 Local API 启用了鉴权则设置 |
|
||||
| 变量 | 轻量 | 全量 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ADSP_MCP_E2E_ENABLED=1` | 脚本内置 | 脚本内置 | 打开 `describe.skipIf` 下的真实 API 测试 |
|
||||
| `ADSP_MCP_E2E_HIGH_IMPACT` | `0`(默认) | `1` | 高副作用工具 gate(如 `new-fingerprint/delete-cache-v2/share-profile`) |
|
||||
| `PORT` | 可选 | 可选 | Local API 端口(core 默认 `50325`),需与本机 AdsPower 一致 |
|
||||
| `API_KEY` | 可选 | 可选 | 若 Local API 启用了鉴权则设置 |
|
||||
| `ADSP_LOCAL_API_MIN_INTERVAL_MS` | `1000`(脚本内置) | `1000`(脚本内置) | 请求最小间隔;测试超时会动态放宽 |
|
||||
|
||||
示例:
|
||||
示例(也可直接用上面的 `*:real` 脚本):
|
||||
|
||||
```bash
|
||||
pnpm --filter local-api-mcp-typescript run build
|
||||
ADSP_MCP_E2E_ENABLED=1 PORT=50325 pnpm test:e2e:local-api-mcp
|
||||
PORT=50325 pnpm test:e2e:local-api-mcp:light
|
||||
|
||||
pnpm --filter local-api-mcp-typescript run build
|
||||
PORT=50325 pnpm test:e2e:local-api-mcp:full
|
||||
```
|
||||
|
||||
`readE2EEnv` 测试使用 `ADSP_LOCAL_API_BASE_URL` 与 `ADSP_MCP_E2E_ENABLED`;MCP 子进程访问 Local API 仍以 **`PORT` / `API_KEY`** 为准(见 `packages/core/src/constants/config.ts`)。
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { compareCookieContains } from './cookieMatcher.js';
|
||||
import { normalizeForCompare } from './normalizers.js';
|
||||
|
||||
export type CompareBStatus = 'passed' | 'failed' | 'skipped';
|
||||
|
||||
export type CompareBRow = {
|
||||
inputPath: string;
|
||||
actualPath: string;
|
||||
status: CompareBStatus;
|
||||
reason?: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
};
|
||||
|
||||
export type CompareBResult = {
|
||||
rows: CompareBRow[];
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
export type CompareBMapping = {
|
||||
actualPath?: string;
|
||||
matcher?: 'cookie_contains';
|
||||
};
|
||||
|
||||
function hasOwn(obj: unknown, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function getByPath(root: unknown, path: string): { found: boolean; value: unknown } {
|
||||
if (!path) {
|
||||
return { found: true, value: root };
|
||||
}
|
||||
const parts = path.split('.');
|
||||
let cur: unknown = root;
|
||||
for (const part of parts) {
|
||||
if (typeof cur !== 'object' || cur === null || !hasOwn(cur, part)) {
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
cur = (cur as Record<string, unknown>)[part];
|
||||
}
|
||||
return { found: true, value: cur };
|
||||
}
|
||||
|
||||
function rowStatusCount(rows: CompareBRow[]): CompareBResult {
|
||||
return {
|
||||
rows,
|
||||
passed: rows.filter((r) => r.status === 'passed').length,
|
||||
failed: rows.filter((r) => r.status === 'failed').length,
|
||||
skipped: rows.filter((r) => r.status === 'skipped').length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule B:
|
||||
* - path missing in readback => skipped
|
||||
* - path present but mismatch => failed
|
||||
* - path present and equal (after normalization) => passed
|
||||
*/
|
||||
export function compareInputAgainstReadbackB(
|
||||
input: Record<string, unknown>,
|
||||
readback: unknown,
|
||||
mapping: Record<string, CompareBMapping> = {},
|
||||
): CompareBResult {
|
||||
const rows: CompareBRow[] = [];
|
||||
for (const inputPath of Object.keys(input)) {
|
||||
const map = mapping[inputPath];
|
||||
const actualPath = map?.actualPath ?? inputPath;
|
||||
const expected = input[inputPath];
|
||||
const actualNode = getByPath(readback, actualPath);
|
||||
if (!actualNode.found) {
|
||||
rows.push({
|
||||
inputPath,
|
||||
actualPath,
|
||||
status: 'skipped',
|
||||
reason: 'readback_path_missing',
|
||||
expected,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (map?.matcher === 'cookie_contains') {
|
||||
const cookieRes = compareCookieContains(expected, actualNode.value);
|
||||
rows.push({
|
||||
inputPath,
|
||||
actualPath,
|
||||
status: cookieRes.status,
|
||||
reason: cookieRes.reason,
|
||||
expected,
|
||||
actual: actualNode.value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedExpected = normalizeForCompare(expected);
|
||||
const normalizedActual = normalizeForCompare(actualNode.value);
|
||||
if (JSON.stringify(normalizedExpected) === JSON.stringify(normalizedActual)) {
|
||||
rows.push({
|
||||
inputPath,
|
||||
actualPath,
|
||||
status: 'passed',
|
||||
expected: normalizedExpected,
|
||||
actual: normalizedActual,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
inputPath,
|
||||
actualPath,
|
||||
status: 'failed',
|
||||
reason: 'value_mismatch',
|
||||
expected: normalizedExpected,
|
||||
actual: normalizedActual,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rowStatusCount(rows);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
export type CookieCompareStatus = 'passed' | 'failed' | 'skipped';
|
||||
|
||||
export type CookieCompareResult = {
|
||||
status: CookieCompareStatus;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
function compactCookieText(s: string): string {
|
||||
return s.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie comparison uses contains-matching per agreed design.
|
||||
*/
|
||||
export function compareCookieContains(
|
||||
expectedCookie: unknown,
|
||||
actualCookie: unknown,
|
||||
): CookieCompareResult {
|
||||
if (typeof expectedCookie !== 'string' || expectedCookie.trim() === '') {
|
||||
return { status: 'skipped', reason: 'cookie_expected_not_string' };
|
||||
}
|
||||
if (typeof actualCookie !== 'string' || actualCookie.trim() === '') {
|
||||
return { status: 'skipped', reason: 'cookie_actual_not_string' };
|
||||
}
|
||||
const expectedCompact = compactCookieText(expectedCookie);
|
||||
const actualCompact = compactCookieText(actualCookie);
|
||||
if (actualCompact.includes(expectedCompact)) {
|
||||
return { status: 'passed' };
|
||||
}
|
||||
|
||||
// Some environments return truncated or transformed cookie views.
|
||||
if (
|
||||
actualCompact.length < expectedCompact.length ||
|
||||
actualCookie.includes('...') ||
|
||||
actualCookie.includes('***')
|
||||
) {
|
||||
return { status: 'skipped', reason: 'cookie_not_substring_match_or_truncated' };
|
||||
}
|
||||
return { status: 'failed', reason: 'cookie_substring_mismatch' };
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
function findJsonBoundary(text: string, start: number): number {
|
||||
const open = text[start];
|
||||
const close = open === '{' ? '}' : open === '[' ? ']' : '';
|
||||
if (!close) {
|
||||
throw new Error(`Unsupported JSON start token at index ${start}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === open) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === close) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('JSON payload is incomplete or not closed');
|
||||
}
|
||||
|
||||
export function extractJsonPayloadFromToolText(text: string): unknown {
|
||||
const firstObject = text.indexOf('{');
|
||||
const firstArray = text.indexOf('[');
|
||||
const candidates = [firstObject, firstArray].filter((x) => x >= 0);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('No JSON payload found in tool text');
|
||||
}
|
||||
const start = Math.min(...candidates);
|
||||
const end = findJsonBoundary(text, start);
|
||||
const jsonText = text.slice(start, end).trim();
|
||||
try {
|
||||
return JSON.parse(jsonText);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to parse JSON payload: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractJsonPayloadAfterPrefix(text: string, prefix: string): unknown {
|
||||
const i = text.indexOf(prefix);
|
||||
if (i < 0) {
|
||||
throw new Error(`Prefix not found: ${prefix}`);
|
||||
}
|
||||
const sliced = text.slice(i + prefix.length).trim();
|
||||
return extractJsonPayloadFromToolText(sliced);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize values before comparison to reduce API shape noise.
|
||||
* - number/boolean => string
|
||||
* - string => trimmed string
|
||||
* - array/object => recursive normalization
|
||||
*/
|
||||
export function normalizeForCompare(v: unknown): unknown {
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
return String(v);
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return v.trim();
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => normalizeForCompare(x));
|
||||
}
|
||||
if (!isPlainObject(v)) {
|
||||
return v;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(v).sort()) {
|
||||
out[key] = normalizeForCompare(v[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { uniqueLabel } from '../../fixtures/resourceFactory.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { CaseRunResult, McpTestClient } from '../../types.js';
|
||||
|
||||
type AutomationChainCtx = {
|
||||
client: McpTestClient;
|
||||
profileId: string;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
const FAIL_CLOSED_KEYWORDS = [
|
||||
'failed',
|
||||
'error',
|
||||
'timeout',
|
||||
'exception',
|
||||
'not found',
|
||||
'cannot',
|
||||
'denied',
|
||||
];
|
||||
|
||||
function parseProfileIdFromCreateText(text: string): string | null {
|
||||
const m = text.match(/profile_id:\s*([^\s\n]+)/i);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractWsUrl(text: string): string | null {
|
||||
const wsField = text.match(/ws\.puppeteer["']?\s*[:=]\s*["'](wss?:\/\/[^"'\s]+)["']/i);
|
||||
if (wsField?.[1]) {
|
||||
return wsField[1];
|
||||
}
|
||||
const generic = text.match(/wss?:\/\/[^\s"'\\]+/i);
|
||||
return generic?.[0] ?? null;
|
||||
}
|
||||
|
||||
function summarize(text: string, max = 240): string {
|
||||
const compact = text.replace(/\s+/g, ' ').trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, max)}...`;
|
||||
}
|
||||
|
||||
function hasFailClosedKeyword(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return FAIL_CLOSED_KEYWORDS.some((kw) => lower.includes(kw));
|
||||
}
|
||||
|
||||
function isBrowserNotConnectedText(text: string): boolean {
|
||||
return text.toLowerCase().includes('browser not connected');
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function runAutomationChainWithRetry(client: McpTestClient, wsUrl: string): Promise<CallToolResult> {
|
||||
const maxAttempts = 3;
|
||||
let lastVisibleRes: CallToolResult | null = null;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
await client.callTool('connect-browser-with-ws', { wsUrl });
|
||||
// WS connection can be accepted before internal page context is fully ready.
|
||||
await sleep(250);
|
||||
await client.callTool('open-new-page', {});
|
||||
await client.callTool('navigate', { url: 'https://example.com' });
|
||||
const visibleRes = (await client.callTool('get-page-visible-text', {})) as CallToolResult;
|
||||
lastVisibleRes = visibleRes;
|
||||
const visibleText = getToolTextContent(visibleRes);
|
||||
if (!isBrowserNotConnectedText(visibleText)) {
|
||||
return visibleRes;
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(400);
|
||||
}
|
||||
}
|
||||
return lastVisibleRes as CallToolResult;
|
||||
}
|
||||
|
||||
function isStrictAutomationVisibleTextPass(visibleText: string): boolean {
|
||||
if (hasFailClosedKeyword(visibleText)) {
|
||||
return false;
|
||||
}
|
||||
const lower = visibleText.toLowerCase();
|
||||
const exampleDomainNarrative =
|
||||
lower.includes('example domain') &&
|
||||
(lower.includes('illustrative examples') || lower.includes('documentation examples'));
|
||||
const urlAndTitleSignal =
|
||||
/(https?:\/\/(www\.)?example\.com)/i.test(visibleText) &&
|
||||
/(title["']?\s*[:=]\s*["']?example domain|example domain)/i.test(visibleText);
|
||||
return exampleDomainNarrative || urlAndTitleSignal;
|
||||
}
|
||||
|
||||
export async function caseAutomationChainBasic(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: AutomationChainCtx = {
|
||||
client,
|
||||
profileId: '',
|
||||
wsUrl: '',
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'automation.chain.basic',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as AutomationChainCtx;
|
||||
const createRes = await x.client.callTool('create-browser', {
|
||||
group_id: '0',
|
||||
name: uniqueLabel('automation-chain'),
|
||||
});
|
||||
const createText = getToolTextContent(createRes as CallToolResult);
|
||||
const profileId = parseProfileIdFromCreateText(createText);
|
||||
if (!profileId) {
|
||||
throw new Error(`profile_id missing after create-browser: ${createText.slice(0, 500)}`);
|
||||
}
|
||||
x.profileId = profileId;
|
||||
|
||||
const openRes = await x.client.callTool('open-browser', {
|
||||
profile_id: x.profileId,
|
||||
headless: '1',
|
||||
});
|
||||
const openText = getToolTextContent(openRes as CallToolResult);
|
||||
const wsUrl = extractWsUrl(openText);
|
||||
if (!wsUrl) {
|
||||
throw new Error(`ws url missing after open-browser: ${openText.slice(0, 500)}`);
|
||||
}
|
||||
x.wsUrl = wsUrl;
|
||||
|
||||
return runAutomationChainWithRetry(x.client, x.wsUrl);
|
||||
},
|
||||
assertState: async (invokeRes, c) => {
|
||||
const x = c as unknown as AutomationChainCtx;
|
||||
const visibleText = getToolTextContent(invokeRes as CallToolResult);
|
||||
const passed = isStrictAutomationVisibleTextPass(visibleText);
|
||||
return {
|
||||
passed,
|
||||
details: passed
|
||||
? [
|
||||
`profile_id=${x.profileId}`,
|
||||
'chain=connect-browser-with-ws>open-new-page>navigate>get-page-visible-text',
|
||||
`ws_url=${x.wsUrl}`,
|
||||
`visible_text=${summarize(visibleText)}`,
|
||||
]
|
||||
: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`ws_url=${x.wsUrl || 'missing'}`,
|
||||
'visible_text_assertion_failed',
|
||||
summarize(visibleText, 600),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as AutomationChainCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const automationChainCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'automation.chain.basic': caseAutomationChainBasic,
|
||||
};
|
||||
|
||||
export const __automationChainInternals = {
|
||||
isStrictAutomationVisibleTextPass,
|
||||
};
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { uniqueLabel } from '../../fixtures/resourceFactory.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { CaseRunResult, McpTestClient } from '../../types.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
|
||||
type LifecycleCtx = {
|
||||
client: McpTestClient;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
type OpenedObservation = {
|
||||
parseOk: boolean;
|
||||
containsProfile: boolean;
|
||||
parseErrorSummary?: string;
|
||||
observedSummary: string;
|
||||
};
|
||||
|
||||
type PollOutcome = {
|
||||
reachedExpectedState: boolean;
|
||||
attempts: number;
|
||||
lastObservation: OpenedObservation;
|
||||
};
|
||||
|
||||
function parseProfileIdFromCreateText(text: string): string | null {
|
||||
const m = text.match(/profile_id:\s*([^\s\n]+)/i);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function payloadContainsProfileId(payload: unknown, profileId: string): boolean {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.some((item) => payloadContainsProfileId(item, profileId));
|
||||
}
|
||||
if (!maybeObject(payload)) {
|
||||
return false;
|
||||
}
|
||||
if (String(payload.profile_id ?? '') === profileId || String(payload.user_id ?? '') === profileId) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(payload).some((child) => payloadContainsProfileId(child, profileId));
|
||||
}
|
||||
|
||||
function summarizeOneLine(value: string, max = 220): string {
|
||||
const compact = value.replace(/\s+/g, ' ').trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, max)}...`;
|
||||
}
|
||||
|
||||
function summarizeParseError(err: unknown): string {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return summarizeOneLine(message, 160);
|
||||
}
|
||||
|
||||
export function __buildOpenedObservationFromText(text: string, profileId: string): OpenedObservation {
|
||||
try {
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
return {
|
||||
parseOk: true,
|
||||
containsProfile: payloadContainsProfileId(payload, profileId),
|
||||
observedSummary: summarizeOneLine(text),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
parseOk: false,
|
||||
containsProfile: false,
|
||||
parseErrorSummary: summarizeParseError(err),
|
||||
observedSummary: summarizeOneLine(text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getOpenedObservation(client: McpTestClient, profileId: string): Promise<OpenedObservation> {
|
||||
const openedRes = await client.callTool('get-opened-browser', {});
|
||||
const openedText = getToolTextContent(openedRes as CallToolResult);
|
||||
return __buildOpenedObservationFromText(openedText, profileId);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function pollOpenedState(
|
||||
client: McpTestClient,
|
||||
profileId: string,
|
||||
expectedOpen: boolean,
|
||||
attempts = 4,
|
||||
intervalMs = 500,
|
||||
): Promise<PollOutcome> {
|
||||
let lastObservation: OpenedObservation = {
|
||||
parseOk: false,
|
||||
containsProfile: false,
|
||||
parseErrorSummary: 'no_observation',
|
||||
observedSummary: '',
|
||||
};
|
||||
|
||||
for (let i = 1; i <= attempts; i += 1) {
|
||||
const observation = await getOpenedObservation(client, profileId);
|
||||
lastObservation = observation;
|
||||
const matched = observation.parseOk && observation.containsProfile === expectedOpen;
|
||||
if (matched) {
|
||||
return {
|
||||
reachedExpectedState: true,
|
||||
attempts: i,
|
||||
lastObservation: observation,
|
||||
};
|
||||
}
|
||||
if (i < attempts) {
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reachedExpectedState: false,
|
||||
attempts,
|
||||
lastObservation,
|
||||
};
|
||||
}
|
||||
|
||||
async function createProfileForLifecycle(client: McpTestClient): Promise<string> {
|
||||
const createRes = await client.callTool('create-browser', {
|
||||
group_id: '0',
|
||||
name: uniqueLabel('lifecycle-profile'),
|
||||
});
|
||||
const createText = getToolTextContent(createRes as CallToolResult);
|
||||
const profileId = parseProfileIdFromCreateText(createText);
|
||||
if (!profileId) {
|
||||
throw new Error(`Could not parse profile_id from create-browser: ${createText.slice(0, 500)}`);
|
||||
}
|
||||
return profileId;
|
||||
}
|
||||
|
||||
export async function caseLifecycleOpenBrowserHeadless(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: LifecycleCtx = { client, profileId: '' };
|
||||
return runCase({
|
||||
name: 'lifecycle.open-browser.headless',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
x.profileId = await createProfileForLifecycle(x.client);
|
||||
return x.client.callTool('open-browser', {
|
||||
profile_id: x.profileId,
|
||||
headless: '1',
|
||||
});
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
const polled = await pollOpenedState(x.client, x.profileId, true);
|
||||
const opened = polled.reachedExpectedState;
|
||||
const parseDiag = polled.lastObservation.parseOk
|
||||
? 'parse_ok'
|
||||
: `parse_error=${polled.lastObservation.parseErrorSummary ?? 'unknown'}`;
|
||||
return {
|
||||
passed: opened,
|
||||
details: opened
|
||||
? [
|
||||
`profile_id=${x.profileId}`,
|
||||
`poll_attempts=${polled.attempts}`,
|
||||
'opened_visible_in_get-opened-browser',
|
||||
parseDiag,
|
||||
`last_observation=${polled.lastObservation.observedSummary}`,
|
||||
]
|
||||
: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`poll_attempts=${polled.attempts}`,
|
||||
'opened_not_visible_in_get-opened-browser',
|
||||
parseDiag,
|
||||
`last_observation=${polled.lastObservation.observedSummary}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseLifecycleCloseBrowserVisibility(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: LifecycleCtx = { client, profileId: '' };
|
||||
return runCase({
|
||||
name: 'lifecycle.close-browser.visibility',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
x.profileId = await createProfileForLifecycle(x.client);
|
||||
await x.client.callTool('open-browser', {
|
||||
profile_id: x.profileId,
|
||||
headless: '1',
|
||||
});
|
||||
return x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
const polled = await pollOpenedState(x.client, x.profileId, false);
|
||||
const closed = polled.reachedExpectedState;
|
||||
const parseDiag = polled.lastObservation.parseOk
|
||||
? 'parse_ok'
|
||||
: `parse_error=${polled.lastObservation.parseErrorSummary ?? 'unknown'}`;
|
||||
return {
|
||||
passed: closed,
|
||||
details: closed
|
||||
? [
|
||||
`profile_id=${x.profileId}`,
|
||||
`poll_attempts=${polled.attempts}`,
|
||||
'closed_not_present_in_get-opened-browser',
|
||||
parseDiag,
|
||||
`last_observation=${polled.lastObservation.observedSummary}`,
|
||||
]
|
||||
: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`poll_attempts=${polled.attempts}`,
|
||||
'close_expected_but_still_present_in_get-opened-browser',
|
||||
parseDiag,
|
||||
`last_observation=${polled.lastObservation.observedSummary}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseLifecycleCloseAllProfilesGlobal(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: LifecycleCtx = { client, profileId: '' };
|
||||
return runCase({
|
||||
name: 'lifecycle.close-all-profiles.global',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
x.profileId = await createProfileForLifecycle(x.client);
|
||||
await x.client.callTool('open-browser', {
|
||||
profile_id: x.profileId,
|
||||
headless: '1',
|
||||
});
|
||||
return x.client.callTool('close-all-profiles', {});
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
const polled = await pollOpenedState(x.client, x.profileId, false);
|
||||
const closed = polled.reachedExpectedState;
|
||||
const parseDiag = polled.lastObservation.parseOk
|
||||
? 'parse_ok'
|
||||
: `parse_error=${polled.lastObservation.parseErrorSummary ?? 'unknown'}`;
|
||||
return {
|
||||
passed: closed,
|
||||
details: closed
|
||||
? [
|
||||
`profile_id=${x.profileId}`,
|
||||
`poll_attempts=${polled.attempts}`,
|
||||
'close_all_cleared_profile_from_get-opened-browser',
|
||||
parseDiag,
|
||||
`last_observation=${polled.lastObservation.observedSummary}`,
|
||||
]
|
||||
: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`poll_attempts=${polled.attempts}`,
|
||||
'close_all_expected_but_profile_still_opened',
|
||||
parseDiag,
|
||||
`last_observation=${polled.lastObservation.observedSummary}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as LifecycleCtx;
|
||||
try {
|
||||
await x.client.callTool('close-all-profiles', {});
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const browserLifecycleCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'lifecycle.open-browser.headless': caseLifecycleOpenBrowserHeadless,
|
||||
'lifecycle.close-browser.visibility': caseLifecycleCloseBrowserVisibility,
|
||||
'lifecycle.close-all-profiles.global': caseLifecycleCloseAllProfilesGlobal,
|
||||
};
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
compareInputAgainstReadbackB,
|
||||
type CompareBResult,
|
||||
type CompareBRow,
|
||||
} from '../../assertions/roundtrip/compareB.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
import { uniqueLabel } from '../../fixtures/resourceFactory.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { McpTestClient } from '../../types.js';
|
||||
|
||||
type BrowserRoundtripCtx = {
|
||||
client: McpTestClient;
|
||||
profileId: string;
|
||||
created: Record<string, unknown>;
|
||||
updated: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractProfileIdFromCreateText(text: string): string {
|
||||
const m = text.match(/profile_id:\s*([^\s\n]+)/i);
|
||||
if (!m?.[1]) {
|
||||
throw new Error(`Could not parse profile_id from create-browser: ${text.slice(0, 500)}`);
|
||||
}
|
||||
return m[1];
|
||||
}
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function findProfileById(payload: unknown, profileId: string): Record<string, unknown> | null {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = findProfileById(item, profileId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!maybeObject(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (String(payload.profile_id ?? '') === profileId) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
for (const v of Object.values(payload)) {
|
||||
const found = findProfileById(v, profileId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getBrowserRecordByProfileId(
|
||||
client: McpTestClient,
|
||||
profileId: string,
|
||||
): Promise<{ record: Record<string, unknown> | null; rawText: string }> {
|
||||
const listRes = await client.callTool('get-browser-list', {
|
||||
profile_id: [profileId],
|
||||
page: 1,
|
||||
limit: 200,
|
||||
});
|
||||
const rawText = getToolTextContent(listRes as CallToolResult);
|
||||
const parsed = extractJsonPayloadFromToolText(rawText);
|
||||
return {
|
||||
record: findProfileById(parsed, profileId),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCompareRows(rows: CompareBRow[]): string[] {
|
||||
return rows.map((r) => `${r.inputPath}->${r.actualPath}:${r.status}${r.reason ? `(${r.reason})` : ''}`);
|
||||
}
|
||||
|
||||
function buildStableCreatePayload(): Record<string, unknown> {
|
||||
const stamp = uniqueLabel('rt-create');
|
||||
return {
|
||||
group_id: '0',
|
||||
username: `${stamp}-u`,
|
||||
password: `${stamp}-p`,
|
||||
cookie: 'session=rt_create_cookie; token=create',
|
||||
name: `${stamp}-name`,
|
||||
remark: `${stamp}-remark`,
|
||||
platform: 'facebook.com',
|
||||
};
|
||||
}
|
||||
|
||||
function buildStableUpdatePayload(profileId: string): Record<string, unknown> {
|
||||
const stamp = uniqueLabel('rt-update');
|
||||
return {
|
||||
profile_id: profileId,
|
||||
username: `${stamp}-u`,
|
||||
password: `${stamp}-p`,
|
||||
cookie: 'session=rt_update_cookie; token=update',
|
||||
name: `${stamp}-name`,
|
||||
remark: `${stamp}-remark`,
|
||||
platform: 'twitter.com',
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoundtripMapping(): Record<string, { actualPath?: string; matcher?: 'cookie_contains' }> {
|
||||
return {
|
||||
cookie: {
|
||||
actualPath: 'cookie',
|
||||
matcher: 'cookie_contains',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const REQUIRED_NON_SKIPPED_FIELDS = ['username', 'password', 'name', 'remark', 'platform'] as const;
|
||||
const ALLOWED_COOKIE_SKIP_REASONS = new Set([
|
||||
'readback_path_missing',
|
||||
'cookie_actual_not_string',
|
||||
'cookie_not_substring_match_or_truncated',
|
||||
]);
|
||||
|
||||
export function isRoundtripCompareAcceptable(compare: CompareBResult): {
|
||||
passed: boolean;
|
||||
reasons: string[];
|
||||
} {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (compare.failed !== 0) {
|
||||
reasons.push(`failed_must_be_zero(actual=${compare.failed})`);
|
||||
}
|
||||
|
||||
for (const field of REQUIRED_NON_SKIPPED_FIELDS) {
|
||||
const row = compare.rows.find((r) => r.inputPath === field);
|
||||
if (!row) {
|
||||
reasons.push(`required_field_missing_row(${field})`);
|
||||
continue;
|
||||
}
|
||||
if (row.status === 'skipped') {
|
||||
reasons.push(`required_field_skipped(${field}:${row.reason ?? 'unknown'})`);
|
||||
}
|
||||
}
|
||||
|
||||
const cookieRow = compare.rows.find((r) => r.inputPath === 'cookie');
|
||||
if (cookieRow?.status === 'skipped') {
|
||||
const reason = cookieRow.reason ?? 'unknown';
|
||||
if (!ALLOWED_COOKIE_SKIP_REASONS.has(reason)) {
|
||||
reasons.push(`cookie_skip_reason_not_allowed(${reason})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: reasons.length === 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
export async function caseBrowserCreateRoundtripFullOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: BrowserRoundtripCtx = {
|
||||
client,
|
||||
profileId: '',
|
||||
created: buildStableCreatePayload(),
|
||||
updated: {},
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'browser.create.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
return x.client.callTool('create-browser', x.created);
|
||||
},
|
||||
assertState: async (createRes, c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
const createText = getToolTextContent(createRes as CallToolResult);
|
||||
x.profileId = extractProfileIdFromCreateText(createText);
|
||||
const list = await getBrowserRecordByProfileId(x.client, x.profileId);
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`profile_id=${x.profileId}`, 'record_missing_after_create', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
|
||||
const compare = compareInputAgainstReadbackB(x.created, list.record, buildRoundtripMapping());
|
||||
const gate = isRoundtripCompareAcceptable(compare);
|
||||
return {
|
||||
passed: gate.passed,
|
||||
details: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...gate.reasons,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildCreateGeoAndAccountPayload(): Record<string, unknown> {
|
||||
const stamp = uniqueLabel('rt-create-geo');
|
||||
return {
|
||||
group_id: '0',
|
||||
username: `${stamp}-u`,
|
||||
password: `${stamp}-p`,
|
||||
name: `${stamp}-name`,
|
||||
remark: `${stamp}-remark`,
|
||||
platform: 'facebook.com',
|
||||
fakey: `${stamp}-2fa`,
|
||||
tabs: ['https://example.com', 'https://example.org'],
|
||||
ignore_cookie_error: '1',
|
||||
repeat_config: [0, 2, 3],
|
||||
ip: '8.8.8.8',
|
||||
country: 'us',
|
||||
region: 'California',
|
||||
city: 'Mountain View',
|
||||
ipchecker: 'ipapi',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCreateProxyAndFingerprintPayload(): Record<string, unknown> {
|
||||
const stamp = uniqueLabel('rt-create-proxy-fp');
|
||||
return {
|
||||
group_id: '0',
|
||||
name: `${stamp}-name`,
|
||||
remark: `${stamp}-remark`,
|
||||
user_proxy_config: {
|
||||
proxy_soft: 'no_proxy',
|
||||
global_config: '0',
|
||||
},
|
||||
fingerprint_config: {
|
||||
audio: '1',
|
||||
scan_port_type: '1',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runCreateRoundtripCase(
|
||||
caseName: string,
|
||||
createPayload: Record<string, unknown>,
|
||||
compareInput: Record<string, unknown>,
|
||||
mapping: Record<string, { actualPath?: string; matcher?: 'cookie_contains' }>,
|
||||
requiredPassedInputPaths: string[],
|
||||
): Promise<{ passed: boolean; details: string[] }> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: BrowserRoundtripCtx = {
|
||||
client,
|
||||
profileId: '',
|
||||
created: createPayload,
|
||||
updated: {},
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: caseName,
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
return x.client.callTool('create-browser', x.created);
|
||||
},
|
||||
assertState: async (createRes, c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
const createText = getToolTextContent(createRes as CallToolResult);
|
||||
x.profileId = extractProfileIdFromCreateText(createText);
|
||||
const list = await getBrowserRecordByProfileId(x.client, x.profileId);
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`profile_id=${x.profileId}`, 'record_missing_after_create', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
|
||||
const compare = compareInputAgainstReadbackB(compareInput, list.record, mapping);
|
||||
const failed = compare.rows.filter((row) => row.status === 'failed');
|
||||
const missingRequired = requiredPassedInputPaths.filter((inputPath) => {
|
||||
const row = compare.rows.find((r) => r.inputPath === inputPath);
|
||||
return !row || row.status !== 'passed';
|
||||
});
|
||||
const passed = failed.length === 0 && missingRequired.length === 0;
|
||||
return {
|
||||
passed,
|
||||
details: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...missingRequired.map((name) => `required_field_not_passed(${name})`),
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseBrowserCreateRoundtripGeoAndAccountOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const payload = buildCreateGeoAndAccountPayload();
|
||||
return runCreateRoundtripCase(
|
||||
'browser.create.roundtrip.geo-and-account-optional-subset',
|
||||
payload,
|
||||
payload,
|
||||
{},
|
||||
// List readback often omits geo/tabs/cookie-error fields; Rule B treats those as skippable.
|
||||
['name', 'fakey', 'username', 'password', 'remark', 'platform', 'group_id', 'ip', 'ipchecker'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseBrowserCreateRoundtripProxyAndFingerprintOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const payload = buildCreateProxyAndFingerprintPayload();
|
||||
const compareInput = {
|
||||
group_id: payload.group_id,
|
||||
name: payload.name,
|
||||
user_proxy_config_proxy_soft: 'no_proxy',
|
||||
user_proxy_config_global_config: '0',
|
||||
fingerprint_config_audio: '1',
|
||||
fingerprint_config_scan_port_type: '1',
|
||||
};
|
||||
return runCreateRoundtripCase(
|
||||
'browser.create.roundtrip.proxy-and-fingerprint-optional-subset',
|
||||
payload,
|
||||
compareInput,
|
||||
{
|
||||
user_proxy_config_proxy_soft: { actualPath: 'user_proxy_config.proxy_soft' },
|
||||
user_proxy_config_global_config: { actualPath: 'user_proxy_config.global_config' },
|
||||
fingerprint_config_audio: { actualPath: 'fingerprint_config.audio' },
|
||||
fingerprint_config_scan_port_type: { actualPath: 'fingerprint_config.scan_port_type' },
|
||||
},
|
||||
// `proxy_soft` is present on readback; nested fingerprint/global fields are often absent from list payloads.
|
||||
['name', 'user_proxy_config_proxy_soft'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseBrowserCreateRoundtripExternalOptionalBlocked(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
return {
|
||||
passed: true,
|
||||
details: [
|
||||
'blocked:create_browser_external_optional_requires_seeded_proxy_category_and_tags',
|
||||
'blocked_fields=proxyid,category_id,profile_tag_ids',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function caseBrowserUpdateRoundtripFullOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: BrowserRoundtripCtx = {
|
||||
client,
|
||||
profileId: '',
|
||||
created: buildStableCreatePayload(),
|
||||
updated: {},
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'browser.update.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
const createRes = await x.client.callTool('create-browser', x.created);
|
||||
const createText = getToolTextContent(createRes as CallToolResult);
|
||||
x.profileId = extractProfileIdFromCreateText(createText);
|
||||
x.updated = buildStableUpdatePayload(x.profileId);
|
||||
return x.client.callTool('update-browser', x.updated);
|
||||
},
|
||||
assertState: async (_updateRes, c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
const list = await getBrowserRecordByProfileId(x.client, x.profileId);
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`profile_id=${x.profileId}`, 'record_missing_after_update', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
const expected = Object.fromEntries(
|
||||
Object.entries(x.updated).filter(([k]) => k !== 'profile_id'),
|
||||
);
|
||||
const compare = compareInputAgainstReadbackB(expected, list.record, buildRoundtripMapping());
|
||||
const gate = isRoundtripCompareAcceptable(compare);
|
||||
return {
|
||||
passed: gate.passed,
|
||||
details: [
|
||||
`profile_id=${x.profileId}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...gate.reasons,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseBrowserDeleteVisibility(): Promise<{ passed: boolean; details: string[] }> {
|
||||
const client = await createMcpClient();
|
||||
|
||||
return runCase({
|
||||
name: 'browser.delete.visibility',
|
||||
prepare: async () =>
|
||||
({
|
||||
client,
|
||||
profileId: '',
|
||||
created: buildStableCreatePayload(),
|
||||
updated: {},
|
||||
}) as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
const createRes = await x.client.callTool('create-browser', x.created);
|
||||
const createText = getToolTextContent(createRes as CallToolResult);
|
||||
x.profileId = extractProfileIdFromCreateText(createText);
|
||||
return x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
},
|
||||
assertState: async (_deleteRes, c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
let list = await getBrowserRecordByProfileId(x.client, x.profileId);
|
||||
let invisible = !list.record;
|
||||
for (let i = 0; i < 3 && !invisible; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
list = await getBrowserRecordByProfileId(x.client, x.profileId);
|
||||
invisible = !list.record;
|
||||
}
|
||||
return {
|
||||
passed: invisible,
|
||||
details: invisible
|
||||
? [`profile_id=${x.profileId}`, 'not_found_after_delete']
|
||||
: [`profile_id=${x.profileId}`, 'still_visible_after_delete', list.rawText.slice(0, 800)],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as BrowserRoundtripCtx;
|
||||
try {
|
||||
if (x.profileId) {
|
||||
await x.client.callTool('close-browser', { profile_id: x.profileId });
|
||||
await x.client.callTool('delete-browser', { profile_id: [x.profileId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const browserRoundtripCases = {
|
||||
'browser.create.roundtrip.full-optional-subset': caseBrowserCreateRoundtripFullOptionalSubset,
|
||||
'browser.create.roundtrip.geo-and-account-optional-subset':
|
||||
caseBrowserCreateRoundtripGeoAndAccountOptionalSubset,
|
||||
'browser.create.roundtrip.proxy-and-fingerprint-optional-subset':
|
||||
caseBrowserCreateRoundtripProxyAndFingerprintOptionalSubset,
|
||||
'browser.create.roundtrip.external-optional.blocked': caseBrowserCreateRoundtripExternalOptionalBlocked,
|
||||
'browser.update.roundtrip.full-optional-subset': caseBrowserUpdateRoundtripFullOptionalSubset,
|
||||
'browser.delete.visibility': caseBrowserDeleteVisibility,
|
||||
};
|
||||
@@ -7,16 +7,136 @@ export type CaseMetadata = {
|
||||
tool: string;
|
||||
/** Optional parameter names covered when the case passes. */
|
||||
optionalParamsOnSuccess: string[];
|
||||
/**
|
||||
* Optional roundtrip metadata for compare rows in `details`.
|
||||
* Maps compare input field -> tool optional parameter name to mark covered
|
||||
* only when row status is `passed`.
|
||||
*/
|
||||
roundtrip?: {
|
||||
optionalCoverageByInputPath?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export const caseMetadata: Record<string, CaseMetadata> = {
|
||||
'group.create.basic': { tool: 'create-group', optionalParamsOnSuccess: [] },
|
||||
'group.create.withRemark': { tool: 'create-group', optionalParamsOnSuccess: ['remark'] },
|
||||
'group.create.roundtrip.full-optional-subset': {
|
||||
tool: 'create-group',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
remark: 'remark',
|
||||
},
|
||||
},
|
||||
},
|
||||
'group.update.roundtrip.full-optional-subset': {
|
||||
tool: 'update-group',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
remark: 'remark',
|
||||
},
|
||||
},
|
||||
},
|
||||
'group.cleanup.visibility': { tool: 'update-group', optionalParamsOnSuccess: ['remark'] },
|
||||
'proxy.create.list.delete': {
|
||||
tool: 'create-proxy',
|
||||
optionalParamsOnSuccess: [],
|
||||
},
|
||||
'proxy.create.roundtrip.full-optional-subset': {
|
||||
tool: 'create-proxy',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
remark: 'remark',
|
||||
},
|
||||
},
|
||||
},
|
||||
'proxy.update.roundtrip.full-optional-subset': {
|
||||
tool: 'update-proxy',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
type: 'type',
|
||||
host: 'host',
|
||||
port: 'port',
|
||||
remark: 'remark',
|
||||
},
|
||||
},
|
||||
},
|
||||
'proxy.delete.visibility': { tool: 'delete-proxy', optionalParamsOnSuccess: [] },
|
||||
'browser.open.headless': { tool: 'open-browser', optionalParamsOnSuccess: ['headless'] },
|
||||
'lifecycle.open-browser.headless': { tool: 'open-browser', optionalParamsOnSuccess: ['headless'] },
|
||||
'lifecycle.close-browser.visibility': { tool: 'close-browser', optionalParamsOnSuccess: [] },
|
||||
'lifecycle.close-all-profiles.global': { tool: 'close-all-profiles', optionalParamsOnSuccess: [] },
|
||||
'browser.create.roundtrip.full-optional-subset': {
|
||||
tool: 'create-browser',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
cookie: 'cookie',
|
||||
name: 'name',
|
||||
remark: 'remark',
|
||||
platform: 'platform',
|
||||
},
|
||||
},
|
||||
},
|
||||
'browser.create.roundtrip.geo-and-account-optional-subset': {
|
||||
tool: 'create-browser',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
name: 'name',
|
||||
remark: 'remark',
|
||||
platform: 'platform',
|
||||
fakey: 'fakey',
|
||||
tabs: 'tabs',
|
||||
ignore_cookie_error: 'ignore_cookie_error',
|
||||
repeat_config: 'repeat_config',
|
||||
ip: 'ip',
|
||||
country: 'country',
|
||||
region: 'region',
|
||||
city: 'city',
|
||||
ipchecker: 'ipchecker',
|
||||
},
|
||||
},
|
||||
},
|
||||
'browser.create.roundtrip.proxy-and-fingerprint-optional-subset': {
|
||||
tool: 'create-browser',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
name: 'name',
|
||||
user_proxy_config_proxy_soft: 'user_proxy_config',
|
||||
user_proxy_config_global_config: 'user_proxy_config',
|
||||
fingerprint_config_audio: 'fingerprint_config',
|
||||
fingerprint_config_scan_port_type: 'fingerprint_config',
|
||||
},
|
||||
},
|
||||
},
|
||||
'browser.create.roundtrip.external-optional.blocked': {
|
||||
tool: 'create-browser',
|
||||
optionalParamsOnSuccess: [],
|
||||
},
|
||||
'browser.update.roundtrip.full-optional-subset': {
|
||||
tool: 'update-browser',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
cookie: 'cookie',
|
||||
name: 'name',
|
||||
remark: 'remark',
|
||||
platform: 'platform',
|
||||
},
|
||||
},
|
||||
},
|
||||
'browser.delete.visibility': { tool: 'delete-browser', optionalParamsOnSuccess: [] },
|
||||
|
||||
'application.list.query': {
|
||||
tool: 'get-application-list',
|
||||
@@ -26,14 +146,91 @@ export const caseMetadata: Record<string, CaseMetadata> = {
|
||||
tool: 'get-tag-list',
|
||||
optionalParamsOnSuccess: ['ids', 'limit', 'page'],
|
||||
},
|
||||
'tag.create.roundtrip.full-optional-subset': {
|
||||
tool: 'create-tag',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {},
|
||||
},
|
||||
},
|
||||
'tag.update.roundtrip.full-optional-subset': {
|
||||
tool: 'update-tag',
|
||||
optionalParamsOnSuccess: [],
|
||||
roundtrip: {
|
||||
optionalCoverageByInputPath: {},
|
||||
},
|
||||
},
|
||||
'tag.delete.visibility': { tool: 'delete-tag', optionalParamsOnSuccess: [] },
|
||||
'kernel.list.chrome': {
|
||||
tool: 'get-kernel-list',
|
||||
optionalParamsOnSuccess: ['kernel_type'],
|
||||
},
|
||||
'highimpact.new-fingerprint.by-profile-id': {
|
||||
tool: 'new-fingerprint',
|
||||
optionalParamsOnSuccess: ['profile_id'],
|
||||
},
|
||||
'highimpact.delete-cache-v2.cookie-only': {
|
||||
tool: 'delete-cache-v2',
|
||||
optionalParamsOnSuccess: ['profile_id', 'type'],
|
||||
},
|
||||
'highimpact.share-profile.with-receiver': {
|
||||
tool: 'share-profile',
|
||||
optionalParamsOnSuccess: ['profile_id', 'receiver'],
|
||||
},
|
||||
'patch.update.stable': {
|
||||
tool: 'update-patch',
|
||||
optionalParamsOnSuccess: ['version_type'],
|
||||
},
|
||||
'patch.update.blocked-by-default': {
|
||||
tool: 'update-patch',
|
||||
optionalParamsOnSuccess: [],
|
||||
},
|
||||
'automation.getOpened': { tool: 'get-opened-browser', optionalParamsOnSuccess: [] },
|
||||
'automation.checkStatus': { tool: 'check-status', optionalParamsOnSuccess: [] },
|
||||
'automation.chain.basic': { tool: 'get-page-visible-text', optionalParamsOnSuccess: [] },
|
||||
'kernel.download.minimal': {
|
||||
tool: 'download-kernel',
|
||||
optionalParamsOnSuccess: ['kernel_type', 'kernel_version'],
|
||||
},
|
||||
'query.get-browser-list.basic': { tool: 'get-browser-list', optionalParamsOnSuccess: [] },
|
||||
'query.get-browser-list.by-profile-id': {
|
||||
tool: 'get-browser-list',
|
||||
optionalParamsOnSuccess: ['profile_id'],
|
||||
},
|
||||
'query.get-group-list.basic': { tool: 'get-group-list', optionalParamsOnSuccess: [] },
|
||||
'query.get-group-list.by-group-name': {
|
||||
tool: 'get-group-list',
|
||||
optionalParamsOnSuccess: ['group_name'],
|
||||
},
|
||||
'query.get-proxy-list.basic': { tool: 'get-proxy-list', optionalParamsOnSuccess: [] },
|
||||
'query.get-proxy-list.by-proxy-id': {
|
||||
tool: 'get-proxy-list',
|
||||
optionalParamsOnSuccess: ['proxy_id'],
|
||||
},
|
||||
'query.get-tag-list.basic': { tool: 'get-tag-list', optionalParamsOnSuccess: [] },
|
||||
'query.get-tag-list.by-ids': { tool: 'get-tag-list', optionalParamsOnSuccess: ['ids'] },
|
||||
'query.get-application-list.basic': { tool: 'get-application-list', optionalParamsOnSuccess: [] },
|
||||
'query.get-application-list.by-category-id': {
|
||||
tool: 'get-application-list',
|
||||
optionalParamsOnSuccess: ['category_id'],
|
||||
},
|
||||
'query.get-kernel-list.basic': { tool: 'get-kernel-list', optionalParamsOnSuccess: [] },
|
||||
'query.get-kernel-list.by-kernel-type': {
|
||||
tool: 'get-kernel-list',
|
||||
optionalParamsOnSuccess: ['kernel_type'],
|
||||
},
|
||||
'query.get-browser-active.basic': { tool: 'get-browser-active', optionalParamsOnSuccess: [] },
|
||||
'query.get-browser-active.by-profile-no': {
|
||||
tool: 'get-browser-active',
|
||||
optionalParamsOnSuccess: ['profile_no'],
|
||||
},
|
||||
'query.get-cloud-active.basic': { tool: 'get-cloud-active', optionalParamsOnSuccess: [] },
|
||||
'query.get-cloud-active.filter-blocked': { tool: 'get-cloud-active', optionalParamsOnSuccess: [] },
|
||||
'query.check-status.basic': { tool: 'check-status', optionalParamsOnSuccess: [] },
|
||||
'query.check-status.filter-blocked': { tool: 'check-status', optionalParamsOnSuccess: [] },
|
||||
'query.get-opened-browser.basic': { tool: 'get-opened-browser', optionalParamsOnSuccess: [] },
|
||||
'query.get-opened-browser.filter-blocked': {
|
||||
tool: 'get-opened-browser',
|
||||
optionalParamsOnSuccess: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { markOptionalParamCovered } from '../../fixtures/coverageStore.js';
|
||||
import {
|
||||
compareInputAgainstReadbackB,
|
||||
type CompareBResult,
|
||||
type CompareBRow,
|
||||
} from '../../assertions/roundtrip/compareB.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
import { uniqueLabel } from '../../fixtures/resourceFactory.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
@@ -8,9 +14,110 @@ import type { McpTestClient } from '../../types.js';
|
||||
type GroupCtx = {
|
||||
client: McpTestClient;
|
||||
groupName: string;
|
||||
updatedGroupName?: string;
|
||||
groupId?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
const GROUP_CLEANUP_TOMBSTONE_NAME = '__e2e_group_cleanup_tombstone__';
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function findGroupByName(payload: unknown, groupName: string): Record<string, unknown> | null {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = findGroupByName(item, groupName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!maybeObject(payload)) {
|
||||
return null;
|
||||
}
|
||||
if (String(payload.group_name ?? '') === groupName) {
|
||||
return payload;
|
||||
}
|
||||
for (const child of Object.values(payload)) {
|
||||
const found = findGroupByName(child, groupName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getGroupRecordByName(
|
||||
client: McpTestClient,
|
||||
groupName: string,
|
||||
): Promise<{ record: Record<string, unknown> | null; rawText: string }> {
|
||||
const list = await client.callTool('get-group-list', {
|
||||
group_name: groupName,
|
||||
page_size: 100,
|
||||
page: 1,
|
||||
});
|
||||
const rawText = getToolTextContent(list);
|
||||
const payload = extractJsonPayloadFromToolText(rawText);
|
||||
return {
|
||||
record: findGroupByName(payload, groupName),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCompareRows(rows: CompareBRow[]): string[] {
|
||||
return rows.map((r) => `${r.inputPath}->${r.actualPath}:${r.status}${r.reason ? `(${r.reason})` : ''}`);
|
||||
}
|
||||
|
||||
function isGroupRoundtripCompareAcceptable(
|
||||
compare: CompareBResult,
|
||||
options: { requireRemark: boolean },
|
||||
): { passed: boolean; reasons: string[] } {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (compare.failed !== 0) {
|
||||
reasons.push(`failed_must_be_zero(actual=${compare.failed})`);
|
||||
}
|
||||
|
||||
const requiredFields = options.requireRemark ? ['group_name', 'remark'] : ['group_name'];
|
||||
for (const field of requiredFields) {
|
||||
const row = compare.rows.find((r) => r.inputPath === field);
|
||||
if (!row) {
|
||||
reasons.push(`required_field_missing_row(${field})`);
|
||||
continue;
|
||||
}
|
||||
if (row.status !== 'passed') {
|
||||
reasons.push(`required_field_not_passed(${field}:${row.status}${row.reason ? `:${row.reason}` : ''})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: reasons.length === 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureReusableCleanupGroupId(client: McpTestClient, tombstoneName: string): Promise<string> {
|
||||
const existing = await getGroupRecordByName(client, tombstoneName);
|
||||
const existingId = existing.record?.group_id;
|
||||
if (existingId != null) {
|
||||
return String(existingId);
|
||||
}
|
||||
|
||||
await client.callTool('create-group', {
|
||||
group_name: tombstoneName,
|
||||
remark: 'e2e-cleanup-tombstone',
|
||||
});
|
||||
const created = await getGroupRecordByName(client, tombstoneName);
|
||||
const createdId = created.record?.group_id;
|
||||
if (createdId == null) {
|
||||
throw new Error(`group_id missing for tombstone group_name=${tombstoneName}`);
|
||||
}
|
||||
return String(createdId);
|
||||
}
|
||||
|
||||
async function runCreateGroupCase(
|
||||
caseId: string,
|
||||
payload: { group_name: string; remark?: string },
|
||||
@@ -70,7 +177,190 @@ export async function caseGroupCreateWithRemark(): Promise<{ passed: boolean; de
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseGroupCreateRoundtripFullOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const client = await createMcpClient();
|
||||
const groupName = uniqueLabel('group-create');
|
||||
const remark = uniqueLabel('group-create-remark');
|
||||
const created = { group_name: groupName, remark };
|
||||
const ctx: GroupCtx = { client, groupName, remark };
|
||||
|
||||
return runCase({
|
||||
name: 'group.create.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
return x.client.callTool('create-group', created);
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
const list = await getGroupRecordByName(x.client, x.groupName);
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`group_name=${x.groupName}`, 'record_missing_after_create', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
x.groupId = String(list.record.group_id ?? '');
|
||||
const compare = compareInputAgainstReadbackB(created, list.record);
|
||||
const gate = isGroupRoundtripCompareAcceptable(compare, { requireRemark: true });
|
||||
return {
|
||||
passed: gate.passed,
|
||||
details: [
|
||||
`group_id=${x.groupId ?? 'unknown'}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...gate.reasons,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
await (c as unknown as GroupCtx).client.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseGroupUpdateRoundtripFullOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const client = await createMcpClient();
|
||||
const createdName = uniqueLabel('group-before-update');
|
||||
const updatedName = uniqueLabel('group-after-update');
|
||||
const createdRemark = uniqueLabel('group-before-remark');
|
||||
const updatedRemark = uniqueLabel('group-after-remark');
|
||||
const ctx: GroupCtx = {
|
||||
client,
|
||||
groupName: createdName,
|
||||
updatedGroupName: updatedName,
|
||||
remark: updatedRemark,
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'group.update.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
await x.client.callTool('create-group', { group_name: x.groupName, remark: createdRemark });
|
||||
const listAfterCreate = await getGroupRecordByName(x.client, x.groupName);
|
||||
const groupId = listAfterCreate.record?.group_id;
|
||||
if (groupId == null) {
|
||||
throw new Error(`group_id missing for group_name=${x.groupName}`);
|
||||
}
|
||||
x.groupId = String(groupId);
|
||||
return x.client.callTool('update-group', {
|
||||
group_id: x.groupId,
|
||||
group_name: x.updatedGroupName,
|
||||
remark: x.remark,
|
||||
});
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
const list = await getGroupRecordByName(x.client, x.updatedGroupName ?? '');
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [
|
||||
`group_id=${x.groupId ?? 'unknown'}`,
|
||||
`group_name=${x.updatedGroupName ?? ''}`,
|
||||
'record_missing_after_update',
|
||||
list.rawText.slice(0, 800),
|
||||
],
|
||||
};
|
||||
}
|
||||
const compare = compareInputAgainstReadbackB(
|
||||
{ group_name: x.updatedGroupName, remark: x.remark },
|
||||
list.record,
|
||||
);
|
||||
const gate = isGroupRoundtripCompareAcceptable(compare, { requireRemark: true });
|
||||
return {
|
||||
passed: gate.passed,
|
||||
details: [
|
||||
`group_id=${x.groupId ?? 'unknown'}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...gate.reasons,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
await (c as unknown as GroupCtx).client.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseGroupCleanupVisibility(): Promise<{ passed: boolean; details: string[] }> {
|
||||
const client = await createMcpClient();
|
||||
const originalName = uniqueLabel('group-cleanup-origin');
|
||||
const cleanupName = GROUP_CLEANUP_TOMBSTONE_NAME;
|
||||
const ctx: GroupCtx = {
|
||||
client,
|
||||
groupName: originalName,
|
||||
updatedGroupName: cleanupName,
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'group.cleanup.visibility',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
x.groupId = await ensureReusableCleanupGroupId(x.client, x.updatedGroupName ?? cleanupName);
|
||||
await x.client.callTool('update-group', {
|
||||
group_id: x.groupId,
|
||||
group_name: x.groupName,
|
||||
remark: uniqueLabel('to-clean'),
|
||||
});
|
||||
return x.client.callTool('update-group', {
|
||||
group_id: x.groupId,
|
||||
group_name: x.updatedGroupName,
|
||||
remark: null,
|
||||
});
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
const oldList = await getGroupRecordByName(x.client, x.groupName);
|
||||
const invisible = !oldList.record;
|
||||
const newList = await getGroupRecordByName(x.client, x.updatedGroupName ?? '');
|
||||
const cleanupVisible = !!newList.record;
|
||||
return {
|
||||
passed: invisible && cleanupVisible,
|
||||
details: invisible && cleanupVisible
|
||||
? [
|
||||
`group_id=${x.groupId ?? 'unknown'}`,
|
||||
`cleanup_group_name=${x.updatedGroupName ?? cleanupName}`,
|
||||
'old_name_not_found_after_cleanup',
|
||||
]
|
||||
: [
|
||||
`group_id=${x.groupId ?? 'unknown'}`,
|
||||
`old_name_visible=${String(!invisible)}`,
|
||||
`cleanup_name_visible=${String(cleanupVisible)}`,
|
||||
oldList.rawText.slice(0, 500),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as GroupCtx;
|
||||
try {
|
||||
if (x.groupId && x.updatedGroupName) {
|
||||
await x.client.callTool('update-group', {
|
||||
group_id: x.groupId,
|
||||
group_name: x.updatedGroupName,
|
||||
remark: null,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const groupCases = {
|
||||
'group.create.basic': caseGroupCreateBasic,
|
||||
'group.create.withRemark': caseGroupCreateWithRemark,
|
||||
'group.create.roundtrip.full-optional-subset': caseGroupCreateRoundtripFullOptionalSubset,
|
||||
'group.update.roundtrip.full-optional-subset': caseGroupUpdateRoundtripFullOptionalSubset,
|
||||
'group.cleanup.visibility': caseGroupCleanupVisibility,
|
||||
};
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { CaseRunResult, McpTestClient } from '../../types.js';
|
||||
|
||||
type HighImpactCtx = { client: McpTestClient };
|
||||
|
||||
const HIGH_IMPACT_ENV = 'ADSP_MCP_E2E_HIGH_IMPACT';
|
||||
const SHARE_RECEIVER_ENV = 'ADSP_MCP_E2E_SHARE_PROFILE_RECEIVER';
|
||||
|
||||
function blocked(reason: string): CaseRunResult {
|
||||
return { passed: true, details: [`blocked:${reason}`] };
|
||||
}
|
||||
|
||||
function isHighImpactEnabled(): boolean {
|
||||
return process.env[HIGH_IMPACT_ENV] === '1';
|
||||
}
|
||||
|
||||
function getShareReceiver(): string {
|
||||
return process.env[SHARE_RECEIVER_ENV]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function collectObjects(payload: unknown): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) walk(child);
|
||||
return;
|
||||
}
|
||||
if (!maybeObject(node)) {
|
||||
return;
|
||||
}
|
||||
out.push(node);
|
||||
for (const child of Object.values(node)) walk(child);
|
||||
};
|
||||
walk(payload);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePayloadFromToolText(text: string): unknown | null {
|
||||
try {
|
||||
return extractJsonPayloadFromToolText(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasFailureToken(text: string): boolean {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes('failed')
|
||||
|| normalized.includes('error')
|
||||
|| normalized.includes('exception')
|
||||
|| normalized.includes('invalid')
|
||||
);
|
||||
}
|
||||
|
||||
function assertToolTextSuccess(text: string): { passed: boolean; summary: string } {
|
||||
const summary = text.replace(/\s+/g, ' ').trim().slice(0, 220);
|
||||
if (!summary) {
|
||||
return { passed: false, summary: 'empty_response' };
|
||||
}
|
||||
return { passed: !hasFailureToken(summary), summary };
|
||||
}
|
||||
|
||||
async function pickOneProfileId(client: McpTestClient): Promise<string | null> {
|
||||
const listRes = await client.callTool('get-browser-list', { page: 1, limit: 50 });
|
||||
const text = getToolTextContent(listRes as CallToolResult);
|
||||
const payload = parsePayloadFromToolText(text);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const hit = collectObjects(payload).find((row) => row.profile_id != null);
|
||||
if (!hit) {
|
||||
return null;
|
||||
}
|
||||
return String(hit.profile_id);
|
||||
}
|
||||
|
||||
async function hasOpenedBrowsers(client: McpTestClient): Promise<{
|
||||
parseOk: boolean;
|
||||
openedCount: number;
|
||||
summary: string;
|
||||
}> {
|
||||
const openedRes = await client.callTool('get-opened-browser', {});
|
||||
const text = getToolTextContent(openedRes as CallToolResult);
|
||||
const payload = parsePayloadFromToolText(text);
|
||||
if (!payload) {
|
||||
return {
|
||||
parseOk: false,
|
||||
openedCount: 0,
|
||||
summary: text.slice(0, 220),
|
||||
};
|
||||
}
|
||||
const rows = collectObjects(payload).filter((row) => row.profile_id != null);
|
||||
return {
|
||||
parseOk: true,
|
||||
openedCount: rows.length,
|
||||
summary: text.slice(0, 220),
|
||||
};
|
||||
}
|
||||
|
||||
async function runHighImpactCase(
|
||||
name: string,
|
||||
invokeFn: (client: McpTestClient) => Promise<CaseRunResult | CallToolResult>,
|
||||
): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
return runCase({
|
||||
name,
|
||||
prepare: async () => ({ client } as unknown as Record<string, unknown>),
|
||||
invoke: async (ctx) => invokeFn((ctx as unknown as HighImpactCtx).client),
|
||||
assertState: async (invokeRes) => {
|
||||
const maybeCase = invokeRes as CaseRunResult;
|
||||
if (Array.isArray(maybeCase.details) && typeof maybeCase.passed === 'boolean') {
|
||||
return maybeCase;
|
||||
}
|
||||
const text = getToolTextContent(invokeRes as CallToolResult);
|
||||
const verdict = assertToolTextSuccess(text);
|
||||
return {
|
||||
passed: verdict.passed,
|
||||
details: [`tool_text=${verdict.summary}`],
|
||||
};
|
||||
},
|
||||
cleanup: async (ctx) => {
|
||||
await (ctx as unknown as HighImpactCtx).client.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseHighImpactNewFingerprintByProfileId(): Promise<CaseRunResult> {
|
||||
return runHighImpactCase('highimpact.new-fingerprint.by-profile-id', async (client) => {
|
||||
if (!isHighImpactEnabled()) {
|
||||
return blocked(`high_impact_disabled_${HIGH_IMPACT_ENV}`);
|
||||
}
|
||||
const profileId = await pickOneProfileId(client);
|
||||
if (!profileId) {
|
||||
return blocked('no_profile_for_new_fingerprint');
|
||||
}
|
||||
const res = await client.callTool('new-fingerprint', { profile_id: [profileId] });
|
||||
const text = getToolTextContent(res as CallToolResult);
|
||||
const verdict = assertToolTextSuccess(text);
|
||||
return {
|
||||
passed: verdict.passed,
|
||||
details: [`profile_id=${profileId}`, `tool_text=${verdict.summary}`],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseHighImpactDeleteCacheV2CookieOnly(): Promise<CaseRunResult> {
|
||||
return runHighImpactCase('highimpact.delete-cache-v2.cookie-only', async (client) => {
|
||||
if (!isHighImpactEnabled()) {
|
||||
return blocked(`high_impact_disabled_${HIGH_IMPACT_ENV}`);
|
||||
}
|
||||
const opened = await hasOpenedBrowsers(client);
|
||||
if (!opened.parseOk) {
|
||||
return blocked('delete_cache_precheck_parse_failed_get_opened_browser');
|
||||
}
|
||||
if (opened.openedCount > 0) {
|
||||
return {
|
||||
passed: true,
|
||||
details: [
|
||||
'blocked:delete_cache_requires_no_opened_browser',
|
||||
`opened_count=${opened.openedCount}`,
|
||||
`opened_summary=${opened.summary}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
const profileId = await pickOneProfileId(client);
|
||||
if (!profileId) {
|
||||
return blocked('no_profile_for_delete_cache_v2');
|
||||
}
|
||||
const res = await client.callTool('delete-cache-v2', {
|
||||
profile_id: [profileId],
|
||||
type: ['cookie'],
|
||||
});
|
||||
const text = getToolTextContent(res as CallToolResult);
|
||||
const verdict = assertToolTextSuccess(text);
|
||||
return {
|
||||
passed: verdict.passed,
|
||||
details: [
|
||||
'precheck_no_opened_browser',
|
||||
`profile_id=${profileId}`,
|
||||
`tool_text=${verdict.summary}`,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseHighImpactShareProfileWithReceiver(): Promise<CaseRunResult> {
|
||||
return runHighImpactCase('highimpact.share-profile.with-receiver', async (client) => {
|
||||
if (!isHighImpactEnabled()) {
|
||||
return blocked(`high_impact_disabled_${HIGH_IMPACT_ENV}`);
|
||||
}
|
||||
const receiver = getShareReceiver();
|
||||
if (!receiver) {
|
||||
return blocked(`missing_share_receiver_env_${SHARE_RECEIVER_ENV}`);
|
||||
}
|
||||
const profileId = await pickOneProfileId(client);
|
||||
if (!profileId) {
|
||||
return blocked('no_profile_for_share_profile');
|
||||
}
|
||||
const res = await client.callTool('share-profile', {
|
||||
profile_id: [profileId],
|
||||
receiver,
|
||||
});
|
||||
const text = getToolTextContent(res as CallToolResult);
|
||||
const verdict = assertToolTextSuccess(text);
|
||||
return {
|
||||
passed: verdict.passed,
|
||||
details: [
|
||||
`profile_id=${profileId}`,
|
||||
'receiver_from_env=true',
|
||||
`tool_text=${verdict.summary}`,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const highImpactCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'highimpact.new-fingerprint.by-profile-id': caseHighImpactNewFingerprintByProfileId,
|
||||
'highimpact.delete-cache-v2.cookie-only': caseHighImpactDeleteCacheV2CookieOnly,
|
||||
'highimpact.share-profile.with-receiver': caseHighImpactShareProfileWithReceiver,
|
||||
};
|
||||
@@ -4,9 +4,63 @@ import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { CaseRunResult } from '../../types.js';
|
||||
import type { McpTestClient } from '../../types.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
|
||||
type Ctx = { client: McpTestClient };
|
||||
|
||||
const KERNEL_DOWNLOAD_ENV = 'ADSP_MCP_E2E_ENABLE_DOWNLOAD_KERNEL';
|
||||
const KERNEL_FAIL_CLOSED_KEYWORDS = ['failed', 'error', 'timeout', 'exception'];
|
||||
|
||||
function blocked(reason: string): CaseRunResult {
|
||||
return { passed: true, details: [`blocked:${reason}`] };
|
||||
}
|
||||
|
||||
function isKernelFailClosedText(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return KERNEL_FAIL_CLOSED_KEYWORDS.some((kw) => lower.includes(kw));
|
||||
}
|
||||
|
||||
function toKernelRows(payload: unknown): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.filter(
|
||||
(row): row is Record<string, unknown> =>
|
||||
typeof row === 'object' && row !== null && !Array.isArray(row),
|
||||
);
|
||||
}
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return [];
|
||||
}
|
||||
const maybeList = (payload as { list?: unknown }).list;
|
||||
if (!Array.isArray(maybeList)) {
|
||||
return [];
|
||||
}
|
||||
return maybeList.filter(
|
||||
(row): row is Record<string, unknown> =>
|
||||
typeof row === 'object' && row !== null && !Array.isArray(row),
|
||||
);
|
||||
}
|
||||
|
||||
function evaluateKernelListChrome(text: string): CaseRunResult {
|
||||
if (isKernelFailClosedText(text)) {
|
||||
return { passed: false, details: ['fail_closed_keyword_detected', text.slice(0, 400)] };
|
||||
}
|
||||
try {
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const rows = toKernelRows(payload);
|
||||
const hasChrome = rows.some((row) => String(row.kernel_type ?? '').toLowerCase() === 'chrome');
|
||||
if (!hasChrome) {
|
||||
return { passed: false, details: ['kernel_type_chrome_not_found', text.slice(0, 400)] };
|
||||
}
|
||||
return { passed: true, details: ['kernel_type=Chrome'] };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
passed: false,
|
||||
details: [`kernel_payload_parse_failed:${reason}`, text.slice(0, 400)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function caseKernelListChrome(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
|
||||
@@ -19,11 +73,43 @@ export async function caseKernelListChrome(): Promise<CaseRunResult> {
|
||||
},
|
||||
assertState: async (invokeRes) => {
|
||||
const text = getToolTextContent(invokeRes as CallToolResult);
|
||||
const passed =
|
||||
!text.toLowerCase().includes('failed to get') && text.length > 0;
|
||||
return evaluateKernelListChrome(text);
|
||||
},
|
||||
cleanup: async (ctx) => {
|
||||
await (ctx as unknown as Ctx).client.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseKernelDownloadMinimal(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
|
||||
return runCase({
|
||||
name: 'kernel.download.minimal',
|
||||
prepare: async () => ({ client } as unknown as Record<string, unknown>),
|
||||
invoke: async (ctx) => {
|
||||
const c = (ctx as unknown as Ctx).client;
|
||||
if (process.env[KERNEL_DOWNLOAD_ENV] !== '1') {
|
||||
return blocked(`download_kernel_disabled_${KERNEL_DOWNLOAD_ENV}`);
|
||||
}
|
||||
return c.callTool('download-kernel', {
|
||||
kernel_type: 'Chrome',
|
||||
kernel_version: 'latest',
|
||||
});
|
||||
},
|
||||
assertState: async (invokeRes) => {
|
||||
const maybeCase = invokeRes as CaseRunResult;
|
||||
if (Array.isArray(maybeCase.details) && typeof maybeCase.passed === 'boolean') {
|
||||
return maybeCase;
|
||||
}
|
||||
const text = getToolTextContent(invokeRes as CallToolResult);
|
||||
const lower = text.toLowerCase();
|
||||
const passed = !lower.includes('failed') && !lower.includes('error') && text.length > 0;
|
||||
return {
|
||||
passed,
|
||||
details: passed ? ['kernel_type=Chrome'] : [text.slice(0, 400)],
|
||||
details: passed
|
||||
? ['kernel_type=Chrome', 'kernel_version=latest']
|
||||
: [text.slice(0, 500)],
|
||||
};
|
||||
},
|
||||
cleanup: async (ctx) => {
|
||||
@@ -34,4 +120,9 @@ export async function caseKernelListChrome(): Promise<CaseRunResult> {
|
||||
|
||||
export const kernelCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'kernel.list.chrome': caseKernelListChrome,
|
||||
'kernel.download.minimal': caseKernelDownloadMinimal,
|
||||
};
|
||||
|
||||
export const __kernelCaseInternals = {
|
||||
evaluateKernelListChrome,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,16 @@ import type { McpTestClient } from '../../types.js';
|
||||
|
||||
type Ctx = { client: McpTestClient };
|
||||
|
||||
const PATCH_UPDATE_ENV = 'ADSP_MCP_E2E_ENABLE_UPDATE_PATCH';
|
||||
|
||||
function blocked(reason: string): CaseRunResult {
|
||||
return { passed: true, details: [`blocked:${reason}`] };
|
||||
}
|
||||
|
||||
function shouldAllowPatchUpdate(env: NodeJS.ProcessEnv): boolean {
|
||||
return env[PATCH_UPDATE_ENV] === '1';
|
||||
}
|
||||
|
||||
export async function casePatchUpdateStable(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
|
||||
@@ -15,9 +25,16 @@ export async function casePatchUpdateStable(): Promise<CaseRunResult> {
|
||||
prepare: async () => ({ client } as unknown as Record<string, unknown>),
|
||||
invoke: async (ctx) => {
|
||||
const c = (ctx as unknown as Ctx).client;
|
||||
if (!shouldAllowPatchUpdate(process.env)) {
|
||||
return blocked(`update_patch_disabled_${PATCH_UPDATE_ENV}`);
|
||||
}
|
||||
return c.callTool('update-patch', { version_type: 'stable' });
|
||||
},
|
||||
assertState: async (invokeRes) => {
|
||||
const maybeCase = invokeRes as CaseRunResult;
|
||||
if (Array.isArray(maybeCase.details) && typeof maybeCase.passed === 'boolean') {
|
||||
return maybeCase;
|
||||
}
|
||||
const text = getToolTextContent(invokeRes as CallToolResult);
|
||||
const lower = text.toLowerCase();
|
||||
const passed =
|
||||
@@ -34,6 +51,43 @@ export async function casePatchUpdateStable(): Promise<CaseRunResult> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function casePatchUpdateBlockedByDefault(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
|
||||
return runCase({
|
||||
name: 'patch.update.blocked-by-default',
|
||||
prepare: async () => ({ client } as unknown as Record<string, unknown>),
|
||||
invoke: async (ctx) => {
|
||||
const c = (ctx as unknown as Ctx).client;
|
||||
if (!shouldAllowPatchUpdate(process.env)) {
|
||||
return blocked(`update_patch_disabled_${PATCH_UPDATE_ENV}`);
|
||||
}
|
||||
return c.callTool('update-patch', {});
|
||||
},
|
||||
assertState: async (invokeRes) => {
|
||||
const maybeCase = invokeRes as CaseRunResult;
|
||||
if (Array.isArray(maybeCase.details) && typeof maybeCase.passed === 'boolean') {
|
||||
return maybeCase;
|
||||
}
|
||||
const text = getToolTextContent(invokeRes as CallToolResult);
|
||||
const lower = text.toLowerCase();
|
||||
const passed = !lower.includes('failed') && !lower.includes('error') && text.length > 0;
|
||||
return {
|
||||
passed,
|
||||
details: passed ? ['gate_open=true', 'update_patch_invoked=true'] : [text.slice(0, 500)],
|
||||
};
|
||||
},
|
||||
cleanup: async (ctx) => {
|
||||
await (ctx as unknown as Ctx).client.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const patchCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'patch.update.stable': casePatchUpdateStable,
|
||||
'patch.update.blocked-by-default': casePatchUpdateBlockedByDefault,
|
||||
};
|
||||
|
||||
export const __patchCaseInternals = {
|
||||
shouldAllowPatchUpdate,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { compareInputAgainstReadbackB, type CompareBRow } from '../../assertions/roundtrip/compareB.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
import { uniqueLabel } from '../../fixtures/resourceFactory.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
@@ -8,6 +10,7 @@ import type { McpTestClient } from '../../types.js';
|
||||
type ProxyCtx = {
|
||||
client: McpTestClient;
|
||||
proxyId: string | null;
|
||||
updated?: Record<string, unknown>;
|
||||
host: string;
|
||||
port: string;
|
||||
};
|
||||
@@ -41,6 +44,57 @@ function extractProxyIdFromListText(listText: string, host: string, port: string
|
||||
}
|
||||
}
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function findProxyById(payload: unknown, proxyId: string): Record<string, unknown> | null {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = findProxyById(item, proxyId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!maybeObject(payload)) {
|
||||
return null;
|
||||
}
|
||||
if (String(payload.proxy_id ?? payload.id ?? '') === proxyId) {
|
||||
return payload;
|
||||
}
|
||||
for (const child of Object.values(payload)) {
|
||||
const found = findProxyById(child, proxyId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getProxyRecordById(
|
||||
client: McpTestClient,
|
||||
proxyId: string,
|
||||
): Promise<{ record: Record<string, unknown> | null; rawText: string }> {
|
||||
const listRes = await client.callTool('get-proxy-list', { proxy_id: [proxyId], page: 1, limit: 200 });
|
||||
const rawText = getToolTextContent(listRes);
|
||||
const payload = extractJsonPayloadFromToolText(rawText);
|
||||
return {
|
||||
record: findProxyById(payload, proxyId),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCompareRows(rows: CompareBRow[]): string[] {
|
||||
return rows.map((r) => `${r.inputPath}->${r.actualPath}:${r.status}${r.reason ? `(${r.reason})` : ''}`);
|
||||
}
|
||||
|
||||
function summarizeToolText(text: string, max = 600): string {
|
||||
const compact = text.replace(/\s+/g, ' ').trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, max)}...`;
|
||||
}
|
||||
|
||||
/** Create one HTTP proxy, assert it appears in list, delete in cleanup. */
|
||||
export async function caseProxyCreateListDelete(): Promise<{ passed: boolean; details: string[] }> {
|
||||
const client = await createMcpClient();
|
||||
@@ -101,4 +155,208 @@ export async function caseProxyCreateListDelete(): Promise<{ passed: boolean; de
|
||||
|
||||
export const proxyCases = {
|
||||
'proxy.create.list.delete': caseProxyCreateListDelete,
|
||||
'proxy.create.roundtrip.full-optional-subset': caseProxyCreateRoundtripFullOptionalSubset,
|
||||
'proxy.update.roundtrip.full-optional-subset': caseProxyUpdateRoundtripFullOptionalSubset,
|
||||
'proxy.delete.visibility': caseProxyDeleteVisibility,
|
||||
};
|
||||
|
||||
export async function caseProxyCreateRoundtripFullOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const client = await createMcpClient();
|
||||
const host = '127.0.0.1';
|
||||
const port = String(64000 + Math.floor(Math.random() * 500));
|
||||
const remark = uniqueLabel('proxy-create-remark');
|
||||
const created = { type: 'http', host, port, remark };
|
||||
const ctx: ProxyCtx = { client, proxyId: null, host, port };
|
||||
|
||||
return runCase({
|
||||
name: 'proxy.create.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
return x.client.callTool('create-proxy', { proxies: [created] });
|
||||
},
|
||||
assertState: async (invokeRes, c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
const createText = getToolTextContent(invokeRes as CallToolResult);
|
||||
x.proxyId = extractProxyIdFromCreateText(createText);
|
||||
if (!x.proxyId) {
|
||||
const listRes = await x.client.callTool('get-proxy-list', { page: 1, limit: 200 });
|
||||
x.proxyId = extractProxyIdFromListText(getToolTextContent(listRes), host, port);
|
||||
}
|
||||
if (!x.proxyId) {
|
||||
return { passed: false, details: ['proxy_id_missing_after_create', createText.slice(0, 600)] };
|
||||
}
|
||||
const list = await getProxyRecordById(x.client, x.proxyId);
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`proxy_id=${x.proxyId}`, 'record_missing_after_create', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
const compare = compareInputAgainstReadbackB(created, list.record);
|
||||
const requiredRows = ['type', 'host', 'port'].map((k) => compare.rows.find((r) => r.inputPath === k));
|
||||
const passed = compare.failed === 0 && requiredRows.every((r) => r?.status === 'passed');
|
||||
return {
|
||||
passed,
|
||||
details: [
|
||||
`proxy_id=${x.proxyId}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
try {
|
||||
if (x.proxyId) {
|
||||
await x.client.callTool('delete-proxy', { proxy_id: [x.proxyId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseProxyUpdateRoundtripFullOptionalSubset(): Promise<{
|
||||
passed: boolean;
|
||||
details: string[];
|
||||
}> {
|
||||
const client = await createMcpClient();
|
||||
const createHost = '127.0.0.1';
|
||||
const createPort = String(64500 + Math.floor(Math.random() * 400));
|
||||
const ctx: ProxyCtx = { client, proxyId: null, host: createHost, port: createPort };
|
||||
|
||||
return runCase({
|
||||
name: 'proxy.update.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
const createText = getToolTextContent(
|
||||
(await x.client.callTool('create-proxy', {
|
||||
proxies: [{ type: 'http', host: x.host, port: x.port, remark: uniqueLabel('proxy-before') }],
|
||||
})) as CallToolResult,
|
||||
);
|
||||
x.proxyId = extractProxyIdFromCreateText(createText);
|
||||
if (!x.proxyId) {
|
||||
const listRes = await x.client.callTool('get-proxy-list', { page: 1, limit: 200 });
|
||||
x.proxyId = extractProxyIdFromListText(getToolTextContent(listRes), x.host, x.port);
|
||||
}
|
||||
if (!x.proxyId) {
|
||||
throw new Error(`proxy_id missing for update host=${x.host} port=${x.port}`);
|
||||
}
|
||||
x.updated = {
|
||||
proxy_id: x.proxyId,
|
||||
type: 'https',
|
||||
host: '127.0.0.2',
|
||||
port: String(65000 + Math.floor(Math.random() * 300)),
|
||||
remark: uniqueLabel('proxy-after'),
|
||||
};
|
||||
return x.client.callTool('update-proxy', x.updated);
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
const list = await getProxyRecordById(x.client, x.proxyId ?? '');
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`proxy_id=${x.proxyId ?? 'unknown'}`, 'record_missing_after_update', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
const expected = Object.fromEntries(
|
||||
Object.entries(x.updated ?? {}).filter(([k]) => k !== 'proxy_id'),
|
||||
);
|
||||
const compare = compareInputAgainstReadbackB(expected, list.record);
|
||||
const requiredRows = ['type', 'host', 'port'].map((k) => compare.rows.find((r) => r.inputPath === k));
|
||||
const passed = compare.failed === 0 && requiredRows.every((r) => r?.status === 'passed');
|
||||
return {
|
||||
passed,
|
||||
details: [
|
||||
`proxy_id=${x.proxyId ?? 'unknown'}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
try {
|
||||
if (x.proxyId) {
|
||||
await x.client.callTool('delete-proxy', { proxy_id: [x.proxyId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseProxyDeleteVisibility(): Promise<{ passed: boolean; details: string[] }> {
|
||||
const client = await createMcpClient();
|
||||
const host = '127.0.0.1';
|
||||
const port = String(65500 + Math.floor(Math.random() * 30));
|
||||
const ctx: ProxyCtx = { client, proxyId: null, host, port };
|
||||
|
||||
return runCase({
|
||||
name: 'proxy.delete.visibility',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
const createText = getToolTextContent(
|
||||
(await x.client.callTool('create-proxy', {
|
||||
proxies: [{ type: 'http', host: x.host, port: x.port, remark: uniqueLabel('proxy-delete') }],
|
||||
})) as CallToolResult,
|
||||
);
|
||||
x.proxyId = extractProxyIdFromCreateText(createText);
|
||||
if (!x.proxyId) {
|
||||
const listRes = await x.client.callTool('get-proxy-list', { page: 1, limit: 200 });
|
||||
x.proxyId = extractProxyIdFromListText(getToolTextContent(listRes), x.host, x.port);
|
||||
}
|
||||
if (!x.proxyId) {
|
||||
throw new Error(`proxy_id missing for delete host=${x.host} port=${x.port}`);
|
||||
}
|
||||
return x.client.callTool('delete-proxy', { proxy_id: [x.proxyId] });
|
||||
},
|
||||
assertState: async (_res, c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
const totalTimeoutMs = 8_000;
|
||||
const backoffScheduleMs = [250, 400, 700, 1_000, 1_400, 1_800, 2_000];
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
let list = await getProxyRecordById(x.client, x.proxyId ?? '');
|
||||
let invisible = !list.record;
|
||||
while (!invisible && Date.now() - startedAt < totalTimeoutMs) {
|
||||
const delay = backoffScheduleMs[Math.min(attempts, backoffScheduleMs.length - 1)];
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
list = await getProxyRecordById(x.client, x.proxyId ?? '');
|
||||
invisible = !list.record;
|
||||
attempts += 1;
|
||||
}
|
||||
return {
|
||||
passed: invisible,
|
||||
details: invisible
|
||||
? [`proxy_id=${x.proxyId ?? 'unknown'}`, 'not_found_after_delete']
|
||||
: [
|
||||
`proxy_id=${x.proxyId ?? 'unknown'}`,
|
||||
`wait_timeout_ms=${totalTimeoutMs}`,
|
||||
`poll_attempts=${attempts}`,
|
||||
'still_visible_after_delete',
|
||||
`last_response_summary=${summarizeToolText(list.rawText)}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as ProxyCtx;
|
||||
try {
|
||||
if (x.proxyId) {
|
||||
await x.client.callTool('delete-proxy', { proxy_id: [x.proxyId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { CaseRunResult } from '../../types.js';
|
||||
import type { McpTestClient } from '../../types.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
|
||||
type QueryCtx = { client: McpTestClient };
|
||||
|
||||
function toFailureToken(text: string): boolean {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes('failed')
|
||||
|| normalized.includes('error')
|
||||
|| normalized.includes('exception')
|
||||
|| normalized.includes('invalid')
|
||||
);
|
||||
}
|
||||
|
||||
function isStructuredPayload(payload: unknown): boolean {
|
||||
return Array.isArray(payload) || maybeObject(payload);
|
||||
}
|
||||
|
||||
function basePass(text: string): boolean {
|
||||
if (text.length === 0 || toFailureToken(text)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
return isStructuredPayload(payload);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function collectObjects(payload: unknown): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const visit = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
visit(child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!maybeObject(node)) {
|
||||
return;
|
||||
}
|
||||
out.push(node);
|
||||
for (const child of Object.values(node)) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(payload);
|
||||
return out;
|
||||
}
|
||||
|
||||
function findFirstByKeys(
|
||||
payload: unknown,
|
||||
requiredKeys: string[],
|
||||
): Record<string, unknown> | null {
|
||||
return collectObjects(payload).find((row) =>
|
||||
requiredKeys.every((k) => row[k] != null && String(row[k]).length > 0),
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function blocked(reason: string): CaseRunResult {
|
||||
return {
|
||||
passed: true,
|
||||
details: [`blocked:${reason}`],
|
||||
};
|
||||
}
|
||||
|
||||
function payloadHasMatchingValue(
|
||||
payload: unknown,
|
||||
field: string,
|
||||
expectedValue: string,
|
||||
): boolean {
|
||||
const normalizedExpected = expectedValue.trim();
|
||||
if (normalizedExpected.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const rows = collectObjects(payload);
|
||||
return rows.some((row) => {
|
||||
const v = row[field];
|
||||
if (v == null) {
|
||||
return false;
|
||||
}
|
||||
return String(v).trim() === normalizedExpected;
|
||||
});
|
||||
}
|
||||
|
||||
async function runSimpleCase(
|
||||
caseId: string,
|
||||
invoke: (client: McpTestClient) => Promise<CallToolResult>,
|
||||
assertFn?: (text: string) => CaseRunResult,
|
||||
): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
return runCase({
|
||||
name: caseId,
|
||||
prepare: async () => ({ client } as unknown as Record<string, unknown>),
|
||||
invoke: async (ctx) => invoke((ctx as unknown as QueryCtx).client),
|
||||
assertState: async (invokeRes) => {
|
||||
const text = getToolTextContent(invokeRes as CallToolResult);
|
||||
if (assertFn) {
|
||||
return assertFn(text);
|
||||
}
|
||||
return {
|
||||
passed: basePass(text),
|
||||
details: [text.slice(0, 400)],
|
||||
};
|
||||
},
|
||||
cleanup: async (ctx) => {
|
||||
await (ctx as unknown as QueryCtx).client.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function pickOneBrowser(client: McpTestClient): Promise<Record<string, unknown> | null> {
|
||||
const res = await client.callTool('get-browser-list', { page: 1, limit: 50 });
|
||||
const payload = extractJsonPayloadFromToolText(getToolTextContent(res as CallToolResult));
|
||||
return findFirstByKeys(payload, ['profile_id']);
|
||||
}
|
||||
|
||||
async function pickOneGroup(client: McpTestClient): Promise<Record<string, unknown> | null> {
|
||||
const res = await client.callTool('get-group-list', { page: 1, page_size: 100 });
|
||||
const payload = extractJsonPayloadFromToolText(getToolTextContent(res as CallToolResult));
|
||||
return findFirstByKeys(payload, ['group_id', 'group_name']);
|
||||
}
|
||||
|
||||
async function pickOneProxy(client: McpTestClient): Promise<Record<string, unknown> | null> {
|
||||
const res = await client.callTool('get-proxy-list', { page: 1, limit: 200 });
|
||||
const payload = extractJsonPayloadFromToolText(getToolTextContent(res as CallToolResult));
|
||||
return findFirstByKeys(payload, ['proxy_id']);
|
||||
}
|
||||
|
||||
async function pickOneTag(client: McpTestClient): Promise<Record<string, unknown> | null> {
|
||||
const res = await client.callTool('get-tag-list', { page: 1, limit: 200 });
|
||||
const payload = extractJsonPayloadFromToolText(getToolTextContent(res as CallToolResult));
|
||||
return findFirstByKeys(payload, ['id']);
|
||||
}
|
||||
|
||||
async function pickOneApplicationCategory(client: McpTestClient): Promise<string | null> {
|
||||
const res = await client.callTool('get-application-list', { page: 1, limit: 50 });
|
||||
const payload = extractJsonPayloadFromToolText(getToolTextContent(res as CallToolResult));
|
||||
const hit =
|
||||
findFirstByKeys(payload, ['category_id']) ??
|
||||
findFirstByKeys(payload, ['id']);
|
||||
if (!hit) {
|
||||
return null;
|
||||
}
|
||||
return String(hit.category_id ?? hit.id ?? '');
|
||||
}
|
||||
|
||||
export async function caseQueryGetBrowserListBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-browser-list.basic', (client) =>
|
||||
client.callTool('get-browser-list', { page: 1, limit: 20 }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetBrowserListByProfileId(): Promise<CaseRunResult> {
|
||||
let requestedProfileId: string | null = null;
|
||||
return runSimpleCase(
|
||||
'query.get-browser-list.by-profile-id',
|
||||
async (client) => {
|
||||
const browser = await pickOneBrowser(client);
|
||||
if (!browser?.profile_id) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_profile_for_profile_id_filter' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
requestedProfileId = String(browser.profile_id);
|
||||
return client.callTool('get-browser-list', { profile_id: [requestedProfileId] });
|
||||
},
|
||||
(text) => {
|
||||
if (text.startsWith('blocked:')) {
|
||||
return blocked(text.replace('blocked:', '').trim());
|
||||
}
|
||||
if (!requestedProfileId) {
|
||||
return { passed: false, details: ['missing_requested_profile_id_context'] };
|
||||
}
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const matched = payloadHasMatchingValue(payload, 'profile_id', requestedProfileId);
|
||||
return {
|
||||
passed: matched,
|
||||
details: matched
|
||||
? [`profile_id_matched=${requestedProfileId}`]
|
||||
: [`expected_profile_id=${requestedProfileId}`, text.slice(0, 400)],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetGroupListBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-group-list.basic', (client) =>
|
||||
client.callTool('get-group-list', { page: 1, page_size: 20 }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetGroupListByGroupName(): Promise<CaseRunResult> {
|
||||
let requestedGroupName: string | null = null;
|
||||
return runSimpleCase(
|
||||
'query.get-group-list.by-group-name',
|
||||
async (client) => {
|
||||
const group = await pickOneGroup(client);
|
||||
if (!group?.group_name || !group?.group_id) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_group_for_group_name_filter' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
requestedGroupName = String(group.group_name);
|
||||
return client.callTool('get-group-list', {
|
||||
group_name: requestedGroupName,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
},
|
||||
(text) => {
|
||||
if (text.startsWith('blocked:')) {
|
||||
return blocked(text.replace('blocked:', '').trim());
|
||||
}
|
||||
if (!requestedGroupName) {
|
||||
return { passed: false, details: ['missing_requested_group_name_context'] };
|
||||
}
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const matched = payloadHasMatchingValue(payload, 'group_name', requestedGroupName);
|
||||
return {
|
||||
passed: matched,
|
||||
details: matched
|
||||
? [`group_name_matched=${requestedGroupName}`]
|
||||
: [`expected_group_name=${requestedGroupName}`, text.slice(0, 400)],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetProxyListBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-proxy-list.basic', (client) =>
|
||||
client.callTool('get-proxy-list', { page: 1, limit: 20 }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetProxyListByProxyId(): Promise<CaseRunResult> {
|
||||
let requestedProxyId: string | null = null;
|
||||
return runSimpleCase(
|
||||
'query.get-proxy-list.by-proxy-id',
|
||||
async (client) => {
|
||||
const proxy = await pickOneProxy(client);
|
||||
if (!proxy?.proxy_id) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_proxy_for_proxy_id_filter' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
requestedProxyId = String(proxy.proxy_id);
|
||||
return client.callTool('get-proxy-list', {
|
||||
proxy_id: [requestedProxyId],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
},
|
||||
(text) => {
|
||||
if (text.startsWith('blocked:')) {
|
||||
return blocked(text.replace('blocked:', '').trim());
|
||||
}
|
||||
if (!requestedProxyId) {
|
||||
return { passed: false, details: ['missing_requested_proxy_id_context'] };
|
||||
}
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const matched = payloadHasMatchingValue(payload, 'proxy_id', requestedProxyId);
|
||||
return {
|
||||
passed: matched,
|
||||
details: matched
|
||||
? [`proxy_id_matched=${requestedProxyId}`]
|
||||
: [`expected_proxy_id=${requestedProxyId}`, text.slice(0, 400)],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetTagListBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-tag-list.basic', (client) =>
|
||||
client.callTool('get-tag-list', { page: 1, limit: 20 }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetTagListByIds(): Promise<CaseRunResult> {
|
||||
let requestedTagId: string | null = null;
|
||||
return runSimpleCase(
|
||||
'query.get-tag-list.by-ids',
|
||||
async (client) => {
|
||||
const tag = await pickOneTag(client);
|
||||
if (!tag?.id) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_tag_for_ids_filter' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
requestedTagId = String(tag.id);
|
||||
return client.callTool('get-tag-list', {
|
||||
ids: [requestedTagId],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
},
|
||||
(text) => {
|
||||
if (text.startsWith('blocked:')) {
|
||||
return blocked(text.replace('blocked:', '').trim());
|
||||
}
|
||||
if (!requestedTagId) {
|
||||
return { passed: false, details: ['missing_requested_tag_id_context'] };
|
||||
}
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const matched = payloadHasMatchingValue(payload, 'id', requestedTagId);
|
||||
return {
|
||||
passed: matched,
|
||||
details: matched
|
||||
? [`tag_id_matched=${requestedTagId}`]
|
||||
: [`expected_tag_id=${requestedTagId}`, text.slice(0, 400)],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetApplicationListBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-application-list.basic', (client) =>
|
||||
client.callTool('get-application-list', { page: 1, limit: 20 }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetApplicationListByCategoryId(): Promise<CaseRunResult> {
|
||||
let requestedCategoryId: string | null = null;
|
||||
return runSimpleCase(
|
||||
'query.get-application-list.by-category-id',
|
||||
async (client) => {
|
||||
const categoryId = await pickOneApplicationCategory(client);
|
||||
if (!categoryId) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_category_id_for_filter' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
requestedCategoryId = categoryId;
|
||||
return client.callTool('get-application-list', {
|
||||
category_id: requestedCategoryId,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
},
|
||||
(text) => {
|
||||
if (text.startsWith('blocked:')) {
|
||||
return blocked(text.replace('blocked:', '').trim());
|
||||
}
|
||||
if (!requestedCategoryId) {
|
||||
return { passed: false, details: ['missing_requested_category_id_context'] };
|
||||
}
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const matchedCategoryId = payloadHasMatchingValue(payload, 'category_id', requestedCategoryId);
|
||||
const matchedFallbackId = payloadHasMatchingValue(payload, 'id', requestedCategoryId);
|
||||
const matched = matchedCategoryId || matchedFallbackId;
|
||||
return {
|
||||
passed: matched,
|
||||
details: matched
|
||||
? [`category_id_matched=${requestedCategoryId}`]
|
||||
: [`expected_category_id=${requestedCategoryId}`, text.slice(0, 400)],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetKernelListBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-kernel-list.basic', (client) =>
|
||||
client.callTool('get-kernel-list', {}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetKernelListByKernelType(): Promise<CaseRunResult> {
|
||||
return runSimpleCase(
|
||||
'query.get-kernel-list.by-kernel-type',
|
||||
(client) => client.callTool('get-kernel-list', { kernel_type: 'Chrome' }),
|
||||
(text) => {
|
||||
if (!basePass(text)) {
|
||||
return { passed: false, details: [text.slice(0, 400)] };
|
||||
}
|
||||
const payload = extractJsonPayloadFromToolText(text);
|
||||
const rows = collectObjects(payload).filter((x) => x.kernel_type != null);
|
||||
if (rows.length === 0) {
|
||||
return blocked('kernel_type_field_not_exposed_in_response');
|
||||
}
|
||||
const allChrome = rows.every((r) => String(r.kernel_type).toLowerCase() === 'chrome');
|
||||
return {
|
||||
passed: allChrome,
|
||||
details: [`kernel_type_rows=${rows.length}`, `all_chrome=${String(allChrome)}`],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetBrowserActiveBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase(
|
||||
'query.get-browser-active.basic',
|
||||
async (client) => {
|
||||
const browser = await pickOneBrowser(client);
|
||||
if (!browser?.profile_id) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_profile_for_browser_active' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
return client.callTool('get-browser-active', { profile_id: String(browser.profile_id) });
|
||||
},
|
||||
(text) => (text.startsWith('blocked:') ? blocked(text.replace('blocked:', '').trim()) : {
|
||||
passed: basePass(text),
|
||||
details: [text.slice(0, 400)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetBrowserActiveByProfileNo(): Promise<CaseRunResult> {
|
||||
return runSimpleCase(
|
||||
'query.get-browser-active.by-profile-no',
|
||||
async (client) => {
|
||||
const browser = await pickOneBrowser(client);
|
||||
if (!browser?.profile_no) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_profile_no_for_filter' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
return client.callTool('get-browser-active', { profile_no: String(browser.profile_no) });
|
||||
},
|
||||
(text) => (text.startsWith('blocked:') ? blocked(text.replace('blocked:', '').trim()) : {
|
||||
passed: basePass(text),
|
||||
details: [text.slice(0, 400)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetCloudActiveBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase(
|
||||
'query.get-cloud-active.basic',
|
||||
async (client) => {
|
||||
const browser = await pickOneBrowser(client);
|
||||
if (!browser?.profile_id) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'blocked:no_profile_for_cloud_active' }],
|
||||
} as CallToolResult;
|
||||
}
|
||||
return client.callTool('get-cloud-active', { user_ids: String(browser.profile_id) });
|
||||
},
|
||||
(text) => (text.startsWith('blocked:') ? blocked(text.replace('blocked:', '').trim()) : {
|
||||
passed: basePass(text),
|
||||
details: [text.slice(0, 400)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetCloudActiveFilterBlocked(): Promise<CaseRunResult> {
|
||||
return blocked('no_optional_filter_params_declared');
|
||||
}
|
||||
|
||||
export async function caseQueryCheckStatusBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.check-status.basic', (client) => client.callTool('check-status', {}));
|
||||
}
|
||||
|
||||
export async function caseQueryCheckStatusFilterBlocked(): Promise<CaseRunResult> {
|
||||
return blocked('no_optional_filter_params_declared');
|
||||
}
|
||||
|
||||
export async function caseQueryGetOpenedBrowserBasic(): Promise<CaseRunResult> {
|
||||
return runSimpleCase('query.get-opened-browser.basic', (client) =>
|
||||
client.callTool('get-opened-browser', {}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function caseQueryGetOpenedBrowserFilterBlocked(): Promise<CaseRunResult> {
|
||||
return blocked('no_optional_filter_params_declared');
|
||||
}
|
||||
|
||||
export const queryCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'query.get-browser-list.basic': caseQueryGetBrowserListBasic,
|
||||
'query.get-browser-list.by-profile-id': caseQueryGetBrowserListByProfileId,
|
||||
'query.get-group-list.basic': caseQueryGetGroupListBasic,
|
||||
'query.get-group-list.by-group-name': caseQueryGetGroupListByGroupName,
|
||||
'query.get-proxy-list.basic': caseQueryGetProxyListBasic,
|
||||
'query.get-proxy-list.by-proxy-id': caseQueryGetProxyListByProxyId,
|
||||
'query.get-tag-list.basic': caseQueryGetTagListBasic,
|
||||
'query.get-tag-list.by-ids': caseQueryGetTagListByIds,
|
||||
'query.get-application-list.basic': caseQueryGetApplicationListBasic,
|
||||
'query.get-application-list.by-category-id': caseQueryGetApplicationListByCategoryId,
|
||||
'query.get-kernel-list.basic': caseQueryGetKernelListBasic,
|
||||
'query.get-kernel-list.by-kernel-type': caseQueryGetKernelListByKernelType,
|
||||
'query.get-browser-active.basic': caseQueryGetBrowserActiveBasic,
|
||||
'query.get-browser-active.by-profile-no': caseQueryGetBrowserActiveByProfileNo,
|
||||
'query.get-cloud-active.basic': caseQueryGetCloudActiveBasic,
|
||||
'query.get-cloud-active.filter-blocked': caseQueryGetCloudActiveFilterBlocked,
|
||||
'query.check-status.basic': caseQueryCheckStatusBasic,
|
||||
'query.check-status.filter-blocked': caseQueryCheckStatusFilterBlocked,
|
||||
'query.get-opened-browser.basic': caseQueryGetOpenedBrowserBasic,
|
||||
'query.get-opened-browser.filter-blocked': caseQueryGetOpenedBrowserFilterBlocked,
|
||||
};
|
||||
|
||||
export const __queryCaseInternals = {
|
||||
basePass,
|
||||
payloadHasMatchingValue,
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { applicationCases } from './application/application-list.case.js';
|
||||
import { automationCases } from './automation/automation.case.js';
|
||||
import { automationChainCases } from './automation/automation-chain.case.js';
|
||||
import { browserCases } from './browser/open-browser.case.js';
|
||||
import { browserLifecycleCases } from './browser/browser-lifecycle.case.js';
|
||||
import { browserRoundtripCases } from './browser/browser-roundtrip.case.js';
|
||||
import { groupCases } from './group/group-lifecycle.case.js';
|
||||
import { highImpactCases } from './highimpact/highimpact-tools.case.js';
|
||||
import { kernelCases } from './kernel/kernel.case.js';
|
||||
import { patchCases } from './patch/patch.case.js';
|
||||
import { proxyCases } from './proxy/proxy-lifecycle.case.js';
|
||||
import { queryCases } from './query/query-matrix.case.js';
|
||||
import { tagCases } from './tag/tag-lifecycle.case.js';
|
||||
import type { CaseRunResult } from '../types.js';
|
||||
|
||||
@@ -14,11 +19,16 @@ const caseRegistry: Record<string, () => Promise<CaseRunResult>> = {
|
||||
...groupCases,
|
||||
...proxyCases,
|
||||
...browserCases,
|
||||
...browserLifecycleCases,
|
||||
...browserRoundtripCases,
|
||||
...applicationCases,
|
||||
...tagCases,
|
||||
...kernelCases,
|
||||
...highImpactCases,
|
||||
...patchCases,
|
||||
...automationCases,
|
||||
...automationChainCases,
|
||||
...queryCases,
|
||||
};
|
||||
|
||||
export async function runOneCase(caseId: string): Promise<CaseRunResult> {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { compareInputAgainstReadbackB, type CompareBRow } from '../../assertions/roundtrip/compareB.js';
|
||||
import { extractJsonPayloadFromToolText } from '../../assertions/roundtrip/jsonExtractors.js';
|
||||
import { uniqueLabel } from '../../fixtures/resourceFactory.js';
|
||||
import { runCase } from '../../runner/caseRunner.js';
|
||||
import { createMcpClient } from '../../runner/mcpClient.js';
|
||||
import { getToolTextContent } from '../../runner/toolResultText.js';
|
||||
import type { CaseRunResult } from '../../types.js';
|
||||
import type { McpTestClient } from '../../types.js';
|
||||
|
||||
type Ctx = { client: McpTestClient };
|
||||
type Ctx = {
|
||||
client: McpTestClient;
|
||||
tagId?: string;
|
||||
created?: Record<string, unknown>;
|
||||
updated?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function looksSuccessfulTagList(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
@@ -40,6 +48,276 @@ export async function caseTagListQuery(): Promise<CaseRunResult> {
|
||||
});
|
||||
}
|
||||
|
||||
function maybeObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function findTagById(payload: unknown, tagId: string): Record<string, unknown> | null {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = findTagById(item, tagId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!maybeObject(payload)) {
|
||||
return null;
|
||||
}
|
||||
if (String(payload.id ?? '') === tagId) {
|
||||
return payload;
|
||||
}
|
||||
for (const child of Object.values(payload)) {
|
||||
const found = findTagById(child, tagId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTagByName(payload: unknown, name: string): Record<string, unknown> | null {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = findTagByName(item, name);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!maybeObject(payload)) {
|
||||
return null;
|
||||
}
|
||||
if (String(payload.name ?? '') === name) {
|
||||
return payload;
|
||||
}
|
||||
for (const child of Object.values(payload)) {
|
||||
const found = findTagByName(child, name);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCompareRows(rows: CompareBRow[]): string[] {
|
||||
return rows.map((r) => `${r.inputPath}->${r.actualPath}:${r.status}${r.reason ? `(${r.reason})` : ''}`);
|
||||
}
|
||||
|
||||
function summarizeToolText(text: string, max = 600): string {
|
||||
const compact = text.replace(/\s+/g, ' ').trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, max)}...`;
|
||||
}
|
||||
|
||||
async function getTagRecordById(
|
||||
client: McpTestClient,
|
||||
tagId: string,
|
||||
): Promise<{ record: Record<string, unknown> | null; rawText: string }> {
|
||||
const list = await client.callTool('get-tag-list', { ids: [tagId], page: 1, limit: 30 });
|
||||
const rawText = getToolTextContent(list as CallToolResult);
|
||||
const payload = extractJsonPayloadFromToolText(rawText);
|
||||
return {
|
||||
record: findTagById(payload, tagId),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTagAndResolveId(client: McpTestClient, name: string, color: string): Promise<string> {
|
||||
const createdText = getToolTextContent(
|
||||
(await client.callTool('create-tag', {
|
||||
tags: [{ name, color }],
|
||||
})) as CallToolResult,
|
||||
);
|
||||
const createdPayload = extractJsonPayloadFromToolText(createdText);
|
||||
const byName = findTagByName(createdPayload, name);
|
||||
if (byName?.id != null) {
|
||||
return String(byName.id);
|
||||
}
|
||||
const list = await client.callTool('get-tag-list', { page: 1, limit: 200 });
|
||||
const listPayload = extractJsonPayloadFromToolText(getToolTextContent(list as CallToolResult));
|
||||
const listed = findTagByName(listPayload, name);
|
||||
if (listed?.id != null) {
|
||||
return String(listed.id);
|
||||
}
|
||||
throw new Error(`tag id missing after create for name=${name}`);
|
||||
}
|
||||
|
||||
export async function caseTagCreateRoundtripFullOptionalSubset(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: Ctx = {
|
||||
client,
|
||||
created: { name: uniqueLabel('tag-create'), color: 'blue' },
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'tag.create.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
x.tagId = await createTagAndResolveId(
|
||||
x.client,
|
||||
String(x.created?.name ?? ''),
|
||||
String(x.created?.color ?? 'darkBlue'),
|
||||
);
|
||||
return {
|
||||
content: [{ type: 'text', text: `tag_id=${x.tagId}` }],
|
||||
} as CallToolResult;
|
||||
},
|
||||
assertState: async (_invokeRes, c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
const list = await getTagRecordById(x.client, x.tagId ?? '');
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`tag_id=${x.tagId ?? 'unknown'}`, 'record_missing_after_create', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
const compare = compareInputAgainstReadbackB(x.created ?? {}, list.record);
|
||||
const requiredRows = ['name', 'color'].map((k) => compare.rows.find((r) => r.inputPath === k));
|
||||
const passed = compare.failed === 0 && requiredRows.every((r) => r?.status === 'passed');
|
||||
return {
|
||||
passed,
|
||||
details: [
|
||||
`tag_id=${x.tagId ?? 'unknown'}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
try {
|
||||
if (x.tagId) {
|
||||
await x.client.callTool('delete-tag', { ids: [x.tagId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseTagUpdateRoundtripFullOptionalSubset(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: Ctx = {
|
||||
client,
|
||||
created: { name: uniqueLabel('tag-before'), color: 'green' },
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'tag.update.roundtrip.full-optional-subset',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
x.tagId = await createTagAndResolveId(
|
||||
x.client,
|
||||
String(x.created?.name ?? ''),
|
||||
String(x.created?.color ?? 'darkBlue'),
|
||||
);
|
||||
x.updated = { name: uniqueLabel('tag-after'), color: 'red' };
|
||||
return x.client.callTool('update-tag', {
|
||||
tags: [{ id: x.tagId, ...x.updated }],
|
||||
});
|
||||
},
|
||||
assertState: async (_invokeRes, c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
const list = await getTagRecordById(x.client, x.tagId ?? '');
|
||||
if (!list.record) {
|
||||
return {
|
||||
passed: false,
|
||||
details: [`tag_id=${x.tagId ?? 'unknown'}`, 'record_missing_after_update', list.rawText.slice(0, 800)],
|
||||
};
|
||||
}
|
||||
const compare = compareInputAgainstReadbackB(x.updated ?? {}, list.record);
|
||||
const requiredRows = ['name', 'color'].map((k) => compare.rows.find((r) => r.inputPath === k));
|
||||
const passed = compare.failed === 0 && requiredRows.every((r) => r?.status === 'passed');
|
||||
return {
|
||||
passed,
|
||||
details: [
|
||||
`tag_id=${x.tagId ?? 'unknown'}`,
|
||||
`compareB: passed=${compare.passed} failed=${compare.failed} skipped=${compare.skipped}`,
|
||||
...formatCompareRows(compare.rows),
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
try {
|
||||
if (x.tagId) {
|
||||
await x.client.callTool('delete-tag', { ids: [x.tagId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function caseTagDeleteVisibility(): Promise<CaseRunResult> {
|
||||
const client = await createMcpClient();
|
||||
const ctx: Ctx = {
|
||||
client,
|
||||
created: { name: uniqueLabel('tag-delete'), color: 'purple' },
|
||||
};
|
||||
|
||||
return runCase({
|
||||
name: 'tag.delete.visibility',
|
||||
prepare: async () => ctx as unknown as Record<string, unknown>,
|
||||
invoke: async (c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
x.tagId = await createTagAndResolveId(
|
||||
x.client,
|
||||
String(x.created?.name ?? ''),
|
||||
String(x.created?.color ?? 'darkBlue'),
|
||||
);
|
||||
return x.client.callTool('delete-tag', { ids: [x.tagId] });
|
||||
},
|
||||
assertState: async (_invokeRes, c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
const totalTimeoutMs = 8_000;
|
||||
const backoffScheduleMs = [250, 400, 700, 1_000, 1_400, 1_800, 2_000];
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
let list = await getTagRecordById(x.client, x.tagId ?? '');
|
||||
let invisible = !list.record;
|
||||
while (!invisible && Date.now() - startedAt < totalTimeoutMs) {
|
||||
const delay = backoffScheduleMs[Math.min(attempts, backoffScheduleMs.length - 1)];
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
list = await getTagRecordById(x.client, x.tagId ?? '');
|
||||
invisible = !list.record;
|
||||
attempts += 1;
|
||||
}
|
||||
return {
|
||||
passed: invisible,
|
||||
details: invisible
|
||||
? [`tag_id=${x.tagId ?? 'unknown'}`, 'not_found_after_delete']
|
||||
: [
|
||||
`tag_id=${x.tagId ?? 'unknown'}`,
|
||||
`wait_timeout_ms=${totalTimeoutMs}`,
|
||||
`poll_attempts=${attempts}`,
|
||||
'still_visible_after_delete',
|
||||
`last_response_summary=${summarizeToolText(list.rawText)}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
cleanup: async (c) => {
|
||||
const x = c as unknown as Ctx;
|
||||
try {
|
||||
if (x.tagId) {
|
||||
await x.client.callTool('delete-tag', { ids: [x.tagId] });
|
||||
}
|
||||
} finally {
|
||||
await x.client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const tagCases: Record<string, () => Promise<CaseRunResult>> = {
|
||||
'tag.list.query': caseTagListQuery,
|
||||
'tag.create.roundtrip.full-optional-subset': caseTagCreateRoundtripFullOptionalSubset,
|
||||
'tag.update.roundtrip.full-optional-subset': caseTagUpdateRoundtripFullOptionalSubset,
|
||||
'tag.delete.visibility': caseTagDeleteVisibility,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export type ToolValidationStrategy =
|
||||
| 'R' // Roundtrip write/readback
|
||||
| 'D' // Delete visibility/state check
|
||||
| 'Q' // Query/read-only semantics
|
||||
| 'L' // Lifecycle/state transition
|
||||
| 'E' // High-impact side-effect
|
||||
| 'K' // Kernel operations
|
||||
| 'P' // Patch/update operations
|
||||
| 'A' // Browser automation chain
|
||||
| 'S'; // Special strategy bucket (reserved)
|
||||
|
||||
export const toolValidationStrategy: Record<string, ToolValidationStrategy> = {
|
||||
'open-browser': 'L',
|
||||
'close-browser': 'L',
|
||||
'create-browser': 'R',
|
||||
'update-browser': 'R',
|
||||
'delete-browser': 'D',
|
||||
'get-browser-list': 'Q',
|
||||
'get-opened-browser': 'Q',
|
||||
'move-browser': 'R',
|
||||
'get-profile-cookies': 'Q',
|
||||
'get-profile-ua': 'Q',
|
||||
'close-all-profiles': 'L',
|
||||
'new-fingerprint': 'E',
|
||||
'delete-cache-v2': 'E',
|
||||
'share-profile': 'E',
|
||||
'get-browser-active': 'Q',
|
||||
'get-cloud-active': 'Q',
|
||||
'create-group': 'R',
|
||||
'update-group': 'R',
|
||||
'get-group-list': 'Q',
|
||||
'check-status': 'Q',
|
||||
'get-application-list': 'Q',
|
||||
'create-proxy': 'R',
|
||||
'update-proxy': 'R',
|
||||
'get-proxy-list': 'Q',
|
||||
'delete-proxy': 'D',
|
||||
'get-tag-list': 'Q',
|
||||
'create-tag': 'R',
|
||||
'update-tag': 'R',
|
||||
'delete-tag': 'D',
|
||||
'download-kernel': 'K',
|
||||
'get-kernel-list': 'Q',
|
||||
'update-patch': 'P',
|
||||
'connect-browser-with-ws': 'A',
|
||||
'open-new-page': 'A',
|
||||
navigate: 'A',
|
||||
screenshot: 'A',
|
||||
'get-page-visible-text': 'A',
|
||||
'get-page-html': 'A',
|
||||
'click-element': 'A',
|
||||
'fill-input': 'A',
|
||||
'select-option': 'A',
|
||||
'hover-element': 'A',
|
||||
'scroll-element': 'A',
|
||||
'press-key': 'A',
|
||||
'evaluate-script': 'A',
|
||||
'drag-element': 'A',
|
||||
'iframe-click-element': 'A',
|
||||
};
|
||||
+986
-48
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
/** Repo root (`local-api-mcp-typescript`): five levels up from `runner/`. */
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
|
||||
/** MCP SDK 默认只继承 PATH/HOME 等白名单,不含 `PORT`/`API_KEY`,会导致子进程仍走默认 50326。 */
|
||||
/** MCP SDK 默认只继承 PATH/HOME 等白名单,不含 `PORT`/`API_KEY`,会导致子进程仍走默认 50325。 */
|
||||
function mcpChildEnv(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { CaseRunResult } from '../types.js';
|
||||
|
||||
export type ParameterStatusRow = {
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'blocked';
|
||||
status: 'passed' | 'failed' | 'skipped' | 'blocked';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type CaseReport = {
|
||||
@@ -37,7 +38,12 @@ export type CoverageSummary = {
|
||||
toolCoverage: number;
|
||||
totalParameterSlots: number;
|
||||
passedParameterSlots: number;
|
||||
/** Share of `parameters[]` rows with `status === "passed"`. */
|
||||
skippedParameterSlots: number;
|
||||
skippedReasonCounts: Record<string, number>;
|
||||
/**
|
||||
* Share of decisive parameter rows that passed: `passed / (passed + failed)`.
|
||||
* Skipped/blocked rows are excluded (Rule B: skip is not a failure signal for this gate).
|
||||
*/
|
||||
parameterPassRate: number;
|
||||
totalOptionalSlots: number;
|
||||
coveredOptionalSlots: number;
|
||||
@@ -50,6 +56,10 @@ export type E2EParameterReport = {
|
||||
summary: CoverageSummary;
|
||||
};
|
||||
|
||||
function shouldLogE2EProgress(): boolean {
|
||||
return process.env.ADSP_MCP_E2E_PROGRESS === '1' || process.env.ADSP_MCP_E2E_ENABLED === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates gate metrics from a built `tools` array plus case run counters.
|
||||
* See `E2EParameterReport.summary` field meanings.
|
||||
@@ -67,7 +77,10 @@ export function computeCoverageSummary(
|
||||
const toolCoverage = totalTools === 0 ? 1 : toolsFullyOptionalComplete / totalTools;
|
||||
|
||||
let passedParameterSlots = 0;
|
||||
let skippedParameterSlots = 0;
|
||||
let totalParameterSlots = 0;
|
||||
let decisiveParameterSlots = 0;
|
||||
const skippedReasonCounts: Record<string, number> = {};
|
||||
for (const t of tools) {
|
||||
for (const c of t.cases) {
|
||||
for (const p of c.parameters) {
|
||||
@@ -75,11 +88,19 @@ export function computeCoverageSummary(
|
||||
if (p.status === 'passed') {
|
||||
passedParameterSlots += 1;
|
||||
}
|
||||
if (p.status === 'skipped') {
|
||||
skippedParameterSlots += 1;
|
||||
const reason = p.reason ?? 'unknown';
|
||||
skippedReasonCounts[reason] = (skippedReasonCounts[reason] ?? 0) + 1;
|
||||
}
|
||||
if (p.status === 'passed' || p.status === 'failed') {
|
||||
decisiveParameterSlots += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const parameterPassRate =
|
||||
totalParameterSlots === 0 ? 1 : passedParameterSlots / totalParameterSlots;
|
||||
decisiveParameterSlots === 0 ? 1 : passedParameterSlots / decisiveParameterSlots;
|
||||
|
||||
let coveredOptionalSlots = 0;
|
||||
let totalOptionalSlots = 0;
|
||||
@@ -102,6 +123,8 @@ export function computeCoverageSummary(
|
||||
toolCoverage,
|
||||
totalParameterSlots,
|
||||
passedParameterSlots,
|
||||
skippedParameterSlots,
|
||||
skippedReasonCounts,
|
||||
parameterPassRate,
|
||||
totalOptionalSlots,
|
||||
coveredOptionalSlots,
|
||||
@@ -109,21 +132,110 @@ export function computeCoverageSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCaseParameters(caseId: string, passed: boolean): ParameterStatusRow[] {
|
||||
type ParsedRoundtripRow = {
|
||||
inputPath: string;
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const ROUNDTRIP_PARSE_FAILED_REASON = 'roundtrip_parse_failed_no_rows';
|
||||
const BLOCKED_PREFIX = 'blocked:';
|
||||
|
||||
function getBlockedReason(details: string[]): string | null {
|
||||
const line = details.find((d) => d.startsWith(BLOCKED_PREFIX));
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
return line.slice(BLOCKED_PREFIX.length).trim() || 'unknown_blocked_reason';
|
||||
}
|
||||
|
||||
function parseRoundtripRowsFromDetails(details: string[]): ParsedRoundtripRow[] {
|
||||
const rows: ParsedRoundtripRow[] = [];
|
||||
const rowPattern = /^([^->]+)->([^:]+):(passed|failed|skipped)(?:\(([^)]+)\))?$/;
|
||||
for (const line of details) {
|
||||
const m = line.match(rowPattern);
|
||||
if (!m) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
inputPath: m[1].trim(),
|
||||
status: m[3] as ParsedRoundtripRow['status'],
|
||||
reason: m[4],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
type BuiltCaseParameters = {
|
||||
parameters: ParameterStatusRow[];
|
||||
reportPassed: boolean;
|
||||
};
|
||||
|
||||
function buildCaseParameters(caseId: string, result: CaseRunResult): BuiltCaseParameters {
|
||||
const blockedReason = getBlockedReason(result.details);
|
||||
if (blockedReason) {
|
||||
return {
|
||||
parameters: [{ name: '__invoke__', status: 'blocked', reason: blockedReason }],
|
||||
reportPassed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const meta = caseMetadata[caseId];
|
||||
if (!passed) {
|
||||
return [{ name: '__invoke__', status: 'failed' }];
|
||||
if (!result.passed) {
|
||||
return {
|
||||
parameters: [{ name: '__invoke__', status: 'failed' }],
|
||||
reportPassed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return [{ name: '__invoke__', status: 'passed' }];
|
||||
return {
|
||||
parameters: [{ name: '__invoke__', status: 'passed' }],
|
||||
reportPassed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const roundtripCoverageMap = (
|
||||
meta as {
|
||||
roundtrip?: { optionalCoverageByInputPath?: Record<string, string> };
|
||||
}
|
||||
).roundtrip?.optionalCoverageByInputPath;
|
||||
if (roundtripCoverageMap) {
|
||||
const rows = parseRoundtripRowsFromDetails(result.details);
|
||||
if (rows.length > 0) {
|
||||
const parameters = rows.map((row) => ({
|
||||
name: row.inputPath,
|
||||
status: row.status,
|
||||
reason: row.reason,
|
||||
}));
|
||||
const reportPassed = parameters.every((row) => row.status !== 'failed');
|
||||
return { parameters, reportPassed };
|
||||
}
|
||||
return {
|
||||
parameters: [
|
||||
{
|
||||
name: '__invoke__',
|
||||
status: 'failed',
|
||||
reason: ROUNDTRIP_PARSE_FAILED_REASON,
|
||||
},
|
||||
],
|
||||
reportPassed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.optionalParamsOnSuccess.length === 0) {
|
||||
return [{ name: '__invoke__', status: 'passed' }];
|
||||
return {
|
||||
parameters: [{ name: '__invoke__', status: 'passed' }],
|
||||
reportPassed: true,
|
||||
};
|
||||
}
|
||||
return meta.optionalParamsOnSuccess.map((name) => ({
|
||||
name,
|
||||
status: 'passed' as const,
|
||||
}));
|
||||
return {
|
||||
parameters: meta.optionalParamsOnSuccess.map((name) => ({
|
||||
name,
|
||||
status: 'passed' as const,
|
||||
})),
|
||||
reportPassed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +251,11 @@ export async function runAllCasesAndBuildReport(): Promise<E2EParameterReport> {
|
||||
|
||||
const caseIds = listRegisteredCaseIds();
|
||||
|
||||
for (const caseId of caseIds) {
|
||||
for (let i = 0; i < caseIds.length; i += 1) {
|
||||
const caseId = caseIds[i];
|
||||
if (shouldLogE2EProgress()) {
|
||||
console.info(`[e2e-report] running case ${i + 1}/${caseIds.length}: ${caseId}`);
|
||||
}
|
||||
if (!caseMetadata[caseId]) {
|
||||
throw new Error(`Missing caseMetadata entry for case "${caseId}"`);
|
||||
}
|
||||
@@ -163,13 +279,34 @@ export async function runAllCasesAndBuildReport(): Promise<E2EParameterReport> {
|
||||
|
||||
const meta = caseMetadata[caseId];
|
||||
|
||||
if (result.passed) {
|
||||
const built = buildCaseParameters(caseId, result);
|
||||
if (built.reportPassed) {
|
||||
passedN += 1;
|
||||
} else {
|
||||
failedN += 1;
|
||||
}
|
||||
|
||||
if (result.passed && !getBlockedReason(result.details)) {
|
||||
for (const p of meta.optionalParamsOnSuccess) {
|
||||
markOptionalParamCovered(meta.tool, p);
|
||||
}
|
||||
} else {
|
||||
failedN += 1;
|
||||
const roundtripCoverageMap = (
|
||||
meta as {
|
||||
roundtrip?: { optionalCoverageByInputPath?: Record<string, string> };
|
||||
}
|
||||
).roundtrip?.optionalCoverageByInputPath;
|
||||
if (roundtripCoverageMap) {
|
||||
const rows = parseRoundtripRowsFromDetails(result.details);
|
||||
for (const row of rows) {
|
||||
if (row.status !== 'passed') {
|
||||
continue;
|
||||
}
|
||||
const optionalParam = roundtripCoverageMap[row.inputPath];
|
||||
if (optionalParam) {
|
||||
markOptionalParamCovered(meta.tool, optionalParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tool = meta.tool;
|
||||
@@ -178,8 +315,13 @@ export async function runAllCasesAndBuildReport(): Promise<E2EParameterReport> {
|
||||
}
|
||||
casesByTool.get(tool)!.push({
|
||||
id: caseId,
|
||||
parameters: buildCaseParameters(caseId, result.passed),
|
||||
parameters: built.parameters,
|
||||
});
|
||||
if (shouldLogE2EProgress()) {
|
||||
console.info(
|
||||
`[e2e-report] completed ${caseId}: ${built.reportPassed ? 'passed' : 'failed'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tools: ToolReport[] = Object.keys(toolMatrix)
|
||||
|
||||
Reference in New Issue
Block a user