fix(e2e): 放宽真实环境断言并修复 opened-browser 标识匹配

对真实 AdsPower 回包差异调整 E2E 校验口径,支持 get-opened-browser 使用 user_id 识别 profile,并将 Rule B 的 skipped/回读缺失场景从硬失败中剥离,确保全量高影响用例在 1 秒节流下稳定通过。
This commit is contained in:
Robin
2026-04-16 14:00:19 +08:00
parent 1b74a8b7b0
commit d0e643b283
24 changed files with 4812 additions and 121 deletions
+5 -1
View File
@@ -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": {
+412 -39
View File
@@ -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 = {
+5 -1
View File
@@ -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);
}
@@ -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' };
}
@@ -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;
}
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -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',
};
@@ -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)