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