callman-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +29 -7
  3. package/dist/client/api.d.ts +83 -1
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/api.js +105 -0
  6. package/dist/client/api.js.map +1 -1
  7. package/dist/client/errors.d.ts.map +1 -1
  8. package/dist/client/errors.js +46 -11
  9. package/dist/client/errors.js.map +1 -1
  10. package/dist/client/types.d.ts +231 -0
  11. package/dist/client/types.d.ts.map +1 -1
  12. package/dist/domain/diagram/patch.d.ts +2 -2
  13. package/dist/domain/uiTest/compose.d.ts +55 -0
  14. package/dist/domain/uiTest/compose.d.ts.map +1 -0
  15. package/dist/domain/uiTest/compose.js +228 -0
  16. package/dist/domain/uiTest/compose.js.map +1 -0
  17. package/dist/domain/uiTest/summarize.d.ts +273 -0
  18. package/dist/domain/uiTest/summarize.d.ts.map +1 -0
  19. package/dist/domain/uiTest/summarize.js +145 -0
  20. package/dist/domain/uiTest/summarize.js.map +1 -0
  21. package/dist/lib/truncate.d.ts +1 -1
  22. package/dist/lib/truncate.d.ts.map +1 -1
  23. package/dist/lib/truncate.js.map +1 -1
  24. package/dist/resources/docs/overview.d.ts +1 -1
  25. package/dist/resources/docs/overview.d.ts.map +1 -1
  26. package/dist/resources/docs/overview.js +12 -3
  27. package/dist/resources/docs/overview.js.map +1 -1
  28. package/dist/resources/docs/uiTests.d.ts +2 -0
  29. package/dist/resources/docs/uiTests.d.ts.map +1 -0
  30. package/dist/resources/docs/uiTests.js +101 -0
  31. package/dist/resources/docs/uiTests.js.map +1 -0
  32. package/dist/resources/generated/aiPacks.d.ts +1 -1
  33. package/dist/resources/generated/aiPacks.js +2 -2
  34. package/dist/resources/index.d.ts.map +1 -1
  35. package/dist/resources/index.js +13 -2
  36. package/dist/resources/index.js.map +1 -1
  37. package/dist/server.d.ts +1 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +2 -2
  40. package/dist/server.js.map +1 -1
  41. package/dist/tools/catalog.d.ts +1 -1
  42. package/dist/tools/catalog.d.ts.map +1 -1
  43. package/dist/tools/catalog.js +31 -0
  44. package/dist/tools/catalog.js.map +1 -1
  45. package/dist/tools/index.d.ts.map +1 -1
  46. package/dist/tools/index.js +6 -0
  47. package/dist/tools/index.js.map +1 -1
  48. package/dist/tools/lookups.d.ts +19 -1
  49. package/dist/tools/lookups.d.ts.map +1 -1
  50. package/dist/tools/lookups.js +77 -0
  51. package/dist/tools/lookups.js.map +1 -1
  52. package/dist/tools/shared.d.ts +5 -0
  53. package/dist/tools/shared.d.ts.map +1 -1
  54. package/dist/tools/shared.js +10 -0
  55. package/dist/tools/shared.js.map +1 -1
  56. package/dist/tools/uiTestAdmin.d.ts +4 -0
  57. package/dist/tools/uiTestAdmin.d.ts.map +1 -0
  58. package/dist/tools/uiTestAdmin.js +509 -0
  59. package/dist/tools/uiTestAdmin.js.map +1 -0
  60. package/dist/tools/uiTestRuns.d.ts +24 -0
  61. package/dist/tools/uiTestRuns.d.ts.map +1 -0
  62. package/dist/tools/uiTestRuns.js +367 -0
  63. package/dist/tools/uiTestRuns.js.map +1 -0
  64. package/dist/tools/uiTestShared.d.ts +236 -0
  65. package/dist/tools/uiTestShared.d.ts.map +1 -0
  66. package/dist/tools/uiTestShared.js +187 -0
  67. package/dist/tools/uiTestShared.js.map +1 -0
  68. package/dist/tools/uiTests.d.ts +4 -0
  69. package/dist/tools/uiTests.d.ts.map +1 -0
  70. package/dist/tools/uiTests.js +572 -0
  71. package/dist/tools/uiTests.js.map +1 -0
  72. package/dist-cjs/client/api.js +105 -0
  73. package/dist-cjs/client/errors.js +46 -11
  74. package/dist-cjs/domain/uiTest/compose.js +242 -0
  75. package/dist-cjs/domain/uiTest/summarize.js +155 -0
  76. package/dist-cjs/resources/docs/overview.js +12 -3
  77. package/dist-cjs/resources/docs/uiTests.js +103 -0
  78. package/dist-cjs/resources/generated/aiPacks.js +2 -2
  79. package/dist-cjs/resources/index.js +11 -0
  80. package/dist-cjs/server.js +2 -2
  81. package/dist-cjs/tools/catalog.js +31 -0
  82. package/dist-cjs/tools/index.js +6 -0
  83. package/dist-cjs/tools/lookups.js +83 -1
  84. package/dist-cjs/tools/shared.js +12 -1
  85. package/dist-cjs/tools/uiTestAdmin.js +512 -0
  86. package/dist-cjs/tools/uiTestRuns.js +372 -0
  87. package/dist-cjs/tools/uiTestShared.js +202 -0
  88. package/dist-cjs/tools/uiTests.js +575 -0
  89. package/package.json +3 -3
