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,372 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerUiTestRunTools = exports.pollRun = exports.startRun = exports.DESKTOP_HINT = 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 summarize_js_1 = require("../domain/uiTest/summarize.js");
8
+ const lookups_js_1 = require("./lookups.js");
9
+ const shared_js_1 = require("./shared.js");
10
+ const uiTestShared_js_1 = require("./uiTestShared.js");
11
+ // Running UI tests. Two executors:
12
+ // * server — the backend's ui-runner (Playwright, web flows only), when the
13
+ // installation has one (GET /ui-test-runner/status);
14
+ // * desktop — the user's own Callman desktop app, when "Let AI tools run UI
15
+ // tests on this computer" is on: it claims the queued job, runs it on a
16
+ // connected device / emulator / simulator (Android, iOS) or a local
17
+ // browser (web) and streams the steps back into the same report.
18
+ // Either way the tool gets a reportId and polls GET /ui-test-runs/:id.
19
+ const TERMINAL = new Set(["passed", "failed", "stopped"]);
20
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
21
+ exports.DESKTOP_HINT = "Open the Callman desktop app on your computer, sign in, and turn on Settings → AI tools → \"Let AI tools run UI tests on this computer\". For Android/iOS also connect a device or start an emulator/simulator. list_ui_test_devices shows what is available.";
22
+ const targetArg = zod_1.z
23
+ .enum(["auto", "server", "desktop"])
24
+ .default("auto")
25
+ .describe("auto = the server runner for web flows when the installation has one, otherwise your desktop app; server = web only; desktop = your Callman desktop app");
26
+ const desktopOptions = {
27
+ device: zod_1.z.string().max(200).optional().describe("Desktop runs: device id or name (list_ui_test_devices); default = the app's current / first online device"),
28
+ browserKind: zod_1.z.enum(["chrome", "firefox", "webkit"]).optional().describe("Desktop web runs: browser (default: the flow's setting)"),
29
+ headless: zod_1.z.boolean().optional().describe("Desktop web runs: headless browser"),
30
+ dataset: zod_1.z.record(zod_1.z.string().max(10_000)).optional().describe("Desktop runs: dataset values overriding the flow's for this run"),
31
+ };
32
+ const hasDesktopOptions = (options) => Object.values(options).some((value) => value !== undefined);
33
+ const serverRunnerAvailable = async (ctx) => {
34
+ try {
35
+ return (await ctx.api.uiTestRunnerStatus()).serverRunner === true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ };
41
+ // Pick the executor and start the run. auto → server for a web flow when a
42
+ // runner is up (and no desktop-only option was given), else desktop; a
43
+ // server answer of "no runner" falls back to the desktop.
44
+ const startRun = async (ctx, flow, target, options, serverRunner) => {
45
+ const ws = (0, uiTestShared_js_1.workspaceIdOf)(flow);
46
+ const notes = [];
47
+ if (target === "server") {
48
+ if (flow.platform !== "web")
49
+ throw new errors_js_1.ToolInputError("UI_TEST_PLATFORM_UNSUPPORTED", `The server runner runs web flows only — "${flow.name}" is ${flow.platform}. Use target: "desktop" (your Callman desktop app with a device/emulator).`);
50
+ if (hasDesktopOptions(options))
51
+ throw new errors_js_1.ToolInputError("ARG_TARGET_MISMATCH", "device / browserKind / headless / dataset apply to desktop runs only — drop them or use target: \"desktop\".");
52
+ }
53
+ const useServer = target === "server" || (target === "auto" && flow.platform === "web" && !hasDesktopOptions(options) && (serverRunner ?? (await serverRunnerAvailable(ctx))));
54
+ if (useServer) {
55
+ try {
56
+ return { flow, trigger: await ctx.api.runUiTestOnServer(ws, flow.clientId), executor: "server", notes };
57
+ }
58
+ catch (error) {
59
+ if (!(target === "auto" && error instanceof errors_js_1.CallmanApiError && error.code === "UI_RUNNER_UNAVAILABLE"))
60
+ throw error;
61
+ notes.push("The server runner is not available — the run went to your desktop app instead.");
62
+ }
63
+ }
64
+ (0, uiTestShared_js_1.refuseMaskedDataset)(options.dataset);
65
+ const body = {
66
+ ...(options.device ? { device: options.device } : {}),
67
+ ...(options.browserKind ? { browserKind: options.browserKind } : {}),
68
+ ...(options.headless !== undefined ? { headless: options.headless } : {}),
69
+ ...(options.dataset ? { dataset: options.dataset } : {}),
70
+ };
71
+ const trigger = await ctx.api.runUiTestOnDesktop(ws, flow.clientId, body);
72
+ // A desktop busy with a run the user started by hand still accepts the job
73
+ // (it waits in the queue) — say so, or a long "queued" looks like a hang.
74
+ const agents = await ctx.api.listUiTestDesktopAgents(ws).then((result) => result.agents ?? [], () => []);
75
+ const capable = agents.filter((agent) => agent.platforms.includes(flow.platform));
76
+ if (capable.length > 0 && capable.every((agent) => agent.busy)) {
77
+ notes.push(`Your desktop app${capable.length > 1 ? "s are" : " is"} busy with another UI-test run (e.g. one started by hand) — this run is queued behind it and starts when it finishes.`);
78
+ }
79
+ return { flow, trigger, executor: "desktop", notes };
80
+ };
81
+ exports.startRun = startRun;
82
+ const fetchReport = async (ctx, ws, reportId) => {
83
+ try {
84
+ return await ctx.api.getUiTestRun(ws, reportId);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof errors_js_1.CallmanApiError && error.status === 404)
88
+ return null; // not visible yet
89
+ throw error;
90
+ }
91
+ };
92
+ const pollRun = async (ctx, ws, reportId, timeoutSec) => {
93
+ const deadline = Date.now() + timeoutSec * 1000;
94
+ let polls = 0;
95
+ let last = null;
96
+ while (Date.now() < deadline) {
97
+ await sleep(polls < 5 ? ctx.pollDelaysMs.fast : ctx.pollDelaysMs.slow);
98
+ polls += 1;
99
+ last = (await fetchReport(ctx, ws, reportId)) ?? last;
100
+ if (last && TERMINAL.has(last.status))
101
+ return { report: last, timedOut: false };
102
+ }
103
+ return { report: last, timedOut: true };
104
+ };
105
+ exports.pollRun = pollRun;
106
+ const screenshotImages = (report) => [
107
+ { caption: `Failure screenshot — ${report.flowName}`, image: (0, summarize_js_1.imageFromDataUrl)(report.failureScreenshot) },
108
+ { caption: `Final screenshot — ${report.flowName}`, image: (0, summarize_js_1.imageFromDataUrl)(report.finalScreenshot) },
109
+ ].flatMap((entry) => (entry.image ? [{ caption: entry.caption, ...entry.image }] : []));
110
+ const timedOutNote = (status, executor, timeoutSec) => status === "queued" && executor === "desktop"
111
+ ? `No desktop app picked the run up within ${timeoutSec}s. ${exports.DESKTOP_HINT} The run stays queued for 10 minutes; check it later with get_ui_test_run, or cancel_ui_test_run.`
112
+ : `Still ${status ?? "running"} after ${timeoutSec}s — call get_ui_test_run with this reportId later.`;
113
+ // The report's workspace is needed for the per-report routes: the flow's, or
114
+ // found by trying each workspace.
115
+ const findReport = async (ctx, workspace, reportId) => (0, lookups_js_1.findInWorkspaces)(ctx, workspace, "UI-test run", reportId, (ws) => ctx.api.getUiTestRun(ws.id, reportId), { matchOf: () => "id" });
116
+ const registerUiTestRunTools = (server, ctx) => {
117
+ server.registerTool("list_ui_test_devices", {
118
+ title: "Where UI tests can run",
119
+ description: "Where UI tests can run right now: whether the server runner is up (web flows), and your desktop apps that let AI tools run UI tests — with their connected Android/iOS devices, emulators and simulators, the browsers they have, and whether they are busy. Call it before run_ui_test for mobile flows, or when a run says the desktop is offline.",
120
+ inputSchema: { workspace: shared_js_1.workspaceArg },
121
+ annotations: shared_js_1.READ,
122
+ }, (0, shared_js_1.guarded)(async ({ workspace }) => {
123
+ const ws = await ctx.resolveWorkspaceOrNull(workspace);
124
+ let agents = [];
125
+ let serverRunner;
126
+ let note;
127
+ try {
128
+ const result = await ctx.api.listUiTestDesktopAgents(ws?.id ?? null);
129
+ agents = result.agents ?? [];
130
+ serverRunner = result.serverRunner === true;
131
+ }
132
+ catch (error) {
133
+ if (!(error instanceof errors_js_1.CallmanApiError && (error.code === "ROUTE_NOT_FOUND" || error.status === 404)))
134
+ throw error;
135
+ serverRunner = await serverRunnerAvailable(ctx);
136
+ note = "This server does not support desktop runs yet (no desktop relay) — only the server runner is available.";
137
+ }
138
+ const online = (platform) => agents.flatMap((agent) => agent.devices.filter((device) => device.platform === platform && device.state === "online"));
139
+ const webDesktop = agents.some((agent) => agent.platforms.includes("web"));
140
+ return (0, shared_js_1.ok)({
141
+ serverRunner,
142
+ desktopAgents: agents.map((agent) => ({
143
+ agentId: agent.agentId,
144
+ hostName: agent.hostName,
145
+ appVersion: agent.appVersion,
146
+ lastSeenAt: agent.lastSeenAt,
147
+ busy: agent.busy,
148
+ platforms: agent.platforms,
149
+ browsers: agent.browsers,
150
+ devices: agent.devices,
151
+ })),
152
+ canRun: {
153
+ web: serverRunner ? (webDesktop ? "server or desktop" : "server") : webDesktop ? "desktop" : "nowhere",
154
+ android: online("android").length > 0 ? "desktop" : agents.some((agent) => agent.platforms.includes("android")) ? "desktop (no online device — connect one or start an emulator)" : "nowhere",
155
+ ios: online("ios").length > 0 ? "desktop" : agents.some((agent) => agent.platforms.includes("ios")) ? "desktop (no online simulator/device)" : "nowhere",
156
+ },
157
+ ...(agents.length === 0 ? { hint: exports.DESKTOP_HINT } : {}),
158
+ ...(note ? { note } : {}),
159
+ });
160
+ }));
161
+ server.registerTool("run_ui_test", {
162
+ title: "Run a UI test",
163
+ description: "Run one UI test and wait for the result (default up to 10 minutes). target auto (default): web flows on the server runner when the installation has one, everything else (Android, iOS, or no server runner) on your Callman desktop app — which must be open with \"Let AI tools run UI tests on this computer\" on (list_ui_test_devices). device / browserKind / headless / dataset apply to desktop runs. Returns per-step results, failing steps expanded with the data they saw, dataset issues and warnings; includeScreenshots: true adds the failure/final screenshots as images. wait: false returns the reportId at once.",
164
+ inputSchema: {
165
+ workspace: shared_js_1.workspaceArg,
166
+ flow: uiTestShared_js_1.flowArg,
167
+ target: targetArg,
168
+ ...desktopOptions,
169
+ wait: zod_1.z.boolean().default(true),
170
+ timeoutSec: zod_1.z.number().int().min(5).max(1800).default(600),
171
+ includeScreenshots: zod_1.z.boolean().default(false),
172
+ },
173
+ annotations: shared_js_1.RUN,
174
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, target, device, browserKind, headless, dataset, wait, timeoutSec, includeScreenshots }) => {
175
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
176
+ const started = await (0, exports.startRun)(ctx, resolved.item, target, { device, browserKind, headless, dataset });
177
+ const annotation = resolved.annotation ? { resolved: [(0, resolve_js_1.resolvedSummary)(flow, resolved)] } : {};
178
+ const header = { uiTest: { id: resolved.item.clientId, name: resolved.item.name, platform: resolved.item.platform }, reportId: started.trigger.reportId, executor: started.executor };
179
+ if (!wait) {
180
+ return (0, shared_js_1.ok)({ ...header, status: started.trigger.status, ...(started.notes.length ? { warnings: started.notes } : {}), note: "Check it with get_ui_test_run.", ...annotation });
181
+ }
182
+ const { report, timedOut } = await (0, exports.pollRun)(ctx, (0, uiTestShared_js_1.workspaceIdOf)(resolved.item), started.trigger.reportId, timeoutSec);
183
+ if (timedOut || !report) {
184
+ return (0, shared_js_1.ok)({ ...header, status: report?.status ?? started.trigger.status, timedOut: true, note: timedOutNote(report?.status ?? started.trigger.status, started.executor, timeoutSec), ...(started.notes.length ? { warnings: started.notes } : {}), ...annotation });
185
+ }
186
+ const structured = { run: (0, summarize_js_1.runReportView)({ ...report, executor: report.executor ?? started.executor }), ...(started.notes.length ? { warnings: started.notes } : {}), ...annotation };
187
+ return includeScreenshots ? (0, shared_js_1.okWithImages)(structured, screenshotImages(report)) : (0, shared_js_1.ok)(structured);
188
+ }));
189
+ server.registerTool("run_ui_tests", {
190
+ title: "Run several UI tests",
191
+ description: "Run a batch of UI tests — a list of flows, or every flow in a folder (subfolders included unless includeSubfolders: false) — and wait for all of them. Each flow goes to the same executor run_ui_test would pick (server runs may run in parallel; desktop runs queue one after another on your app). Returns totals and one row per flow (status, executor, duration, first failing step); read details with get_ui_test_run.",
192
+ inputSchema: {
193
+ workspace: shared_js_1.workspaceArg,
194
+ flows: zod_1.z.array(uiTestShared_js_1.flowArg).min(1).max(100).optional(),
195
+ folder: zod_1.z.string().max(300).optional().describe("Run every flow in this folder (id, name or path)"),
196
+ includeSubfolders: zod_1.z.boolean().default(true),
197
+ target: targetArg,
198
+ wait: zod_1.z.boolean().default(true),
199
+ timeoutSec: zod_1.z.number().int().min(5).max(7200).default(1800).describe("For the whole batch"),
200
+ },
201
+ annotations: shared_js_1.RUN,
202
+ }, (0, shared_js_1.guarded)(async ({ workspace, flows, folder, includeSubfolders, target, wait, timeoutSec }) => {
203
+ if ((flows === undefined) === (folder === undefined))
204
+ throw new errors_js_1.ToolInputError("RUN_BATCH_SCOPE", "Pass `flows` or `folder` (one of them).");
205
+ let selected = [];
206
+ const notStarted = [];
207
+ let unresolved = 0;
208
+ if (folder) {
209
+ const target = await (0, lookups_js_1.resolveUiTestFolder)(ctx, workspace, folder);
210
+ const scope = includeSubfolders ? (0, lookups_js_1.uiTestFolderSubtree)(await (0, lookups_js_1.loadUiTestFolders)(ctx), target.item.id) : new Set([target.item.id]);
211
+ selected = (await (0, lookups_js_1.loadUiTests)(ctx)).filter((flow) => flow.folderId && scope.has(flow.folderId));
212
+ if (selected.length === 0)
213
+ throw new errors_js_1.ToolInputError("UI_TEST_FOLDER_EMPTY", `Folder "${target.item.path}" has no UI tests${includeSubfolders ? "" : " directly (try includeSubfolders: true)"}.`);
214
+ if (selected.length > 100)
215
+ throw new errors_js_1.ToolInputError("RUN_BATCH_TOO_LARGE", `Folder "${target.item.path}" has ${selected.length} UI tests (limit 100 per batch) — run a subfolder.`);
216
+ }
217
+ else {
218
+ for (const ref of flows ?? []) {
219
+ try {
220
+ selected.push((await (0, lookups_js_1.resolveUiTest)(ctx, workspace, ref)).item);
221
+ }
222
+ catch (error) {
223
+ notStarted.push({ flow: ref, error: (0, shared_js_1.failureOf)(error) });
224
+ unresolved += 1;
225
+ }
226
+ }
227
+ }
228
+ const serverRunner = await serverRunnerAvailable(ctx);
229
+ const started = [];
230
+ for (const flow of selected) {
231
+ try {
232
+ started.push(await (0, exports.startRun)(ctx, flow, target, {}, serverRunner));
233
+ }
234
+ catch (error) {
235
+ notStarted.push({ flow: flow.name, error: (0, shared_js_1.failureOf)(error) });
236
+ }
237
+ }
238
+ const reports = new Map(started.map((run) => [run.trigger.reportId, null]));
239
+ let timedOut = false;
240
+ if (wait && started.length > 0) {
241
+ const deadline = Date.now() + timeoutSec * 1000;
242
+ let polls = 0;
243
+ for (;;) {
244
+ const pending = started.filter((run) => !TERMINAL.has(reports.get(run.trigger.reportId)?.status ?? ""));
245
+ if (pending.length === 0)
246
+ break;
247
+ if (Date.now() >= deadline) {
248
+ timedOut = true;
249
+ break;
250
+ }
251
+ await sleep(polls < 5 ? ctx.pollDelaysMs.fast : ctx.pollDelaysMs.slow);
252
+ polls += 1;
253
+ for (const run of pending) {
254
+ const report = await fetchReport(ctx, (0, uiTestShared_js_1.workspaceIdOf)(run.flow), run.trigger.reportId);
255
+ if (report)
256
+ reports.set(run.trigger.reportId, report);
257
+ }
258
+ }
259
+ }
260
+ const rows = started.map((run) => {
261
+ const report = reports.get(run.trigger.reportId);
262
+ return report
263
+ ? { ...(0, summarize_js_1.runListRow)(report), executor: report.executor ?? run.executor }
264
+ : { reportId: run.trigger.reportId, flow: { id: run.flow.clientId, name: run.flow.name }, status: run.trigger.status, executor: run.executor };
265
+ });
266
+ const count = (status) => rows.filter((row) => row.status === status).length;
267
+ const unfinished = rows.filter((row) => !TERMINAL.has(row.status));
268
+ const desktopQueued = unfinished.some((row) => row.status === "queued" && row.executor === "desktop");
269
+ return (0, shared_js_1.ok)({
270
+ total: selected.length + unresolved,
271
+ passed: count("passed"),
272
+ failed: count("failed"),
273
+ stopped: count("stopped"),
274
+ ...(unfinished.length > 0 ? { unfinished: unfinished.length } : {}),
275
+ runs: rows,
276
+ ...(notStarted.length > 0 ? { notStarted } : {}),
277
+ ...(timedOut ? { timedOut: true, note: `Some runs were still going after ${timeoutSec}s — check them with get_ui_test_run.${desktopQueued ? ` ${exports.DESKTOP_HINT}` : ""}` } : !wait ? { note: "Started — check each with get_ui_test_run." } : {}),
278
+ ...([...new Set(started.flatMap((run) => run.notes))].length > 0 ? { warnings: [...new Set(started.flatMap((run) => run.notes))] } : {}),
279
+ });
280
+ }));
281
+ server.registerTool("get_ui_test_run", {
282
+ title: "Get a UI-test run",
283
+ description: "One UI-test run report: status, executor and device, per-step results (command, status, duration, error, the sub-flow it came from), failing steps expanded with the data they returned (DB rows, API responses, extracted {{vars}}), dataset issues and warnings. By `reportId`, or `flow` for its latest run. includeScreenshots: true returns the failure/final screenshots as images; includeNetwork: true adds the captured API calls; stepData: true expands every data step, not only failing ones.",
284
+ inputSchema: {
285
+ workspace: shared_js_1.workspaceArg,
286
+ reportId: zod_1.z.string().max(64).optional(),
287
+ flow: uiTestShared_js_1.flowArg.optional(),
288
+ includeScreenshots: zod_1.z.boolean().default(false),
289
+ includeNetwork: zod_1.z.boolean().default(false),
290
+ stepData: zod_1.z.boolean().default(false),
291
+ },
292
+ annotations: shared_js_1.READ,
293
+ }, (0, shared_js_1.guarded)(async ({ workspace, reportId, flow, includeScreenshots, includeNetwork, stepData }) => {
294
+ if (!reportId && !flow)
295
+ throw new errors_js_1.ToolInputError("RUN_REF_REQUIRED", "Pass `reportId`, or `flow` for its latest run.");
296
+ let report;
297
+ if (reportId) {
298
+ report = (await findReport(ctx, workspace, reportId)).value;
299
+ }
300
+ else {
301
+ const resolved = await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow);
302
+ const [latest] = await ctx.api.listUiTestRuns({ flowClientId: resolved.item.clientId, limit: 1 });
303
+ if (!latest) {
304
+ const [history] = await ctx.api.listUiTestResults({ flowClientId: resolved.item.clientId, limit: 1 }).catch(() => []);
305
+ return (0, shared_js_1.ok)({
306
+ uiTest: { id: resolved.item.clientId, name: resolved.item.name },
307
+ run: null,
308
+ ...(history ? { lastRecordedRun: (0, summarize_js_1.historyRow)(history), note: "The latest run was recorded by the desktop app without a full report (step list only)." } : { note: "This UI test has no runs yet. Use run_ui_test." }),
309
+ });
310
+ }
311
+ report = await ctx.api.getUiTestRun((0, uiTestShared_js_1.workspaceIdOf)(resolved.item), latest.id);
312
+ }
313
+ const structured = { run: (0, summarize_js_1.runReportView)(report, { includeNetwork, allStepData: stepData }) };
314
+ return includeScreenshots ? (0, shared_js_1.okWithImages)(structured, screenshotImages(report)) : (0, shared_js_1.ok)(structured);
315
+ }));
316
+ server.registerTool("list_ui_test_runs", {
317
+ title: "List UI-test runs",
318
+ description: "Recent UI-test runs, newest first — server, schedule and AI-started desktop runs (with reportIds), merged with the run history the desktop app recorded itself. Filter by flow and status.",
319
+ inputSchema: {
320
+ workspace: shared_js_1.workspaceArg,
321
+ flow: uiTestShared_js_1.flowArg.optional(),
322
+ status: zod_1.z.enum(["queued", "running", "passed", "failed", "stopped"]).optional(),
323
+ limit: zod_1.z.number().int().min(1).max(100).default(20),
324
+ includeHistory: zod_1.z.boolean().default(true).describe("Also list desktop-recorded runs that have no report"),
325
+ },
326
+ annotations: shared_js_1.READ,
327
+ }, (0, shared_js_1.guarded)(async ({ workspace, flow, status, limit, includeHistory }) => {
328
+ const resolved = flow ? await (0, lookups_js_1.resolveUiTest)(ctx, workspace, flow) : null;
329
+ const flowClientId = resolved?.item.clientId;
330
+ const [reports, history] = await Promise.all([
331
+ ctx.api.listUiTestRuns({ ...(flowClientId ? { flowClientId } : {}), ...(status ? { status } : {}), limit }),
332
+ includeHistory && (!status || ["passed", "failed", "stopped"].includes(status)) ? ctx.api.listUiTestResults({ ...(flowClientId ? { flowClientId } : {}), limit }).catch(() => []) : Promise.resolve([]),
333
+ ]);
334
+ const reportIds = new Set(reports.map((report) => report.id));
335
+ const extra = history.filter((row) => !row.reportId || !reportIds.has(row.reportId)).filter((row) => !status || row.status === status);
336
+ const rows = [
337
+ ...reports.map((report) => ({ at: Number(new Date(report.startedAt ?? 0)) || 0, row: (0, summarize_js_1.runListRow)(report) })),
338
+ ...extra.map((row) => ({ at: Number(row.startedAt) || 0, row: (0, summarize_js_1.historyRow)(row) })),
339
+ ]
340
+ .sort((a, b) => b.at - a.at)
341
+ .slice(0, limit)
342
+ .map((entry) => entry.row);
343
+ return (0, shared_js_1.ok)({ ...(resolved ? { uiTest: { id: resolved.item.clientId, name: resolved.item.name } } : {}), runs: rows, total: rows.length });
344
+ }));
345
+ server.registerTool("cancel_ui_test_run", {
346
+ title: "Cancel a UI-test run",
347
+ description: "Stop a queued or running UI-test run (server or desktop). A queued desktop run is dropped at once; a running one stops at the next step.",
348
+ inputSchema: { workspace: shared_js_1.workspaceArg, reportId: zod_1.z.string().min(1).max(64) },
349
+ annotations: shared_js_1.RUN,
350
+ }, (0, shared_js_1.guarded)(async ({ workspace, reportId }) => {
351
+ const found = await findReport(ctx, workspace, reportId);
352
+ if (TERMINAL.has(found.value.status)) {
353
+ return (0, shared_js_1.ok)({ reportId, status: found.value.status, note: "The run had already finished — nothing to cancel." });
354
+ }
355
+ const result = (await ctx.api.cancelUiTestRun(found.ws.id, reportId));
356
+ // Dropped from the queue = already stopped; a running one stops at its
357
+ // next step — re-read once so the answer is the report's real status.
358
+ let status = result?.status ?? (result?.cancelledFromQueue ? "stopped" : "stopping");
359
+ if (!TERMINAL.has(status)) {
360
+ const after = await ctx.api.getUiTestRun(found.ws.id, reportId).catch(() => null);
361
+ if (after && TERMINAL.has(after.status))
362
+ status = after.status;
363
+ }
364
+ return (0, shared_js_1.ok)({
365
+ reportId,
366
+ flow: { id: found.value.flowClientId, name: found.value.flowName },
367
+ status,
368
+ note: status === "stopped" ? "The run was still queued — it will not run." : "Cancel requested — get_ui_test_run shows the final status.",
369
+ });
370
+ }));
371
+ };
372
+ exports.registerUiTestRunTools = registerUiTestRunTools;
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.workspaceIdOf = exports.flowDetailView = exports.connectionNames = exports.conflictResult = exports.saveFlow = exports.refuseMaskedDataset = exports.upsertBodyOf = exports.resolveDatasetSources = exports.refuseLintErrors = exports.lintView = exports.lintFlow = exports.lintContext = exports.resolveGroupId = exports.groupArg = exports.headerArg = exports.runSettingsSchema = exports.datasetSourceSchema = exports.stepsArg = exports.stepInputSchema = exports.flowArg = exports.platformArg = exports.SEVERITIES = exports.TEST_TYPES = exports.PLATFORMS = void 0;
4
+ const callman_core_1 = require("callman-core");
5
+ const zod_1 = require("zod");
6
+ const errors_js_1 = require("../client/errors.js");
7
+ const resolve_js_1 = require("../domain/resolve.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
+ // Shared by the UI-test tool modules: argument schemas, the lint context
14
+ // (library names + workspace connections), the full-state upsert with its
15
+ // masked-value guard and version conflict, and the flow detail view.
16
+ exports.PLATFORMS = ["web", "android", "ios"];
17
+ exports.TEST_TYPES = ["regression", "smoke", "sanity", "integration", "e2e", "functional", "acceptance"];
18
+ exports.SEVERITIES = ["critical", "high-risk", "major", "minor"];
19
+ exports.platformArg = zod_1.z.enum(exports.PLATFORMS);
20
+ exports.flowArg = zod_1.z.string().min(1).max(500).describe("UI test (flow) id or name");
21
+ exports.stepInputSchema = zod_1.z.union([zod_1.z.string().min(1).max(20_000), zod_1.z.record(zod_1.z.unknown())]);
22
+ exports.stepsArg = zod_1.z
23
+ .union([zod_1.z.string().max(1_000_000), zod_1.z.array(exports.stepInputSchema).max(1000)])
24
+ .describe('The flow\'s own steps: a YAML list (`- tapOn: "Login"` lines) or an array of steps — objects ({ tapOn: "Login" }, { assertVisible: { text: "Welcome", label: "Welcome shown" } }), bare command names ("launchApp") or YAML snippets. Step reference: callman://docs/ui-test-steps');
25
+ exports.datasetSourceSchema = (0, strict_js_1.strictObject)({
26
+ kind: zod_1.z.literal("db").optional(),
27
+ connection: zod_1.z.string().min(1).max(200).describe("Workspace connection id or name (list_connections)"),
28
+ query: zod_1.z.string().min(1).max(10_000),
29
+ column: zod_1.z.string().max(200).optional().describe("Column to read (default: the first)"),
30
+ }, "dataset source field");
31
+ exports.runSettingsSchema = (0, strict_js_1.strictObject)({
32
+ screenshotOnSuccess: zod_1.z.boolean().nullable().optional(),
33
+ screenshotOnFailure: zod_1.z.boolean().nullable().optional(),
34
+ autoRetryTransient: zod_1.z.boolean().nullable().optional(),
35
+ captureNetwork: zod_1.z.boolean().nullable().optional(),
36
+ }, "run setting").describe("Per-flow run toggles; null = the shared default");
37
+ exports.headerArg = zod_1.z
38
+ .record(zod_1.z.unknown())
39
+ .optional()
40
+ .describe("Extra header keys (appActivity, deviceName, env, tags, onFlowStart …; null removes one). url/appId have their own arguments");
41
+ exports.groupArg = zod_1.z
42
+ .string()
43
+ .max(200)
44
+ .optional()
45
+ .describe('On-prem: the group that owns it (name or id, or "personal"). Needed when you belong to several groups; ignored on cloud');
46
+ const resolveGroupId = async (ctx, ws, ref) => {
47
+ if (!ref)
48
+ return {};
49
+ if (ref.trim().toLowerCase() === "personal")
50
+ return { id: "personal" };
51
+ const { items } = await ctx.api.listMyScenarioGroups(ws);
52
+ if (items.length === 0)
53
+ return { note: "Groups exist only on-prem — `group` was ignored." };
54
+ return { id: (0, resolve_js_1.resolveRef)("group", ref, items).item.id };
55
+ };
56
+ exports.resolveGroupId = resolveGroupId;
57
+ const lintContext = async (ctx, workspaceId) => {
58
+ const [flows, connections] = await Promise.all([
59
+ (0, lookups_js_1.loadUiTests)(ctx),
60
+ // A token without requests:read / scenarios:read cannot list connections —
61
+ // then the connection check is skipped, not failed.
62
+ (0, lookups_js_1.loadConnections)(ctx, workspaceId).catch(() => null),
63
+ ]);
64
+ return { flowNames: [...new Set(flows.map((flow) => flow.name))], connections };
65
+ };
66
+ exports.lintContext = lintContext;
67
+ const lintFlow = (content, platform, context, datasetKeys) => (0, callman_core_1.lintUiTestFlow)(content, {
68
+ platform,
69
+ knownFlowNames: context.flowNames,
70
+ ...(context.connections ? { knownConnections: context.connections.flatMap((connection) => [connection.name, connection.id]) } : {}),
71
+ datasetKeys,
72
+ });
73
+ exports.lintFlow = lintFlow;
74
+ const lintView = (lint, context) => ({
75
+ ok: lint.ok,
76
+ errors: lint.errors,
77
+ warnings: lint.warnings,
78
+ ...(context && !context.connections ? { note: "Connection names were not checked (the token cannot list connections)." } : {}),
79
+ });
80
+ exports.lintView = lintView;
81
+ const refuseLintErrors = (lint, force, content) => {
82
+ if (lint.ok || force)
83
+ return;
84
+ throw new errors_js_1.ToolInputError("UI_TEST_LINT_FAILED", `The flow has ${lint.errors.length} error${lint.errors.length === 1 ? "" : "s"} the engine would trip on — fix them (callman://docs/ui-test-steps), or pass force: true to save anyway.`, {
85
+ errors: lint.errors,
86
+ warnings: lint.warnings,
87
+ content: (0, truncate_js_1.clipBytes)(content, 8_000).text,
88
+ });
89
+ };
90
+ exports.refuseLintErrors = refuseLintErrors;
91
+ const resolveDatasetSources = async (ctx, ws, sources, current = {}) => {
92
+ const out = { ...current };
93
+ const entries = Object.entries(sources);
94
+ const connections = entries.some(([, source]) => source) ? await (0, lookups_js_1.loadConnections)(ctx, ws) : [];
95
+ for (const [key, source] of entries) {
96
+ if (!source) {
97
+ delete out[key];
98
+ continue;
99
+ }
100
+ const connection = (0, resolve_js_1.resolveRef)("connection", source.connection, connections, { describe: (entry) => ({ type: entry.type }) }).item;
101
+ if (connection.type !== "database") {
102
+ throw new errors_js_1.ToolInputError("CONNECTION_WRONG_TYPE", `Dataset source "${key}": "${connection.name}" is a ${connection.type} connection — a dataset source needs a database connection.`);
103
+ }
104
+ out[key] = { kind: "db", connection: connection.id, query: source.query, ...(source.column ? { column: source.column } : {}) };
105
+ }
106
+ return out;
107
+ };
108
+ exports.resolveDatasetSources = resolveDatasetSources;
109
+ // ── the full-state upsert ──────────────────────────────────────────────
110
+ const UPSERT_FIELDS = ["name", "description", "platform", "content", "testType", "severity", "tags", "folderId", "dataset", "datasetSources", "dismissPopups", "browserKind", "headless", "runSettings"];
111
+ // The backend accepts null for "clear" on description / folderId / browserKind /
112
+ // headless / runSettings, but testType and severity are optional, not nullable.
113
+ const NOT_NULLABLE = new Set(["testType", "severity"]);
114
+ const upsertBodyOf = (flow) => {
115
+ const body = { clientId: flow.clientId };
116
+ for (const field of UPSERT_FIELDS) {
117
+ const value = flow[field];
118
+ if (value === undefined || (value === null && NOT_NULLABLE.has(field)))
119
+ continue;
120
+ body[field] = value;
121
+ }
122
+ return body;
123
+ };
124
+ exports.upsertBodyOf = upsertBodyOf;
125
+ /** Refuses masked values the CALLER supplied. Masks read back from the flow
126
+ * itself may be sent unchanged: the backend keeps the stored secret when a
127
+ * value equals the mask of what is stored under that key. */
128
+ const refuseMaskedDataset = (dataset) => {
129
+ const keys = (0, summarize_js_1.maskedDatasetKeys)(dataset);
130
+ if (keys.length === 0)
131
+ return;
132
+ throw new errors_js_1.ToolInputError("MASKED_VALUE", `Masked values cannot be saved (dataset keys: ${keys.join(", ")}). Supply the real values, or leave those keys out to keep what is stored.`, { keys });
133
+ };
134
+ exports.refuseMaskedDataset = refuseMaskedDataset;
135
+ const saveFlow = async (ctx, ws, body, options = {}) => {
136
+ try {
137
+ const flow = await ctx.api.upsertUiTest(ws, { ...body, ...(options.expectedVersion !== undefined ? { expectedVersion: options.expectedVersion } : {}), ...(options.force ? { force: true } : {}) });
138
+ ctx.invalidate("uitests:");
139
+ return { flow };
140
+ }
141
+ catch (error) {
142
+ if (error instanceof errors_js_1.CallmanApiError && error.code === "UI_TEST_SCENARIO_VERSION_CONFLICT") {
143
+ const details = (error.details ?? {});
144
+ return {
145
+ conflict: {
146
+ conflict: true,
147
+ code: error.code,
148
+ message: error.message,
149
+ currentVersion: details.currentVersion ?? details.currentScenario?.version ?? null,
150
+ hint: "Someone saved the flow since you read it. Re-read it (get_ui_test), reapply your change and retry with expectedVersion = currentVersion — or force: true to overwrite.",
151
+ },
152
+ };
153
+ }
154
+ throw error;
155
+ }
156
+ };
157
+ exports.saveFlow = saveFlow;
158
+ const conflictResult = (conflict) => (0, shared_js_1.ok)(conflict);
159
+ exports.conflictResult = conflictResult;
160
+ // ── views ──────────────────────────────────────────────────────────────
161
+ const connectionNames = (connections) => new Map((connections ?? []).map((connection) => [connection.id, connection.name]));
162
+ exports.connectionNames = connectionNames;
163
+ const flowDetailView = async (ctx, flow, lint, context, options) => {
164
+ const folders = await (0, lookups_js_1.loadUiTestFolders)(ctx).catch(() => []);
165
+ const folder = flow.folderId ? folders.find((entry) => entry.id === flow.folderId) : undefined;
166
+ const names = (0, exports.connectionNames)(context.connections);
167
+ const workspace = options.workspaces.find((entry) => entry.id === flow.workspaceId);
168
+ const content = flow.content ?? "";
169
+ const clipped = (0, truncate_js_1.clipBytes)(content, truncate_js_1.LIMITS.SCENARIO_FULL);
170
+ const masked = (0, summarize_js_1.maskedDatasetKeys)(flow.dataset);
171
+ return {
172
+ id: flow.clientId,
173
+ name: flow.name,
174
+ platform: flow.platform,
175
+ workspace: { id: flow.workspaceId, name: workspace?.name ?? null },
176
+ folder: folder ? { id: folder.id, path: folder.path } : null,
177
+ ...(flow.groupName ? { group: flow.groupName } : {}),
178
+ description: flow.description ?? null,
179
+ testType: flow.testType ?? null,
180
+ severity: flow.severity ?? null,
181
+ tags: flow.tags ?? [],
182
+ version: flow.version ?? null,
183
+ ...(flow.reviewStatus ? { reviewStatus: flow.reviewStatus, approvedVersion: flow.approvedVersion ?? null, activeVersionNumber: flow.activeVersionNumber ?? null, ...(flow.reviewNote ? { reviewNote: flow.reviewNote } : {}) } : {}),
184
+ header: lint.header,
185
+ settings: (0, summarize_js_1.settingsView)(flow),
186
+ dataset: flow.dataset ?? {},
187
+ ...(masked.length > 0 ? { datasetMasked: masked } : {}),
188
+ datasetSources: Object.fromEntries(Object.entries(flow.datasetSources ?? {}).map(([key, source]) => [key, { ...source, ...(names.get(source.connection) ? { connectionName: names.get(source.connection) } : {}) }])),
189
+ preconditions: lint.preconditions,
190
+ steps: lint.steps,
191
+ lint: (0, exports.lintView)(lint, context),
192
+ ...(options.includeContent === false ? {} : { content: clipped.text, ...(clipped.truncated ? { contentTruncated: true, contentBytes: clipped.totalBytes } : {}) }),
193
+ ...(masked.length > 0 && options.reveal ? { note: "Secret dataset values stayed masked: the token lacks environments:reveal." } : {}),
194
+ };
195
+ };
196
+ exports.flowDetailView = flowDetailView;
197
+ const workspaceIdOf = (flow) => {
198
+ if (!flow.workspaceId)
199
+ throw new errors_js_1.ToolInputError("UI_TEST_WORKSPACE_UNKNOWN", `The server did not say which workspace "${flow.name}" is in.`);
200
+ return flow.workspaceId;
201
+ };
202
+ exports.workspaceIdOf = workspaceIdOf;