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,512 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerUiTestAdminTools = void 0;
4
+ const zod_1 = require("zod");
5
+ const errors_js_1 = require("../client/errors.js");
6
+ const resolve_js_1 = require("../domain/resolve.js");
7
+ const compose_js_1 = require("../domain/uiTest/compose.js");
8
+ const summarize_js_1 = require("../domain/uiTest/summarize.js");
9
+ const strict_js_1 = require("../lib/strict.js");
10
+ const truncate_js_1 = require("../lib/truncate.js");
11
+ const lookups_js_1 = require("./lookups.js");
12
+ const shared_js_1 = require("./shared.js");
13
+ const uiTestShared_js_1 = require("./uiTestShared.js");
14
+ const uiTestRuns_js_1 = require("./uiTestRuns.js");
15
+ // UI-test schedules (web flows on the server runner), the on-prem review
16
+ // workflow (submit / approve / reject, approved versions) and export/import
17
+ // of `callman.uitest` envelopes.
18
+ const EXPORT_MAX_BYTES = 2 * 1024 * 1024;
19
+ const ENVELOPE_MAX = 8 * 1024 * 1024;
20
+ const CLOUD_REVIEW_NOTE = "Callman cloud has no review workflow for UI tests — flows run and schedule as saved. Approval and versions exist on on-prem installations.";
21
+ const isReviewDisabled = (error) => error instanceof errors_js_1.CallmanApiError && error.code === "REVIEW_DISABLED_CLOUD";
22
+ // ── schedules ──────────────────────────────────────────────────────────
23
+ const timesArg = zod_1.z.array(zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/, "HH:mm")).min(1).max(24);
24
+ const scheduleFields = {
25
+ description: zod_1.z.string().max(1000).nullable().optional(),
26
+ enabled: zod_1.z.boolean().optional(),
27
+ triggerType: zod_1.z.enum(["daily", "weekly", "monthly", "custom_cron"]).optional(),
28
+ times: timesArg.optional().describe('Times of day (HH:mm, 24h) for daily / weekly / monthly, e.g. ["09:00", "18:30"]'),
29
+ daysOfWeek: zod_1.z.array(zod_1.z.number().int().min(0).max(6)).min(1).max(7).optional().describe("weekly: 0 = Sunday … 6 = Saturday"),
30
+ daysOfMonth: zod_1.z.array(zod_1.z.number().int().min(1).max(31)).min(1).max(31).optional().describe("monthly: 1–31"),
31
+ cronExpression: zod_1.z.string().max(120).optional().describe("custom_cron: a 5-field cron expression"),
32
+ timezone: zod_1.z.string().max(100).optional().describe("IANA time zone, e.g. Asia/Baku (default: the server's)"),
33
+ executionPolicy: (0, strict_js_1.strictObject)({
34
+ allowParallelRuns: zod_1.z.boolean().optional(),
35
+ skipIfAlreadyRunning: zod_1.z.boolean().optional(),
36
+ maxRuntimeMinutes: zod_1.z.number().int().min(1).max(1440).nullable().optional(),
37
+ }, "execution policy field").optional(),
38
+ failurePolicy: (0, strict_js_1.strictObject)({
39
+ retryCount: zod_1.z.number().int().min(0).max(10).optional(),
40
+ retryIntervalMinutes: zod_1.z.number().int().min(1).max(1440).optional(),
41
+ }, "failure policy field").optional(),
42
+ notification: (0, strict_js_1.strictObject)({
43
+ notifyOnSuccess: zod_1.z.boolean().optional(),
44
+ notifyOnFailure: zod_1.z.boolean().optional(),
45
+ connection: zod_1.z.string().max(200).nullable().optional().describe("Notification connection (Slack / Teams / email …) id or name"),
46
+ channelOverride: zod_1.z.string().max(500).nullable().optional(),
47
+ }, "notification field")
48
+ .nullable()
49
+ .optional(),
50
+ };
51
+ // Flat tool arguments → the backend's trigger configs.
52
+ const scheduleBody = async (ctx, ws, input, current) => {
53
+ const body = {};
54
+ if (input.description !== undefined)
55
+ body.description = input.description;
56
+ if (input.enabled !== undefined)
57
+ body.enabled = input.enabled;
58
+ if (input.timezone !== undefined)
59
+ body.timezone = input.timezone;
60
+ if (input.executionPolicy)
61
+ body.executionPolicy = input.executionPolicy;
62
+ if (input.failurePolicy)
63
+ body.failurePolicy = input.failurePolicy;
64
+ const triggerTouched = input.triggerType !== undefined || input.times !== undefined || input.daysOfWeek !== undefined || input.daysOfMonth !== undefined || input.cronExpression !== undefined;
65
+ if (triggerTouched) {
66
+ const type = input.triggerType ?? current?.triggerType;
67
+ if (!type)
68
+ throw new errors_js_1.ToolInputError("SCHEDULE_TRIGGER_REQUIRED", "Pass triggerType (daily, weekly, monthly or custom_cron).");
69
+ const times = input.times ?? current?.dailyConfig?.times ?? current?.weeklyConfig?.times ?? current?.monthlyConfig?.times;
70
+ body.triggerType = type;
71
+ if (type === "custom_cron") {
72
+ const cron = input.cronExpression ?? current?.customCronExpression;
73
+ if (!cron)
74
+ throw new errors_js_1.ToolInputError("SCHEDULE_CRON_REQUIRED", "custom_cron needs cronExpression.");
75
+ body.customCronExpression = cron;
76
+ }
77
+ else {
78
+ if (!times)
79
+ throw new errors_js_1.ToolInputError("SCHEDULE_TIMES_REQUIRED", `A ${type} schedule needs times (HH:mm).`);
80
+ if (type === "daily")
81
+ body.dailyConfig = { times };
82
+ if (type === "weekly") {
83
+ const days = input.daysOfWeek ?? current?.weeklyConfig?.daysOfWeek;
84
+ if (!days)
85
+ throw new errors_js_1.ToolInputError("SCHEDULE_DAYS_REQUIRED", "A weekly schedule needs daysOfWeek (0 = Sunday … 6 = Saturday).");
86
+ body.weeklyConfig = { daysOfWeek: days, times };
87
+ }
88
+ if (type === "monthly") {
89
+ const days = input.daysOfMonth ?? current?.monthlyConfig?.daysOfMonth;
90
+ if (!days)
91
+ throw new errors_js_1.ToolInputError("SCHEDULE_DAYS_REQUIRED", "A monthly schedule needs daysOfMonth (1–31).");
92
+ body.monthlyConfig = { daysOfMonth: days, times };
93
+ }
94
+ }
95
+ }
96
+ if (input.notification !== undefined) {
97
+ if (input.notification === null)
98
+ body.notificationConfig = null;
99
+ else {
100
+ const { connection, ...rest } = input.notification;
101
+ let connectionId = connection === null ? null : undefined;
102
+ if (connection)
103
+ connectionId = (0, resolve_js_1.resolveRef)("connection", connection, await (0, lookups_js_1.loadConnections)(ctx, ws), { describe: (entry) => ({ type: entry.type }) }).item.id;
104
+ body.notificationConfig = { ...rest, ...(connectionId !== undefined ? { connectionId } : {}) };
105
+ }
106
+ }
107
+ return body;
108
+ };
109
+ const listSchedulesAcross = async (ctx, workspace, flowClientId) => {
110
+ const out = [];
111
+ for (const ws of await (0, lookups_js_1.workspacesForListing)(ctx, workspace)) {
112
+ try {
113
+ for (const schedule of await ctx.api.listUiTestSchedules(ws.id, flowClientId)) {
114
+ if (!out.some((entry) => entry.schedule.id === schedule.id))
115
+ out.push({ ws, schedule });
116
+ }
117
+ }
118
+ catch (error) {
119
+ if (error instanceof errors_js_1.CallmanApiError && (error.status === 403 || error.status === 404))
120
+ continue;
121
+ throw error;
122
+ }
123
+ }
124
+ return out;
125
+ };
126
+ const resolveSchedule = async (ctx, workspace, ref, exact = false) => {
127
+ const all = await listSchedulesAcross(ctx, workspace);
128
+ const resolved = (0, resolve_js_1.resolveRef)("UI-test schedule", ref, all.map((entry) => ({ id: entry.schedule.id, name: entry.schedule.name, entry })), { describe: (item) => ({ flow: item.entry.schedule.flowName, triggerType: item.entry.schedule.triggerType }), aliases: (item) => [`${item.entry.schedule.flowName}/${item.entry.schedule.name}`], exact });
129
+ return { schedule: resolved.item.entry.schedule, ws: resolved.item.entry.ws, annotation: resolved.annotation };
130
+ };
131
+ const refuseMobile = (flows) => {
132
+ const mobile = flows.filter((flow) => flow.platform !== "web");
133
+ if (mobile.length > 0) {
134
+ throw new errors_js_1.ToolInputError("UI_TEST_PLATFORM_UNSUPPORTED", `Schedules run on the server runner, which runs web flows only — not ${mobile.map((flow) => `"${flow.name}" (${flow.platform})`).join(", ")}. Run mobile flows with run_ui_test on your desktop app.`);
135
+ }
136
+ };
137
+ // ── review helpers ─────────────────────────────────────────────────────
138
+ const flowStatus = (flow) => ({ id: flow.clientId, name: flow.name, reviewStatus: flow.reviewStatus ?? null, version: flow.version ?? null, activeVersionNumber: flow.activeVersionNumber ?? null, ...(flow.reviewNote ? { reviewNote: flow.reviewNote } : {}) });
139
+ const registerUiTestAdminTools = (server, ctx) => {
140
+ // ── schedules ────────────────────────────────────────────────────────
141
+ server.registerTool("list_ui_test_schedules", {
142
+ title: "List UI-test schedules",
143
+ description: "Schedules that run UI tests on the server runner (web flows), across your workspaces or for one flow: trigger (daily / weekly / monthly / cron), times, enabled, last and next run.",
144
+ inputSchema: { workspace: shared_js_1.workspaceArg, flow: uiTestShared_js_1.flowArg.optional() },
145
+ annotations: shared_js_1.READ,
146
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow }) => {
147
+ const resolved = flow ? await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow) : null;
148
+ const rows = await listSchedulesAcross(ctx, resolved ? resolved.item.workspaceId : workspace, resolved?.item.clientId);
149
+ const workspaces = await ctx.listWorkspaces();
150
+ return (0, shared_js_1.ok)({
151
+ ...(resolved ? { uiTest: { id: resolved.item.clientId, name: resolved.item.name } } : {}),
152
+ schedules: rows.map((entry) => ({ ...(0, summarize_js_1.scheduleView)(entry.schedule), ...(workspaces.length > 1 ? { workspace: (0, lookups_js_1.workspaceTag)(entry.ws) } : {}) })),
153
+ total: rows.length,
154
+ });
155
+ }));
156
+ server.registerTool("create_ui_test_schedule", {
157
+ title: "Schedule UI tests",
158
+ description: "Schedule one or many web UI tests to run on the server runner: triggerType daily / weekly / monthly (with times HH:mm, daysOfWeek 0–6 or daysOfMonth 1–31) or custom_cron (cronExpression), timezone, execution / failure (retries) policies and notifications. Many flows = one schedule each; conflictMode says what to do with flows that already have schedules (add — default, replace, skip). dryRun: true shows what would be created. Mobile flows cannot be scheduled. On-prem, schedules run the flow's APPROVED version.",
159
+ inputSchema: {
160
+ workspace: shared_js_1.workspaceArg,
161
+ flows: zod_1.z.array(uiTestShared_js_1.flowArg).min(1).max(200),
162
+ name: zod_1.z.string().trim().min(1).max(120),
163
+ ...scheduleFields,
164
+ triggerType: zod_1.z.enum(["daily", "weekly", "monthly", "custom_cron"]),
165
+ conflictMode: zod_1.z.enum(["add", "replace", "skip"]).optional(),
166
+ dryRun: shared_js_1.dryRunArg,
167
+ },
168
+ annotations: shared_js_1.WRITE,
169
+ }, (0, shared_js_1.guarded)(async ({ workspace, flows, name, conflictMode, dryRun, ...fields }) => {
170
+ const resolved = [];
171
+ for (const ref of flows)
172
+ resolved.push(await (0, lookups_js_1.resolveUiTest)(ctx, workspace, ref));
173
+ const targets = resolved.map((entry) => entry.item);
174
+ refuseMobile(targets);
175
+ const workspaceIds = [...new Set(targets.map((flow) => flow.workspaceId))];
176
+ if (workspaceIds.length > 1)
177
+ throw new errors_js_1.ToolInputError("UI_TEST_SCHEDULE_WORKSPACES", "The flows are in different workspaces — create one schedule call per workspace.");
178
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(targets[0]);
179
+ const base = { name, ...(await scheduleBody(ctx, ws, fields)) };
180
+ if (targets.length === 1 && !dryRun && !conflictMode) {
181
+ const created = await ctx.api.createUiTestSchedule(ws, { ...base, flowClientId: targets[0].clientId });
182
+ return (0, shared_js_1.ok)({ schedule: (0, summarize_js_1.scheduleView)(created), ...(resolved[0].annotation ? { resolved: [(0, resolve_js_1.resolvedSummary)(flows[0], resolved[0])] } : {}) });
183
+ }
184
+ const result = await ctx.api.bulkCreateUiTestSchedules(ws, { ...base, flowClientIds: targets.map((flow) => flow.clientId), conflictMode: conflictMode ?? "add", dryRun: dryRun ?? false });
185
+ const nameOf = new Map(targets.map((flow) => [flow.clientId, flow.name]));
186
+ const names = (ids) => ids.map((id) => nameOf.get(id) ?? id);
187
+ return (0, shared_js_1.ok)({
188
+ ...(dryRun ? { dryRun: true } : {}),
189
+ created: result.created,
190
+ schedules: result.createdSchedules.map(summarize_js_1.scheduleView),
191
+ ...(result.conflictIds.length ? { alreadyScheduled: names(result.conflictIds) } : {}),
192
+ ...(result.skippedIds.length ? { skipped: names(result.skippedIds) } : {}),
193
+ ...(result.replacedCount ? { replaced: result.replacedCount } : {}),
194
+ ...(result.blockedIds.length ? { blocked: names(result.blockedIds), blockedReason: result.blockedReason } : {}),
195
+ ...(result.failures.length ? { failed: result.failures.map((failure) => ({ ...failure, flow: nameOf.get(failure.flowClientId) ?? failure.flowClientId })) } : {}),
196
+ conflictMode: result.conflictMode,
197
+ });
198
+ }));
199
+ server.registerTool("update_ui_test_schedule", {
200
+ title: "Update a UI-test schedule",
201
+ description: "Change a UI-test schedule (id or name): name, enabled (enable / disable), trigger and times, timezone, policies, notifications. Only the fields you pass change.",
202
+ inputSchema: {
203
+ workspace: shared_js_1.workspaceArg,
204
+ schedule: (0, shared_js_1.refArg)("UI-test schedule"),
205
+ name: zod_1.z.string().trim().min(1).max(120).optional(),
206
+ ...scheduleFields,
207
+ },
208
+ annotations: shared_js_1.IDEMPOTENT_WRITE,
209
+ }, (0, shared_js_1.guarded)(async ({ workspace, schedule, name, ...fields }) => {
210
+ const found = await resolveSchedule(ctx, workspace, schedule);
211
+ const body = { ...(name !== undefined ? { name } : {}), ...(await scheduleBody(ctx, found.ws.id, fields, found.schedule)) };
212
+ if (Object.keys(body).length === 0)
213
+ throw new errors_js_1.ToolInputError("NOTHING_TO_UPDATE", "Pass at least one field to change.");
214
+ const updated = Object.keys(body).length === 1 && body.enabled !== undefined
215
+ ? await ctx.api.toggleUiTestSchedule(found.ws.id, found.schedule.id, body.enabled)
216
+ : await ctx.api.updateUiTestSchedule(found.ws.id, found.schedule.id, body);
217
+ return (0, shared_js_1.ok)({ schedule: (0, summarize_js_1.scheduleView)(updated), ...(found.annotation ? { resolved: [{ input: schedule, matched: found.schedule.name, id: found.schedule.id, note: found.annotation }] } : {}) });
218
+ }));
219
+ server.registerTool("delete_ui_test_schedule", {
220
+ title: "Delete a UI-test schedule",
221
+ description: "Delete a UI-test schedule (exact name or id). The flow itself is kept. Confirm with the user first.",
222
+ inputSchema: { workspace: shared_js_1.workspaceArg, schedule: (0, shared_js_1.refArg)("UI-test schedule"), confirm: shared_js_1.confirmArg },
223
+ annotations: shared_js_1.DESTRUCTIVE,
224
+ }, (0, shared_js_1.guarded)(async ({ workspace, schedule, confirm }) => {
225
+ (0, shared_js_1.requireConfirm)(confirm, "a UI-test schedule");
226
+ const found = await resolveSchedule(ctx, workspace, schedule, true);
227
+ await ctx.api.deleteUiTestSchedule(found.ws.id, found.schedule.id);
228
+ return (0, shared_js_1.ok)({ deleted: { id: found.schedule.id, name: found.schedule.name, flow: found.schedule.flowName } });
229
+ }));
230
+ server.registerTool("run_ui_test_schedule_now", {
231
+ title: "Run a UI-test schedule now",
232
+ description: "Trigger a UI-test schedule immediately (server runner, the flow's approved version on-prem) and wait for the result like run_ui_test. Refused while the schedule is already running.",
233
+ inputSchema: {
234
+ workspace: shared_js_1.workspaceArg,
235
+ schedule: (0, shared_js_1.refArg)("UI-test schedule"),
236
+ wait: zod_1.z.boolean().default(true),
237
+ timeoutSec: zod_1.z.number().int().min(5).max(1800).default(600),
238
+ includeScreenshots: zod_1.z.boolean().default(false),
239
+ },
240
+ annotations: shared_js_1.RUN,
241
+ }, (0, shared_js_1.guarded)(async ({ workspace, schedule, wait, timeoutSec, includeScreenshots }) => {
242
+ const found = await resolveSchedule(ctx, workspace, schedule);
243
+ const trigger = await ctx.api.runUiTestScheduleNow(found.ws.id, found.schedule.id);
244
+ const header = { schedule: { id: found.schedule.id, name: found.schedule.name }, flow: { id: found.schedule.flowClientId, name: found.schedule.flowName }, reportId: trigger.reportId };
245
+ if (!wait)
246
+ return (0, shared_js_1.ok)({ ...header, status: trigger.status, note: "Check it with get_ui_test_run." });
247
+ const { report, timedOut } = await (0, uiTestRuns_js_1.pollRun)(ctx, found.ws.id, trigger.reportId, timeoutSec);
248
+ if (timedOut || !report)
249
+ return (0, shared_js_1.ok)({ ...header, status: report?.status ?? trigger.status, timedOut: true, note: `Still running after ${timeoutSec}s — call get_ui_test_run with this reportId later.` });
250
+ const structured = { ...header, run: (0, summarize_js_1.runReportView)(report) };
251
+ if (!includeScreenshots)
252
+ return (0, shared_js_1.ok)(structured);
253
+ const images = [
254
+ { caption: "Failure screenshot", image: (0, summarize_js_1.imageFromDataUrl)(report.failureScreenshot) },
255
+ { caption: "Final screenshot", image: (0, summarize_js_1.imageFromDataUrl)(report.finalScreenshot) },
256
+ ].flatMap((entry) => (entry.image ? [{ caption: entry.caption, ...entry.image }] : []));
257
+ return (0, shared_js_1.okWithImages)(structured, images);
258
+ }));
259
+ // ── review (on-prem) ─────────────────────────────────────────────────
260
+ server.registerTool("submit_ui_test", {
261
+ title: "Submit a UI test for approval",
262
+ description: "Send a draft UI test for review (on-prem approval workflow — schedules run the APPROVED version). Optionally name approvers (email, name or user id; default: every eligible approver is notified). On cloud there is no review workflow and this says so.",
263
+ inputSchema: {
264
+ workspace: shared_js_1.workspaceArg,
265
+ flow: uiTestShared_js_1.flowArg,
266
+ approvers: zod_1.z.array(zod_1.z.string().max(255)).max(100).optional(),
267
+ },
268
+ annotations: shared_js_1.WRITE,
269
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, approvers }) => {
270
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
271
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(resolved.item);
272
+ try {
273
+ let approverIds;
274
+ if (approvers && approvers.length > 0) {
275
+ const { items } = await ctx.api.uiTestApproverCandidates(ws, resolved.item.clientId);
276
+ approverIds = approvers.map((ref) => {
277
+ const needle = ref.trim().toLowerCase();
278
+ const match = items.find((candidate) => candidate.userId === ref.trim() || candidate.email.toLowerCase() === needle || (candidate.displayName ?? "").toLowerCase() === needle);
279
+ if (!match)
280
+ throw new errors_js_1.ToolInputError("APPROVER_NOT_ELIGIBLE", `"${ref}" cannot approve this UI test.`, { candidates: items.map((candidate) => ({ email: candidate.email, name: candidate.displayName })) });
281
+ return match.userId;
282
+ });
283
+ }
284
+ const submitted = await ctx.api.submitUiTest(ws, resolved.item.clientId, approverIds ? { approverIds } : {});
285
+ ctx.invalidate("uitests:");
286
+ return (0, shared_js_1.ok)({ uiTest: flowStatus(submitted), note: "Submitted — an approver approves it with review_ui_test or in the desktop app." });
287
+ }
288
+ catch (error) {
289
+ if (!isReviewDisabled(error))
290
+ throw error;
291
+ return (0, shared_js_1.ok)({ uiTest: { id: resolved.item.clientId, name: resolved.item.name }, reviewWorkflow: false, note: CLOUD_REVIEW_NOTE });
292
+ }
293
+ }));
294
+ server.registerTool("review_ui_test", {
295
+ title: "Approve or reject a UI test",
296
+ description: "As an approver (on-prem), approve a UI test waiting for review — its current content becomes the new approved version that schedules run — or reject it with a note explaining the changes needed. list_ui_tests awaitingMyReview: true lists what waits for you; list_ui_test_versions shows what changed.",
297
+ inputSchema: {
298
+ workspace: shared_js_1.workspaceArg,
299
+ flow: uiTestShared_js_1.flowArg,
300
+ decision: zod_1.z.enum(["approve", "reject"]),
301
+ note: zod_1.z.string().trim().max(2000).optional().describe("Required when rejecting"),
302
+ },
303
+ annotations: shared_js_1.WRITE,
304
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, decision, note }) => {
305
+ if (decision === "reject" && !note)
306
+ throw new errors_js_1.ToolInputError("REVIEW_NOTE_REQUIRED", "Rejecting needs a `note` telling the author what to change.");
307
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
308
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(resolved.item);
309
+ try {
310
+ const result = decision === "approve" ? await ctx.api.approveUiTest(ws, resolved.item.clientId) : await ctx.api.rejectUiTest(ws, resolved.item.clientId, note);
311
+ ctx.invalidate("uitests:");
312
+ return (0, shared_js_1.ok)({ uiTest: flowStatus(result), decision, note: decision === "approve" ? `Approved — version ${result.activeVersionNumber ?? "?"} is what schedules run now.` : "Rejected — the author sees your note (changes requested)." });
313
+ }
314
+ catch (error) {
315
+ if (!isReviewDisabled(error))
316
+ throw error;
317
+ return (0, shared_js_1.ok)({ uiTest: { id: resolved.item.clientId, name: resolved.item.name }, reviewWorkflow: false, note: CLOUD_REVIEW_NOTE });
318
+ }
319
+ }));
320
+ server.registerTool("list_ui_test_versions", {
321
+ title: "UI-test approved versions",
322
+ description: "The approved versions of a UI test (on-prem): number, who submitted and approved it, when, and which one is active (what schedules run). With `version`: that version's snapshot (content, settings) and a line diff of its YAML against the current flow.",
323
+ inputSchema: {
324
+ workspace: shared_js_1.workspaceArg,
325
+ flow: uiTestShared_js_1.flowArg,
326
+ version: zod_1.z.number().int().min(1).optional(),
327
+ },
328
+ annotations: shared_js_1.READ,
329
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, version }) => {
330
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
331
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(resolved.item);
332
+ const head = { uiTest: { id: resolved.item.clientId, name: resolved.item.name, version: resolved.item.version ?? null, reviewStatus: resolved.item.reviewStatus ?? null } };
333
+ try {
334
+ if (version === undefined) {
335
+ const list = await ctx.api.listUiTestVersions(ws, resolved.item.clientId);
336
+ return (0, shared_js_1.ok)({
337
+ ...head,
338
+ activeVersionNumber: list.activeVersionNumber,
339
+ versions: list.items.map(({ snapshot: _snapshot, ...item }) => item),
340
+ ...(list.items.length === 0 ? { note: "Never approved yet — submit_ui_test sends it for review." } : {}),
341
+ });
342
+ }
343
+ const [detail, current] = await Promise.all([ctx.api.getUiTestVersion(ws, resolved.item.clientId, version), ctx.api.getUiTest(resolved.item.clientId)]);
344
+ const snapshotContent = detail.snapshot?.content ?? "";
345
+ const { snapshot, ...meta } = detail;
346
+ return (0, shared_js_1.ok)({
347
+ ...head,
348
+ version: meta,
349
+ snapshot: snapshot ? { ...snapshot, content: (0, truncate_js_1.clipBytes)(snapshotContent, truncate_js_1.LIMITS.SCENARIO_FULL).text } : null,
350
+ diffAgainstCurrent: (0, compose_js_1.diffLines)(snapshotContent, current.content ?? ""),
351
+ });
352
+ }
353
+ catch (error) {
354
+ if (!isReviewDisabled(error))
355
+ throw error;
356
+ return (0, shared_js_1.ok)({ ...head, reviewWorkflow: false, versions: [], note: CLOUD_REVIEW_NOTE });
357
+ }
358
+ }));
359
+ server.registerTool("restore_ui_test_version", {
360
+ title: "Restore an approved UI-test version",
361
+ description: "Make an earlier approved version the ACTIVE one again (on-prem; approvers): schedules run it from now on. The flow's current content, version and review status are not changed.",
362
+ inputSchema: { workspace: shared_js_1.workspaceArg, flow: uiTestShared_js_1.flowArg, version: zod_1.z.number().int().min(1) },
363
+ annotations: shared_js_1.IDEMPOTENT_WRITE,
364
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, version }) => {
365
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
366
+ try {
367
+ const restored = await ctx.api.restoreUiTestVersion((0, uiTestShared_js_1.workspaceIdOf)(resolved.item), resolved.item.clientId, version);
368
+ ctx.invalidate("uitests:");
369
+ return (0, shared_js_1.ok)({ uiTest: flowStatus(restored), note: `Version ${version} is active — schedules run it from now on.` });
370
+ }
371
+ catch (error) {
372
+ if (!isReviewDisabled(error))
373
+ throw error;
374
+ return (0, shared_js_1.ok)({ uiTest: { id: resolved.item.clientId, name: resolved.item.name }, reviewWorkflow: false, note: CLOUD_REVIEW_NOTE });
375
+ }
376
+ }));
377
+ // ── export / import ──────────────────────────────────────────────────
378
+ server.registerTool("export_ui_tests", {
379
+ title: "Export UI tests",
380
+ description: "Export UI tests (a list of flows, or a folder with its subfolders) as a `callman.uitest` envelope — the .callman.uitest file format the desktop app imports. Sub-flows they call with runFlow are bundled; connections they use are included as safe descriptions (never credentials). includeDataset: false leaves test data out; secret-looking dataset values are masked unless reveal: true (needs environments:reveal) — a masked envelope cannot be imported until those values are filled in. The envelope comes back in the result: save it yourself if a file is wanted.",
381
+ inputSchema: {
382
+ workspace: shared_js_1.workspaceArg,
383
+ flows: zod_1.z.array(uiTestShared_js_1.flowArg).min(1).max(100).optional(),
384
+ folder: zod_1.z.string().max(300).optional(),
385
+ includeDataset: zod_1.z.boolean().default(true),
386
+ reveal: zod_1.z.boolean().optional().describe("Export secret dataset values unmasked (needs environments:reveal)"),
387
+ },
388
+ annotations: shared_js_1.READ,
389
+ }, (0, shared_js_1.guarded)(async ({ workspace, flows, folder, includeDataset, reveal }) => {
390
+ if ((flows === undefined) === (folder === undefined))
391
+ throw new errors_js_1.ToolInputError("EXPORT_SCOPE", "Pass `flows` or `folder` (one of them).");
392
+ let targets;
393
+ if (folder) {
394
+ const found = await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder);
395
+ const scope = (0, lookups_js_1.uiTestFolderSubtree)(await (0, lookups_js_1.loadUiTestFolders)(ctx), found.item.id);
396
+ targets = (await (0, lookups_js_1.loadUiTests)(ctx)).filter((flow) => flow.folderId && scope.has(flow.folderId));
397
+ if (targets.length === 0)
398
+ throw new errors_js_1.ToolInputError("UI_TEST_FOLDER_EMPTY", `Folder "${found.item.path}" has no UI tests.`);
399
+ }
400
+ else {
401
+ targets = [];
402
+ for (const ref of flows ?? [])
403
+ targets.push((await (0, lookups_js_1.resolveUiTest)(ctx, workspace, ref)).item);
404
+ }
405
+ if (targets.length > 100)
406
+ throw new errors_js_1.ToolInputError("EXPORT_TOO_MANY", `${targets.length} flows (limit 100 per export).`);
407
+ const envelope = await ctx.api.exportUiTests((0, uiTestShared_js_1.workspaceIdOf)(targets[0]), { clientIds: targets.map((flow) => flow.clientId), includeDataset }, reveal ?? false);
408
+ const masked = envelope.flows.flatMap((entry) => (0, summarize_js_1.maskedDatasetKeys)(entry.dataset).map((key) => `${entry.name}: ${key}`));
409
+ const bytes = (0, truncate_js_1.byteLength)(envelope);
410
+ if (bytes > EXPORT_MAX_BYTES)
411
+ throw new errors_js_1.ToolInputError("EXPORT_TOO_LARGE", `The export is ${bytes} bytes (limit ${EXPORT_MAX_BYTES}). Export fewer flows, or from the desktop app.`, { bytes });
412
+ return (0, shared_js_1.ok)({
413
+ flows: envelope.flows.map((entry) => ({ name: entry.name, platform: entry.platform, ...(entry.isSubFlow ? { bundledSubFlow: true } : {}) })),
414
+ connections: (envelope.connections ?? []).map((connection) => ({ refId: connection.refId, name: connection.name, type: connection.type })),
415
+ bytes,
416
+ ...(masked.length > 0
417
+ ? { maskedDatasetValues: masked, note: "Secret dataset values are masked in this envelope — importing it is refused until they are replaced with real values (or export with reveal: true, which needs environments:reveal)." }
418
+ : includeDataset
419
+ ? { note: "Dataset values are included as stored — treat the envelope as sensitive." }
420
+ : {}),
421
+ envelope: envelope,
422
+ });
423
+ }));
424
+ server.registerTool("import_ui_tests", {
425
+ title: "Import UI tests",
426
+ description: "Import a `callman.uitest` envelope (the JSON of a .callman.uitest file, as text or object) into a workspace. Connections it references are matched to the workspace's connections of the same type and name automatically; `connections` overrides per export connection (workspace connection id or name, or \"skip\"). dryRun: true shows the flows, name clashes and the connection plan without importing. On-prem, `group` picks the owning group.",
427
+ inputSchema: {
428
+ workspace: shared_js_1.workspaceArg,
429
+ envelope: zod_1.z.union([zod_1.z.string().max(ENVELOPE_MAX), zod_1.z.record(zod_1.z.unknown())]).describe("The envelope (JSON text or object)"),
430
+ group: zod_1.z.string().max(200).optional().describe('On-prem group (name or id, or "personal")'),
431
+ connections: zod_1.z
432
+ .array((0, strict_js_1.strictObject)({
433
+ from: zod_1.z.string().min(1).max(300).describe("The envelope's connection (refId or name)"),
434
+ to: zod_1.z.string().min(1).max(200).describe('Workspace connection id or name, or "skip"'),
435
+ }, "connection mapping field"))
436
+ .max(200)
437
+ .optional(),
438
+ dryRun: shared_js_1.dryRunArg,
439
+ },
440
+ annotations: shared_js_1.WRITE,
441
+ }, (0, shared_js_1.guarded)(async ({ workspace, envelope, group, connections: mapping, dryRun }) => {
442
+ const ws = await ctx.resolveWorkspace(workspace);
443
+ let parsed = envelope;
444
+ if (typeof envelope === "string") {
445
+ try {
446
+ parsed = JSON.parse(envelope);
447
+ }
448
+ catch {
449
+ throw new errors_js_1.ToolInputError("IMPORT_INVALID_JSON", "The envelope is not valid JSON.");
450
+ }
451
+ }
452
+ const record = parsed;
453
+ if (record?.kind !== "callman.uitest") {
454
+ throw new errors_js_1.ToolInputError("IMPORT_NOT_UI_TESTS", `This is not a UI-test export (expected kind: "callman.uitest"${record?.kind ? `, got "${String(record.kind)}"` : ""}).${record?.kind === "callman.scenario" ? " Use import_scenario." : ""}`);
455
+ }
456
+ if (!Array.isArray(record.flows) || record.flows.length === 0)
457
+ throw new errors_js_1.ToolInputError("IMPORT_EMPTY", "The envelope has no flows.");
458
+ const masked = record.flows.flatMap((entry) => (0, summarize_js_1.maskedDatasetKeys)(entry.dataset).map((key) => `${entry.name}: ${key}`));
459
+ if (masked.length > 0) {
460
+ throw new errors_js_1.ToolInputError("MASKED_VALUE", `The envelope carries masked dataset values (${masked.join(", ")}) — replace them with the real values, or export again with reveal: true (needs environments:reveal).`, { keys: masked });
461
+ }
462
+ const exported = (record.connections ?? []).filter((connection) => connection.refId);
463
+ for (const entry of mapping ?? []) {
464
+ if (!exported.some((connection) => connection.refId === entry.from || connection.name === entry.from)) {
465
+ throw new errors_js_1.ToolInputError("IMPORT_CONNECTION_UNKNOWN", `The envelope has no connection "${entry.from}".`, { exportConnections: exported.map((connection) => ({ refId: connection.refId, name: connection.name, type: connection.type })) });
466
+ }
467
+ }
468
+ const available = exported.length > 0 ? await (0, lookups_js_1.loadConnections)(ctx, ws.id) : [];
469
+ const decisions = [];
470
+ const connectionMap = {};
471
+ for (const connection of exported) {
472
+ const override = (mapping ?? []).find((entry) => entry.from === connection.refId || entry.from === connection.name);
473
+ let target;
474
+ if (override && override.to.trim().toLowerCase() !== "skip") {
475
+ target = available.find((candidate) => candidate.id === override.to) ?? available.find((candidate) => candidate.name.toLowerCase() === override.to.toLowerCase());
476
+ if (!target)
477
+ throw new errors_js_1.ToolInputError("CONNECTION_NOT_FOUND", `No workspace connection "${override.to}" (list_connections shows them).`);
478
+ if (connection.type && target.type !== connection.type)
479
+ throw new errors_js_1.ToolInputError("CONNECTION_WRONG_TYPE", `"${target.name}" is a ${target.type} connection; the envelope's "${connection.name}" is ${connection.type}.`);
480
+ }
481
+ else if (!override) {
482
+ const sameName = available.filter((candidate) => candidate.type === connection.type && candidate.name.toLowerCase() === (connection.name ?? "").toLowerCase());
483
+ target = sameName.length === 1 ? sameName[0] : undefined;
484
+ }
485
+ connectionMap[connection.refId] = target ? { action: "map", connectionId: target.id } : { action: "skip" };
486
+ decisions.push({ export: connection.name ?? connection.refId, type: connection.type ?? null, mappedTo: target ? target.name : null });
487
+ }
488
+ const existingNames = new Set((await (0, lookups_js_1.loadUiTests)(ctx)).filter((flow) => flow.workspaceId === ws.id).map((flow) => flow.name.toLowerCase()));
489
+ const clashes = record.flows.filter((flow) => existingNames.has(String(flow.name).toLowerCase())).map((flow) => flow.name);
490
+ const targetGroup = await (0, uiTestShared_js_1.resolveGroupId)(ctx, ws.id, group);
491
+ if (dryRun) {
492
+ return (0, shared_js_1.ok)({
493
+ dryRun: true,
494
+ workspace: (0, lookups_js_1.workspaceTag)(ws),
495
+ flows: record.flows.map((flow) => ({ name: flow.name, platform: flow.platform, ...(flow.isSubFlow ? { bundledSubFlow: true } : {}) })),
496
+ connections: decisions,
497
+ ...(clashes.length > 0 ? { nameClashes: clashes, note: "Flows with these names already exist in the workspace — the server resolves clashes (see its warnings after the import)." } : {}),
498
+ });
499
+ }
500
+ const result = await ctx.api.importUiTests(ws.id, { envelope: parsed, connectionMap, ...(targetGroup.id ? { groupId: targetGroup.id } : {}) });
501
+ ctx.invalidate("uitests:");
502
+ const skipped = decisions.filter((decision) => !decision.mappedTo);
503
+ return (0, shared_js_1.ok)({
504
+ workspace: (0, lookups_js_1.workspaceTag)(ws),
505
+ imported: result.flows.map((flow) => ({ id: flow.clientId, name: flow.name, platform: flow.platform })),
506
+ connections: decisions,
507
+ warnings: [...(result.warnings ?? []), ...(targetGroup.note ? [targetGroup.note] : [])],
508
+ ...(skipped.length > 0 ? { note: `Not connected: ${skipped.map((decision) => decision.export).join(", ")} — data steps using them need a workspace connection (validate_ui_test shows which; fix with patch_ui_test_steps).` } : {}),
509
+ });
510
+ }));
511
+ };
512
+ exports.registerUiTestAdminTools = registerUiTestAdminTools;