@@ -0,0 +1,575 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerUiTestTools = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const callman_core_1 = require("callman-core");
6
+ const zod_1 = require("zod");
7
+ const errors_js_1 = require("../client/errors.js");
8
+ const resolve_js_1 = require("../domain/resolve.js");
9
+ const compose_js_1 = require("../domain/uiTest/compose.js");
10
+ const summarize_js_1 = require("../domain/uiTest/summarize.js");
11
+ const pagination_js_1 = require("../lib/pagination.js");
12
+ const strict_js_1 = require("../lib/strict.js");
13
+ const truncate_js_1 = require("../lib/truncate.js");
14
+ const lookups_js_1 = require("./lookups.js");
15
+ const shared_js_1 = require("./shared.js");
16
+ const uiTestShared_js_1 = require("./uiTestShared.js");
17
+ // UI tests (web / Android / iOS flows): the flow library and its folders.
18
+ // A flow is YAML text — header (url / appId …), `---`, steps — stored whole;
19
+ // every write sends the flow's full state (the backend upserts by clientId),
20
+ // linted with callman-core's lintUiTestFlow against the library (runFlow
21
+ // names) and the workspace's connections before it is saved.
22
+ const REVIEW_STATUSES = ["draft", "pending_review", "approved", "changes_requested"];
23
+ const flowFields = {
24
+ description: zod_1.z.string().max(5000).nullable().optional(),
25
+ testType: zod_1.z.enum(uiTestShared_js_1.TEST_TYPES).nullable().optional(),
26
+ severity: zod_1.z.enum(uiTestShared_js_1.SEVERITIES).nullable().optional(),
27
+ tags: zod_1.z.array(zod_1.z.string().trim().min(1).max(50)).max(30).optional(),
28
+ dismissPopups: zod_1.z.array(zod_1.z.string().trim().min(1).max(200)).max(50).optional().describe("Mobile: popup texts dismissed automatically during runs"),
29
+ browserKind: zod_1.z.enum(["chrome", "firefox", "webkit"]).nullable().optional().describe("Web: browser for desktop runs (the server runner is Chromium)"),
30
+ headless: zod_1.z.boolean().nullable().optional().describe("Web: headless desktop runs"),
31
+ runSettings: uiTestShared_js_1.runSettingsSchema.nullable().optional(),
32
+ };
33
+ const refuseCrossPlatformHeader = (platform, url, appId) => {
34
+ if (platform === "web" && appId)
35
+ throw new errors_js_1.ToolInputError("ARG_PLATFORM_MISMATCH", "appId is for android/ios flows — a web flow starts at `url`.");
36
+ if (platform !== "web" && url)
37
+ throw new errors_js_1.ToolInputError("ARG_PLATFORM_MISMATCH", "url is for web flows — a mobile flow names its app with `appId`.");
38
+ };
39
+ const nameTaken = (flows, workspaceId, name, except) => flows.find((flow) => flow.workspaceId === workspaceId && flow.clientId !== except && flow.name.trim().toLowerCase() === name.trim().toLowerCase());
40
+ const callsFlow = (content, name) => new RegExp(`^[ \\t]*-[ \\t]+runFlow:[ \\t]+["']${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][ \\t]*$`, "m").test(content);
41
+ const datasetKeysOf = (flow) => [...Object.keys(flow.dataset ?? {}), ...Object.keys(flow.datasetSources ?? {})];
42
+ const draftNote = (before, after) => {
43
+ if (!before && after.reviewStatus === "draft")
44
+ return "Saved as a draft. On-prem, schedules run the APPROVED version — submit_ui_test and have it approved; runs you start yourself (run_ui_test) use the current content.";
45
+ if (before?.reviewStatus === "approved" && after.reviewStatus && after.reviewStatus !== "approved")
46
+ return "The flow was approved and is now a draft again; schedules keep running the last approved version until it is re-approved (submit_ui_test).";
47
+ return undefined;
48
+ };
49
+ const registerUiTestTools = (server, ctx) => {
50
+ server.registerTool("list_ui_tests", {
51
+ title: "List UI tests",
52
+ description: "List UI tests (web / Android / iOS flows) across your workspaces (on-prem: your groups): platform, folder path, tags, type, severity, version and review status. Filters: platform, folder (subfolders included; null = root only), tag, search (name/description), reviewStatus, awaitingMyReview (on-prem: flows waiting for YOUR approval). get_ui_test reads one with its steps.",
53
+ inputSchema: {
54
+ workspace: shared_js_1.workspaceArg,
55
+ platform: uiTestShared_js_1.platformArg.optional(),
56
+ folder: zod_1.z.string().max(300).nullable().optional().describe("Folder id, name or path (A/B); null = only flows at the root"),
57
+ tag: zod_1.z.string().max(50).optional(),
58
+ search: zod_1.z.string().max(200).optional(),
59
+ reviewStatus: zod_1.z.enum(REVIEW_STATUSES).optional(),
60
+ awaitingMyReview: zod_1.z.boolean().optional(),
61
+ ...shared_js_1.cursorArgs,
62
+ },
63
+ annotations: shared_js_1.READ,
64
+ }, (0, shared_js_1.guarded)(async ({ workspace, platform, folder, tag, search, reviewStatus, awaitingMyReview, cursor, limit }) => {
65
+ const ws = workspace && !(0, lookups_js_1.isAllWorkspaces)(workspace) ? await ctx.resolveWorkspace(workspace) : null;
66
+ let note;
67
+ let flows;
68
+ if (awaitingMyReview) {
69
+ try {
70
+ flows = await ctx.api.uiTestsAwaitingReview();
71
+ }
72
+ catch (error) {
73
+ if (!(error instanceof errors_js_1.CallmanApiError && error.code === "REVIEW_DISABLED_CLOUD"))
74
+ throw error;
75
+ flows = [];
76
+ note = "There is no review workflow on Callman cloud — nothing awaits approval.";
77
+ }
78
+ }
79
+ else {
80
+ flows = await (0, lookups_js_1.loadUiTests)(ctx);
81
+ }
82
+ const [folders, workspaces] = await Promise.all([(0, lookups_js_1.loadUiTestFolders)(ctx).catch(() => []), ctx.listWorkspaces()]);
83
+ const folderPath = new Map(folders.map((entry) => [entry.id, entry.path]));
84
+ const scope = folder ? (0, lookups_js_1.uiTestFolderSubtree)(folders, (await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder)).item.id) : null;
85
+ const needle = search?.trim().toLowerCase();
86
+ const wantedTag = tag?.trim().toLowerCase();
87
+ const filtered = flows
88
+ .filter((flow) => !ws || flow.workspaceId === ws.id)
89
+ .filter((flow) => !platform || flow.platform === platform)
90
+ .filter((flow) => (folder === null ? !flow.folderId : !scope || (flow.folderId ? scope.has(flow.folderId) : false)))
91
+ .filter((flow) => !wantedTag || (flow.tags ?? []).some((entry) => entry.toLowerCase() === wantedTag))
92
+ .filter((flow) => !needle || flow.name.toLowerCase().includes(needle) || (flow.description ?? "").toLowerCase().includes(needle))
93
+ .filter((flow) => !reviewStatus || flow.reviewStatus === reviewStatus)
94
+ .sort((a, b) => a.name.localeCompare(b.name));
95
+ const page = (0, pagination_js_1.sliceWithCursor)(filtered, cursor, limit, JSON.stringify([ws?.id, platform, folder, wantedTag, needle, reviewStatus, awaitingMyReview]));
96
+ const workspaceName = new Map(workspaces.map((entry) => [entry.id, entry.name]));
97
+ return (0, shared_js_1.ok)({
98
+ ...(ws ? { workspace: (0, lookups_js_1.workspaceTag)(ws) } : {}),
99
+ uiTests: page.items.map((flow) => ({
100
+ ...(0, summarize_js_1.flowSummaryView)(flow, flow.folderId ? (folderPath.get(flow.folderId) ?? null) : null),
101
+ ...(workspaces.length > 1 ? { workspace: { id: flow.workspaceId, name: workspaceName.get(flow.workspaceId) ?? null } } : {}),
102
+ })),
103
+ total: page.total,
104
+ nextCursor: page.nextCursor,
105
+ ...(note ? { note } : {}),
106
+ });
107
+ }));
108
+ server.registerTool("get_ui_test", {
109
+ title: "Get a UI test",
110
+ description: "Read one UI test: header (url / appId …), settings (browser, headless, popups, run settings), dataset (secret-looking values masked unless reveal: true with environments:reveal), dataset sources, precondition sub-flows, the numbered top-level steps (numbers patch_ui_test_steps uses), a lint result and the raw YAML `content`.",
111
+ inputSchema: {
112
+ workspace: shared_js_1.workspaceArg,
113
+ flow: uiTestShared_js_1.flowArg,
114
+ reveal: zod_1.z.boolean().optional().describe("Return secret dataset values unmasked (needs environments:reveal)"),
115
+ includeContent: zod_1.z.boolean().default(true).describe("false = leave the raw YAML out"),
116
+ },
117
+ annotations: shared_js_1.READ,
118
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, reveal, includeContent }) => {
119
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
120
+ const fresh = await ctx.api.getUiTest(resolved.item.clientId, reveal ?? false);
121
+ const context = await (0, uiTestShared_js_1.lintContext)(ctx, (0, uiTestShared_js_1.workspaceIdOf)(fresh));
122
+ const lint = (0, uiTestShared_js_1.lintFlow)(fresh.content ?? "", fresh.platform, context, datasetKeysOf(fresh));
123
+ return (0, shared_js_1.ok)({
124
+ uiTest: await (0, uiTestShared_js_1.flowDetailView)(ctx, fresh, lint, context, { workspaces: await ctx.listWorkspaces(), reveal, includeContent }),
125
+ ...(resolved.annotation ? { resolved: [(0, resolve_js_1.resolvedSummary)(flow, resolved)] } : {}),
126
+ });
127
+ }));
128
+ server.registerTool("validate_ui_test", {
129
+ title: "Validate a UI test",
130
+ description: "Lint a flow without saving: YAML syntax, header (web needs url, mobile needs appId), unknown steps, missing arguments, steps the platform cannot run, runFlow names the library does not have, connection names the workspace does not have, {{placeholders}} nothing fills. Pass `content` (+ platform) to check a draft, or `flow` to check a saved one (with `content` it checks that text in the flow's context).",
131
+ inputSchema: {
132
+ workspace: shared_js_1.workspaceArg,
133
+ flow: uiTestShared_js_1.flowArg.optional(),
134
+ content: zod_1.z.string().max(1_000_000).optional().describe("Full flow YAML (header, ---, steps)"),
135
+ platform: uiTestShared_js_1.platformArg.optional(),
136
+ datasetKeys: zod_1.z.array(zod_1.z.string().max(200)).max(500).optional().describe("Dataset keys the flow will have (for the placeholder check)"),
137
+ },
138
+ annotations: shared_js_1.READ,
139
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, content, platform, datasetKeys }) => {
140
+ if (!flow && content === undefined)
141
+ throw new errors_js_1.ToolInputError("VALIDATE_NOTHING", "Pass `content` (+ platform) or `flow`.");
142
+ let target = null;
143
+ if (flow)
144
+ target = await ctx.api.getUiTest((await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow)).item.clientId);
145
+ const effectivePlatform = platform ?? target?.platform;
146
+ if (!effectivePlatform)
147
+ throw new errors_js_1.ToolInputError("PLATFORM_REQUIRED", "Pass `platform` (web, android or ios) with `content`.");
148
+ const ws = target ? target.workspaceId : (await ctx.resolveWorkspace(workspace)).id;
149
+ const context = await (0, uiTestShared_js_1.lintContext)(ctx, ws);
150
+ const text = content ?? target?.content ?? "";
151
+ const lint = (0, uiTestShared_js_1.lintFlow)(text, effectivePlatform, context, datasetKeys ?? (target ? datasetKeysOf(target) : []));
152
+ return (0, shared_js_1.ok)({
153
+ ...(target ? { uiTest: { id: target.clientId, name: target.name } } : {}),
154
+ platform: effectivePlatform,
155
+ lint: (0, uiTestShared_js_1.lintView)(lint, context),
156
+ header: lint.header,
157
+ preconditions: lint.preconditions,
158
+ steps: lint.steps,
159
+ });
160
+ }));
161
+ server.registerTool("create_ui_test", {
162
+ title: "Create a UI test",
163
+ description: "Create a UI test flow. READ callman://docs/ui-tests (concepts) and callman://docs/ui-test-steps (every step and its arguments) first. Give `platform`, `url` (web) or `appId` (android/ios), `steps` (YAML list or array of step objects) and optionally `preconditions` (names of existing flows that run first, e.g. Login — written as leading runFlow steps) — or the whole YAML as `content`. Data steps (dbQuery, apiCall, kafkaPublish/kafkaCheck, redisCheck) name workspace connections (list_connections). `{{key}}` placeholders come from `dataset` (or `datasetSources`: a DB query per key). The flow is linted against the library and connections: errors refuse the save unless force: true; dryRun: true returns the composed YAML and the lint result without saving. Names must be unique in the workspace (runFlow references flows by name).",
164
+ inputSchema: {
165
+ workspace: shared_js_1.workspaceArg,
166
+ name: zod_1.z.string().trim().min(1).max(200),
167
+ platform: uiTestShared_js_1.platformArg,
168
+ folder: zod_1.z.string().max(300).optional().describe("UI-test folder id, name or path"),
169
+ group: uiTestShared_js_1.groupArg,
170
+ url: zod_1.z.string().max(2000).optional().describe("Web: the entry URL ({{dataset}} placeholders allowed)"),
171
+ appId: zod_1.z.string().max(300).optional().describe("Android package / iOS bundle id"),
172
+ header: uiTestShared_js_1.headerArg,
173
+ preconditions: zod_1.z.array(zod_1.z.string().min(1).max(200)).max(20).optional().describe("Names of flows that run first (exact names)"),
174
+ steps: uiTestShared_js_1.stepsArg.optional(),
175
+ content: zod_1.z.string().max(1_000_000).optional().describe("The whole flow YAML instead of url/appId/header/preconditions/steps"),
176
+ ...flowFields,
177
+ dataset: zod_1.z.record(zod_1.z.string().max(10_000)).optional().describe("Test data: key → value, used as {{key}}"),
178
+ datasetSources: zod_1.z.record(uiTestShared_js_1.datasetSourceSchema).optional().describe("key → { connection, query, column? }: the value comes from a DB query at run time"),
179
+ force: zod_1.z.boolean().optional().describe("Save even when the linter reports errors"),
180
+ dryRun: shared_js_1.dryRunArg,
181
+ },
182
+ annotations: shared_js_1.WRITE,
183
+ }, (0, shared_js_1.guarded)(async ({ workspace, name, platform, folder, group, url, appId, header, preconditions, steps, content, dataset, datasetSources, force, dryRun, ...fields }) => {
184
+ if (content !== undefined && (steps !== undefined || url !== undefined || appId !== undefined || header !== undefined || preconditions !== undefined)) {
185
+ throw new errors_js_1.ToolInputError("CONTENT_EXCLUSIVE", "Pass either `content` (the whole YAML) or url/appId/header/preconditions/steps — not both.");
186
+ }
187
+ refuseCrossPlatformHeader(platform, url, appId);
188
+ const targetFolder = folder ? await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder) : null;
189
+ const ws = targetFolder ? { id: targetFolder.item.workspaceId } : await ctx.resolveWorkspace(workspace);
190
+ const existing = nameTaken(await (0, lookups_js_1.loadUiTests)(ctx), ws.id, name);
191
+ if (existing) {
192
+ throw new errors_js_1.ToolInputError("UI_TEST_NAME_EXISTS", `A UI test named "${existing.name}" already exists in this workspace (id ${existing.clientId}) — runFlow finds flows by name, so names must be unique. Update it (update_ui_test) or pick another name.`, { existing: { id: existing.clientId, name: existing.name } });
193
+ }
194
+ const composed = content ??
195
+ (0, compose_js_1.composeFlow)({
196
+ header: { ...(url ? { url } : {}), ...(appId ? { appId } : {}), ...(header ?? {}) },
197
+ preconditions,
198
+ steps: steps !== undefined ? (0, compose_js_1.stepsToYaml)(steps) : "",
199
+ });
200
+ const sources = datasetSources ? await (0, uiTestShared_js_1.resolveDatasetSources)(ctx, ws.id, datasetSources) : undefined;
201
+ const context = await (0, uiTestShared_js_1.lintContext)(ctx, ws.id);
202
+ const lint = (0, uiTestShared_js_1.lintFlow)(composed, platform, context, [...Object.keys(dataset ?? {}), ...Object.keys(sources ?? {})]);
203
+ if (dryRun)
204
+ return (0, shared_js_1.ok)({ dryRun: true, uiTest: { name, platform, content: composed, steps: lint.steps, preconditions: lint.preconditions }, lint: (0, uiTestShared_js_1.lintView)(lint, context) });
205
+ (0, uiTestShared_js_1.refuseLintErrors)(lint, force, composed);
206
+ (0, uiTestShared_js_1.refuseMaskedDataset)(dataset);
207
+ const targetGroup = await (0, uiTestShared_js_1.resolveGroupId)(ctx, ws.id, group);
208
+ const body = {
209
+ clientId: (0, node_crypto_1.randomUUID)(),
210
+ name: name.trim(),
211
+ platform,
212
+ content: composed,
213
+ tags: fields.tags ?? [],
214
+ dataset: dataset ?? {},
215
+ folderId: targetFolder?.item.id ?? null,
216
+ ...(fields.description !== undefined ? { description: fields.description } : {}),
217
+ ...(fields.testType ? { testType: fields.testType } : {}),
218
+ ...(fields.severity ? { severity: fields.severity } : {}),
219
+ ...(sources ? { datasetSources: sources } : {}),
220
+ ...(fields.dismissPopups ? { dismissPopups: fields.dismissPopups } : {}),
221
+ ...(fields.browserKind !== undefined ? { browserKind: fields.browserKind } : {}),
222
+ ...(fields.headless !== undefined ? { headless: fields.headless } : {}),
223
+ ...(fields.runSettings !== undefined ? { runSettings: fields.runSettings } : {}),
224
+ ...(targetGroup.id ? { groupId: targetGroup.id } : {}),
225
+ };
226
+ const saved = await (0, uiTestShared_js_1.saveFlow)(ctx, ws.id, body);
227
+ if ("conflict" in saved)
228
+ return (0, uiTestShared_js_1.conflictResult)(saved.conflict);
229
+ const note = draftNote(null, saved.flow);
230
+ const warnings = [...lint.warnings.map((issue) => `${issue.step ? `step ${issue.step}: ` : ""}${issue.message}`), ...(targetGroup.note ? [targetGroup.note] : [])];
231
+ return (0, shared_js_1.ok)({
232
+ uiTest: { ...(0, summarize_js_1.flowSummaryView)(saved.flow, targetFolder?.item.path ?? null), stepCount: lint.steps.length },
233
+ lint: (0, uiTestShared_js_1.lintView)(lint, context),
234
+ ...(warnings.length > 0 ? { warnings } : {}),
235
+ note: note ?? "Created. Run it with run_ui_test (list_ui_test_devices shows where it can run).",
236
+ ...(targetFolder ? { resolved: [(0, resolve_js_1.resolvedSummary)(folder, targetFolder)] } : {}),
237
+ });
238
+ }));
239
+ server.registerTool("update_ui_test", {
240
+ title: "Update a UI test",
241
+ description: "Change any part of a UI test: rename, description, type/severity/tags, folder (null = root), platform, url / appId / header keys, preconditions (the leading runFlow steps), `steps` (replaces the flow's own steps, preconditions kept) or the whole `content`, dataset (merged: a key set to null is removed), datasetSources (merged the same way), popups, browser, headless, run settings. For step-level edits prefer patch_ui_test_steps. Linted like create_ui_test when the YAML or platform changes (errors refuse unless force: true). Version-checked: pass the `version` you read (expectedVersion) or it is read first; a conflict comes back structured. Secret dataset values: the flow is saved whole, so masked values must be revealed (environments:reveal) or re-supplied.",
242
+ inputSchema: {
243
+ workspace: shared_js_1.workspaceArg,
244
+ flow: uiTestShared_js_1.flowArg,
245
+ expectedVersion: zod_1.z.number().int().min(1).optional(),
246
+ force: zod_1.z.boolean().optional().describe("Save despite lint errors, and overwrite even if someone saved since expectedVersion"),
247
+ name: zod_1.z.string().trim().min(1).max(200).optional(),
248
+ platform: uiTestShared_js_1.platformArg.optional(),
249
+ folder: zod_1.z.string().max(300).nullable().optional().describe("UI-test folder id, name or path; null = root"),
250
+ url: zod_1.z.string().max(2000).nullable().optional(),
251
+ appId: zod_1.z.string().max(300).nullable().optional(),
252
+ header: uiTestShared_js_1.headerArg,
253
+ preconditions: zod_1.z.array(zod_1.z.string().min(1).max(200)).max(20).optional(),
254
+ steps: uiTestShared_js_1.stepsArg.optional(),
255
+ content: zod_1.z.string().max(1_000_000).optional(),
256
+ ...flowFields,
257
+ dataset: zod_1.z.record(zod_1.z.string().max(10_000).nullable()).optional().describe("Keys to set (null removes a key); other keys are kept"),
258
+ datasetSources: zod_1.z.record(uiTestShared_js_1.datasetSourceSchema.nullable()).optional().describe("Sources to set (null removes one); others are kept"),
259
+ dryRun: shared_js_1.dryRunArg,
260
+ },
261
+ annotations: shared_js_1.IDEMPOTENT_WRITE,
262
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, expectedVersion, force, name, platform, folder, url, appId, header, preconditions, steps, content, dataset, datasetSources, dryRun, ...fields }) => {
263
+ if (content !== undefined && (steps !== undefined || url !== undefined || appId !== undefined || header !== undefined || preconditions !== undefined)) {
264
+ throw new errors_js_1.ToolInputError("CONTENT_EXCLUSIVE", "Pass either `content` (the whole YAML) or url/appId/header/preconditions/steps — not both.");
265
+ }
266
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
267
+ const current = await ctx.api.getUiTest(resolved.item.clientId, true);
268
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(current);
269
+ const nextPlatform = platform ?? current.platform;
270
+ refuseCrossPlatformHeader(nextPlatform, url, appId);
271
+ let nextContent = current.content ?? "";
272
+ if (content !== undefined)
273
+ nextContent = content;
274
+ if (url !== undefined || appId !== undefined || header !== undefined) {
275
+ const parts = (0, compose_js_1.splitFlow)(nextContent);
276
+ let head = parts.head;
277
+ if (url !== undefined)
278
+ head = (0, compose_js_1.setHeaderValue)(head, "url", url);
279
+ if (appId !== undefined)
280
+ head = (0, compose_js_1.setHeaderValue)(head, "appId", appId);
281
+ for (const [key, value] of Object.entries(header ?? {}))
282
+ head = (0, compose_js_1.setHeaderValue)(head, key, value);
283
+ nextContent = (0, compose_js_1.joinFlow)(head, parts.body);
284
+ }
285
+ if (steps !== undefined) {
286
+ const own = (0, compose_js_1.ownSteps)(nextContent);
287
+ nextContent = (0, compose_js_1.composeFlow)({ head: (0, compose_js_1.splitFlow)(nextContent).head, preconditions: preconditions ?? own.preconditions, steps: (0, compose_js_1.stepsToYaml)(steps) });
288
+ }
289
+ else if (preconditions !== undefined) {
290
+ nextContent = (0, callman_core_1.setPreconditions)(nextContent, preconditions);
291
+ }
292
+ const targetFolder = folder ? await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace ?? ws, folder) : null;
293
+ if (targetFolder && targetFolder.item.workspaceId !== ws) {
294
+ throw new errors_js_1.ToolInputError("UI_TEST_FOLDER_OTHER_WORKSPACE", `Folder "${targetFolder.item.path}" is in another workspace than the flow.`);
295
+ }
296
+ const warnings = [];
297
+ if (name !== undefined && name.trim() !== current.name) {
298
+ const taken = nameTaken(await (0, lookups_js_1.loadUiTests)(ctx), ws, name, current.clientId);
299
+ if (taken)
300
+ throw new errors_js_1.ToolInputError("UI_TEST_NAME_EXISTS", `A UI test named "${taken.name}" already exists in this workspace.`, { existing: { id: taken.clientId, name: taken.name } });
301
+ const callers = (await ctx.api.listUiTests(false).catch(() => [])).filter((entry) => entry.clientId !== current.clientId && callsFlow(entry.content ?? "", current.name));
302
+ if (callers.length > 0)
303
+ warnings.push(`These flows call "${current.name}" with runFlow and will not find it under the new name: ${callers.map((entry) => entry.name).join(", ")} — update them too.`);
304
+ }
305
+ const mergedDataset = { ...(current.dataset ?? {}) };
306
+ for (const [key, value] of Object.entries(dataset ?? {})) {
307
+ if (value === null)
308
+ delete mergedDataset[key];
309
+ else
310
+ mergedDataset[key] = value;
311
+ }
312
+ const sources = datasetSources ? await (0, uiTestShared_js_1.resolveDatasetSources)(ctx, ws, datasetSources, current.datasetSources ?? {}) : current.datasetSources;
313
+ const next = {
314
+ ...current,
315
+ ...(name !== undefined ? { name: name.trim() } : {}),
316
+ platform: nextPlatform,
317
+ content: nextContent,
318
+ dataset: mergedDataset,
319
+ ...(sources !== undefined ? { datasetSources: sources } : {}),
320
+ ...(folder !== undefined ? { folderId: targetFolder?.item.id ?? null } : {}),
321
+ ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)),
322
+ };
323
+ const body = (0, uiTestShared_js_1.upsertBodyOf)(next);
324
+ const changed = Object.keys(body).filter((key) => JSON.stringify(body[key]) !== JSON.stringify((0, uiTestShared_js_1.upsertBodyOf)(current)[key]));
325
+ if (changed.length === 0)
326
+ throw new errors_js_1.ToolInputError("NOTHING_TO_UPDATE", "Nothing would change — pass at least one field with a new value.");
327
+ const context = await (0, uiTestShared_js_1.lintContext)(ctx, ws);
328
+ const lint = (0, uiTestShared_js_1.lintFlow)(nextContent, nextPlatform, context, datasetKeysOf(next));
329
+ if (dryRun)
330
+ return (0, shared_js_1.ok)({ dryRun: true, uiTest: { id: current.clientId, name: next.name, version: current.version ?? null }, changed, content: nextContent, lint: (0, uiTestShared_js_1.lintView)(lint, context), ...(warnings.length > 0 ? { warnings } : {}) });
331
+ if (changed.includes("content") || changed.includes("platform"))
332
+ (0, uiTestShared_js_1.refuseLintErrors)(lint, force, nextContent);
333
+ // Masks read from the flow go back as-is (the backend keeps the stored
334
+ // secret for an unchanged mask); only values the caller typed are checked.
335
+ (0, uiTestShared_js_1.refuseMaskedDataset)(Object.fromEntries(Object.entries(dataset ?? {}).filter(([, value]) => value !== null)));
336
+ const saved = await (0, uiTestShared_js_1.saveFlow)(ctx, ws, body, { expectedVersion: expectedVersion ?? current.version, force });
337
+ if ("conflict" in saved)
338
+ return (0, uiTestShared_js_1.conflictResult)(saved.conflict);
339
+ const note = draftNote(current, saved.flow);
340
+ const folders = await (0, lookups_js_1.loadUiTestFolders)(ctx).catch(() => []);
341
+ return (0, shared_js_1.ok)({
342
+ uiTest: { ...(0, summarize_js_1.flowSummaryView)(saved.flow, saved.flow.folderId ? (folders.find((entry) => entry.id === saved.flow.folderId)?.path ?? null) : null), stepCount: lint.steps.length },
343
+ previousVersion: current.version ?? null,
344
+ changed,
345
+ lint: (0, uiTestShared_js_1.lintView)(lint, context),
346
+ ...(warnings.length > 0 ? { warnings } : {}),
347
+ ...(note ? { note } : {}),
348
+ ...(resolved.annotation ? { resolved: [(0, resolve_js_1.resolvedSummary)(flow, resolved)] } : {}),
349
+ });
350
+ }));
351
+ server.registerTool("patch_ui_test_steps", {
352
+ title: "Edit a UI test's steps",
353
+ description: "Edit steps in place without resending the flow. Step numbers are the 1-based top-level steps get_ui_test lists (precondition runFlow lines included), applied in order — each op sees the result of the previous one. Ops: insert { at, steps } (before step `at`; stepCount+1 appends), replace { at, step }, remove { at } or { from, to }, move { from, to }, setLabel { at, label }. Untouched steps keep their exact text. Linted after the edit (errors refuse unless force: true); dryRun: true returns the new YAML. Version-checked like update_ui_test.",
354
+ inputSchema: {
355
+ workspace: shared_js_1.workspaceArg,
356
+ flow: uiTestShared_js_1.flowArg,
357
+ expectedVersion: zod_1.z.number().int().min(1).optional(),
358
+ force: zod_1.z.boolean().optional().describe("Save despite lint errors, and overwrite even if someone saved since expectedVersion"),
359
+ ops: zod_1.z
360
+ .array(zod_1.z.discriminatedUnion("op", [
361
+ (0, strict_js_1.strictObject)({ op: zod_1.z.literal("insert"), at: zod_1.z.number().int().min(1), steps: zod_1.z.array(uiTestShared_js_1.stepInputSchema).min(1).max(200) }, "field", "insert ops"),
362
+ (0, strict_js_1.strictObject)({ op: zod_1.z.literal("replace"), at: zod_1.z.number().int().min(1), step: uiTestShared_js_1.stepInputSchema }, "field", "replace ops"),
363
+ (0, strict_js_1.strictObject)({ op: zod_1.z.literal("remove"), at: zod_1.z.number().int().min(1).optional(), from: zod_1.z.number().int().min(1).optional(), to: zod_1.z.number().int().min(1).optional() }, "field", "remove ops"),
364
+ (0, strict_js_1.strictObject)({ op: zod_1.z.literal("move"), from: zod_1.z.number().int().min(1), to: zod_1.z.number().int().min(1) }, "field", "move ops"),
365
+ (0, strict_js_1.strictObject)({ op: zod_1.z.literal("setLabel"), at: zod_1.z.number().int().min(1), label: zod_1.z.string().trim().min(1).max(200) }, "field", "setLabel ops"),
366
+ ]))
367
+ .min(1)
368
+ .max(200),
369
+ dryRun: shared_js_1.dryRunArg,
370
+ },
371
+ annotations: shared_js_1.IDEMPOTENT_WRITE,
372
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, expectedVersion, force, ops, dryRun }) => {
373
+ for (const op of ops) {
374
+ if (op.op === "remove" && (op.at === undefined) === (op.from === undefined))
375
+ throw new errors_js_1.ToolInputError("STEP_RANGE_INVALID", "remove takes `at`, or `from` (+ optional `to`).");
376
+ }
377
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
378
+ const current = await ctx.api.getUiTest(resolved.item.clientId, true);
379
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(current);
380
+ const context = await (0, uiTestShared_js_1.lintContext)(ctx, ws);
381
+ const before = (0, uiTestShared_js_1.lintFlow)(current.content ?? "", current.platform, context, datasetKeysOf(current));
382
+ if (before.errors.some((issue) => issue.code === "YAML_SYNTAX")) {
383
+ throw new errors_js_1.ToolInputError("UI_TEST_YAML_INVALID", "The stored flow is not valid YAML, so its steps cannot be addressed — replace it with update_ui_test content.", { errors: before.errors });
384
+ }
385
+ const patched = (0, compose_js_1.applyStepOps)(current.content ?? "", ops, before.steps.length);
386
+ const lint = (0, uiTestShared_js_1.lintFlow)(patched.content, current.platform, context, datasetKeysOf(current));
387
+ if (dryRun)
388
+ return (0, shared_js_1.ok)({ dryRun: true, uiTest: { id: current.clientId, name: current.name, version: current.version ?? null }, content: patched.content, steps: lint.steps, lint: (0, uiTestShared_js_1.lintView)(lint, context) });
389
+ (0, uiTestShared_js_1.refuseLintErrors)(lint, force, patched.content);
390
+ const saved = await (0, uiTestShared_js_1.saveFlow)(ctx, ws, (0, uiTestShared_js_1.upsertBodyOf)({ ...current, content: patched.content }), { expectedVersion: expectedVersion ?? current.version, force });
391
+ if ("conflict" in saved)
392
+ return (0, uiTestShared_js_1.conflictResult)(saved.conflict);
393
+ const note = draftNote(current, saved.flow);
394
+ return (0, shared_js_1.ok)({
395
+ uiTest: { id: saved.flow.clientId, name: saved.flow.name, version: saved.flow.version ?? null, ...(saved.flow.reviewStatus ? { reviewStatus: saved.flow.reviewStatus } : {}) },
396
+ previousVersion: current.version ?? null,
397
+ steps: lint.steps,
398
+ lint: (0, uiTestShared_js_1.lintView)(lint, context),
399
+ ...(note ? { note } : {}),
400
+ ...(resolved.annotation ? { resolved: [(0, resolve_js_1.resolvedSummary)(flow, resolved)] } : {}),
401
+ });
402
+ }));
403
+ server.registerTool("delete_ui_test", {
404
+ title: "Delete a UI test",
405
+ description: "Delete a UI test (exact name or id), its schedules and run history. Flows that call it with runFlow stop finding it. Permanent — confirm with the user first.",
406
+ inputSchema: { workspace: shared_js_1.workspaceArg, flow: uiTestShared_js_1.flowArg, confirm: shared_js_1.confirmArg },
407
+ annotations: shared_js_1.DESTRUCTIVE,
408
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, confirm }) => {
409
+ (0, shared_js_1.requireConfirm)(confirm, "a UI test");
410
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow, { exact: true });
411
+ await ctx.api.deleteUiTest(resolved.item.workspaceId ?? null, resolved.item.clientId);
412
+ ctx.invalidate("uitests:");
413
+ return (0, shared_js_1.ok)({ deleted: { id: resolved.item.clientId, name: resolved.item.name }, resolved: [(0, resolve_js_1.resolvedSummary)(flow, resolved)] });
414
+ }));
415
+ // ── folders ──────────────────────────────────────────────────────────
416
+ server.registerTool("list_ui_test_folders", {
417
+ title: "List UI-test folders",
418
+ description: "The UI-test folder tree across your workspaces: id, name, path (A/B — usable as a reference), parent, and how many flows each holds directly and with its subfolders.",
419
+ inputSchema: { workspace: shared_js_1.workspaceArg },
420
+ annotations: shared_js_1.READ,
421
+ }, (0, shared_js_1.guarded)(async ({ workspace }) => {
422
+ const ws = workspace && !(0, lookups_js_1.isAllWorkspaces)(workspace) ? await ctx.resolveWorkspace(workspace) : null;
423
+ const [folders, flows, workspaces] = await Promise.all([(0, lookups_js_1.loadUiTestFolders)(ctx), (0, lookups_js_1.loadUiTests)(ctx), ctx.listWorkspaces()]);
424
+ const scoped = folders.filter((folder) => !ws || folder.workspaceId === ws.id).sort((a, b) => a.path.localeCompare(b.path));
425
+ const direct = new Map();
426
+ for (const flow of flows)
427
+ if (flow.folderId)
428
+ direct.set(flow.folderId, (direct.get(flow.folderId) ?? 0) + 1);
429
+ const workspaceName = new Map(workspaces.map((entry) => [entry.id, entry.name]));
430
+ return (0, shared_js_1.ok)({
431
+ ...(ws ? { workspace: (0, lookups_js_1.workspaceTag)(ws) } : {}),
432
+ folders: scoped.slice(0, truncate_js_1.LIMITS.TREE_ITEMS).map((folder) => ({
433
+ id: folder.id,
434
+ name: folder.name,
435
+ path: folder.path,
436
+ parentId: folder.parentId,
437
+ flowCount: direct.get(folder.id) ?? 0,
438
+ totalFlowCount: [...(0, lookups_js_1.uiTestFolderSubtree)(folders, folder.id)].reduce((sum, id) => sum + (direct.get(id) ?? 0), 0),
439
+ ...(workspaces.length > 1 ? { workspace: { id: folder.workspaceId, name: workspaceName.get(folder.workspaceId) ?? null } } : {}),
440
+ })),
441
+ rootFlowCount: flows.filter((flow) => (!ws || flow.workspaceId === ws.id) && !flow.folderId).length,
442
+ });
443
+ }));
444
+ server.registerTool("create_ui_test_folder", {
445
+ title: "Create a UI-test folder",
446
+ description: "Create a UI-test folder at the root or inside `parent` (id, name or path). An existing folder with the same name at that place is returned instead of a duplicate.",
447
+ inputSchema: {
448
+ workspace: shared_js_1.workspaceArg,
449
+ name: zod_1.z.string().trim().min(1).max(120),
450
+ parent: zod_1.z.string().max(300).optional().describe("Parent folder id, name or path"),
451
+ group: uiTestShared_js_1.groupArg,
452
+ },
453
+ annotations: shared_js_1.WRITE,
454
+ }, (0, shared_js_1.guarded)(async ({ workspace, name, parent, group }) => {
455
+ const parentFolder = parent ? await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, parent) : null;
456
+ const ws = parentFolder ? { id: parentFolder.item.workspaceId } : await ctx.resolveWorkspace(workspace);
457
+ const folders = await (0, lookups_js_1.loadUiTestFolders)(ctx);
458
+ const sibling = folders.find((folder) => folder.workspaceId === ws.id && folder.parentId === (parentFolder?.item.id ?? null) && folder.name.trim().toLowerCase() === name.trim().toLowerCase());
459
+ if (sibling)
460
+ return (0, shared_js_1.ok)({ folder: { id: sibling.id, name: sibling.name, path: sibling.path }, existed: true, note: "A folder with that name already exists there — returned instead of creating a duplicate." });
461
+ const targetGroup = await (0, uiTestShared_js_1.resolveGroupId)(ctx, ws.id, group);
462
+ const siblings = folders.filter((folder) => folder.workspaceId === ws.id && folder.parentId === (parentFolder?.item.id ?? null));
463
+ const created = await ctx.api.upsertUiTestFolder(ws.id, {
464
+ clientId: (0, node_crypto_1.randomUUID)(),
465
+ name: name.trim(),
466
+ parentId: parentFolder?.item.id ?? null,
467
+ sortOrder: siblings.reduce((max, folder) => Math.max(max, folder.sortOrder + 1), 0),
468
+ ...(targetGroup.id ? { groupId: targetGroup.id } : parentFolder?.item.groupId ? { groupId: parentFolder.item.groupId } : {}),
469
+ });
470
+ ctx.invalidate("uitests:");
471
+ return (0, shared_js_1.ok)({
472
+ folder: { id: created.clientId, name: created.name, parentId: created.parentId ?? null, path: parentFolder ? `${parentFolder.item.path}/${created.name}` : created.name },
473
+ ...(targetGroup.note ? { warnings: [targetGroup.note] } : {}),
474
+ ...(parentFolder ? { resolved: [(0, resolve_js_1.resolvedSummary)(parent, parentFolder)] } : {}),
475
+ });
476
+ }));
477
+ server.registerTool("update_ui_test_folder", {
478
+ title: "Rename / move a UI-test folder",
479
+ description: "Rename a UI-test folder and/or move it under another folder (`parent`: id, name or path; null = root). A folder cannot move into itself or its own subfolders.",
480
+ inputSchema: {
481
+ workspace: shared_js_1.workspaceArg,
482
+ folder: (0, shared_js_1.refArg)("UI-test folder", "(or its path A/B)"),
483
+ name: zod_1.z.string().trim().min(1).max(120).optional(),
484
+ parent: zod_1.z.string().max(300).nullable().optional(),
485
+ },
486
+ annotations: shared_js_1.IDEMPOTENT_WRITE,
487
+ }, (0, shared_js_1.guarded)(async ({ workspace, folder, name, parent }) => {
488
+ if (name === undefined && parent === undefined)
489
+ throw new errors_js_1.ToolInputError("NOTHING_TO_UPDATE", "Pass name and/or parent.");
490
+ const target = await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder);
491
+ const parentFolder = parent ? await (0, lookups_js_1.resolveUiTestFolder)(ctx, target.item.workspaceId, parent) : null;
492
+ if (parentFolder) {
493
+ if (parentFolder.item.workspaceId !== target.item.workspaceId)
494
+ throw new errors_js_1.ToolInputError("UI_TEST_FOLDER_OTHER_WORKSPACE", "The new parent is in another workspace.");
495
+ if ((0, lookups_js_1.uiTestFolderSubtree)(await (0, lookups_js_1.loadUiTestFolders)(ctx), target.item.id).has(parentFolder.item.id)) {
496
+ throw new errors_js_1.ToolInputError("FOLDER_CYCLE", `"${target.item.path}" cannot move into itself or one of its subfolders.`);
497
+ }
498
+ }
499
+ const updated = await ctx.api.upsertUiTestFolder(target.item.workspaceId, {
500
+ clientId: target.item.id,
501
+ name: name?.trim() ?? target.item.name,
502
+ parentId: parent === undefined ? target.item.parentId : (parentFolder?.item.id ?? null),
503
+ sortOrder: target.item.sortOrder,
504
+ ...(target.item.groupId ? { groupId: target.item.groupId } : {}),
505
+ });
506
+ ctx.invalidate("uitests:");
507
+ const refreshed = (await (0, lookups_js_1.loadUiTestFolders)(ctx)).find((entry) => entry.id === updated.clientId);
508
+ return (0, shared_js_1.ok)({
509
+ folder: { id: updated.clientId, name: updated.name, parentId: updated.parentId ?? null, path: refreshed?.path ?? updated.name },
510
+ resolved: [(0, resolve_js_1.resolvedSummary)(folder, target), ...(parentFolder ? [(0, resolve_js_1.resolvedSummary)(parent, parentFolder)] : [])],
511
+ });
512
+ }));
513
+ server.registerTool("delete_ui_test_folder", {
514
+ title: "Delete a UI-test folder",
515
+ description: "Delete a UI-test folder (exact name, path or id) and its subfolders. The flows inside are NOT deleted — they move to the root. Confirm with the user first.",
516
+ inputSchema: { workspace: shared_js_1.workspaceArg, folder: (0, shared_js_1.refArg)("UI-test folder", "(or its path A/B)"), confirm: shared_js_1.confirmArg },
517
+ annotations: shared_js_1.DESTRUCTIVE,
518
+ }, (0, shared_js_1.guarded)(async ({ workspace, folder, confirm }) => {
519
+ (0, shared_js_1.requireConfirm)(confirm, "a UI-test folder");
520
+ const target = await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder, { exact: true });
521
+ const [folders, flows] = await Promise.all([(0, lookups_js_1.loadUiTestFolders)(ctx), (0, lookups_js_1.loadUiTests)(ctx)]);
522
+ const subtree = (0, lookups_js_1.uiTestFolderSubtree)(folders, target.item.id);
523
+ const moved = flows.filter((flow) => flow.folderId && subtree.has(flow.folderId)).map((flow) => flow.name);
524
+ await ctx.api.deleteUiTestFolder(target.item.workspaceId, target.item.id);
525
+ ctx.invalidate("uitests:");
526
+ return (0, shared_js_1.ok)({
527
+ deleted: { id: target.item.id, name: target.item.name, path: target.item.path, subfolders: subtree.size - 1 },
528
+ movedToRoot: moved,
529
+ resolved: [(0, resolve_js_1.resolvedSummary)(folder, target)],
530
+ });
531
+ }));
532
+ server.registerTool("move_ui_tests", {
533
+ title: "Move UI tests to a folder",
534
+ description: "Move UI tests (ids or names) into a folder (id, name or path) or to the root (folder: null). Each flow is saved with its new folder; failures are reported per flow.",
535
+ inputSchema: {
536
+ workspace: shared_js_1.workspaceArg,
537
+ flows: zod_1.z.array(uiTestShared_js_1.flowArg).min(1).max(200),
538
+ folder: zod_1.z.string().max(300).nullable().describe("Target folder id, name or path; null = root"),
539
+ },
540
+ annotations: shared_js_1.IDEMPOTENT_WRITE,
541
+ }, (0, shared_js_1.guarded)(async ({ workspace, flows, folder }) => {
542
+ const target = folder ? await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder) : null;
543
+ const moved = [];
544
+ const unchanged = [];
545
+ const failed = [];
546
+ for (const ref of flows) {
547
+ try {
548
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, ref);
549
+ if (target && target.item.workspaceId !== resolved.item.workspaceId)
550
+ throw new errors_js_1.ToolInputError("UI_TEST_FOLDER_OTHER_WORKSPACE", `"${resolved.item.name}" is in another workspace than folder "${target.item.path}".`);
551
+ const nextFolder = target?.item.id ?? null;
552
+ if ((resolved.item.folderId ?? null) === nextFolder) {
553
+ unchanged.push(resolved.item.name);
554
+ continue;
555
+ }
556
+ const current = await ctx.api.getUiTest(resolved.item.clientId, true);
557
+ const saved = await (0, uiTestShared_js_1.saveFlow)(ctx, (0, uiTestShared_js_1.workspaceIdOf)(current), (0, uiTestShared_js_1.upsertBodyOf)({ ...current, folderId: nextFolder }), { expectedVersion: current.version });
558
+ if ("conflict" in saved)
559
+ throw new errors_js_1.ToolInputError("UI_TEST_SCENARIO_VERSION_CONFLICT", String(saved.conflict.message));
560
+ moved.push({ id: saved.flow.clientId, name: saved.flow.name });
561
+ }
562
+ catch (error) {
563
+ failed.push({ flow: ref, error: (0, shared_js_1.failureOf)(error) });
564
+ }
565
+ }
566
+ return (0, shared_js_1.ok)({
567
+ folder: target ? { id: target.item.id, path: target.item.path } : null,
568
+ moved,
569
+ ...(unchanged.length > 0 ? { alreadyThere: unchanged } : {}),
570
+ failed,
571
+ ...(target ? { resolved: [(0, resolve_js_1.resolvedSummary)(folder, target)] } : {}),
572
+ });
573
+ }));
574
+ };
575
+ exports.registerUiTestTools = registerUiTestTools;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "callman-mcp",
3
- "version": "0.3.0",
4
- "description": "MCP server for Callman: lets Claude Code, Claude Desktop, Cursor, Codex and Gemini CLI manage collections, requests, environments, test scenarios and system-design diagrams in your Callman workspace — cloud or on-prem.",
3
+ "version": "0.5.0",
4
+ "description": "MCP server for Callman: lets Claude Code, Claude Desktop, Cursor, Codex and Gemini CLI manage collections, requests, environments, test scenarios, UI tests (web, Android, iOS) and system-design diagrams in your Callman workspace — cloud or on-prem.",
5
5
  "keywords": [
6
6
  "callman",
7
7
  "mcp",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@modelcontextprotocol/sdk": "^1.30.0",
65
- "callman-core": "^1.29.0",
65
+ "callman-core": "^1.31.0",
66
66
  "elkjs": "^0.12.0",
67
67
  "js-yaml": "^4.1.0",
68
68
  "zod": "^3.25.0"