@flowriki/agent 0.1.0-beta.33 → 0.1.0-beta.36
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/dist/{chunk-3UUCI66O.js → chunk-N3P4O3OH.js} +2 -1
- package/dist/cli.js +512 -59
- package/dist/engine.js +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import os from "os";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { createRequire } from "module";
|
|
8
8
|
import { spawnSync } from "child_process";
|
|
9
|
-
var AGENT_VERSION = "0.1.0-beta.
|
|
9
|
+
var AGENT_VERSION = "0.1.0-beta.36";
|
|
10
10
|
async function prepareBrowsers() {
|
|
11
11
|
if (browsersReady()) {
|
|
12
12
|
return { ok: true, already: true, message: "chromium is already installed" };
|
|
@@ -11278,6 +11278,7 @@ export {
|
|
|
11278
11278
|
closeRunSession,
|
|
11279
11279
|
replayFlow,
|
|
11280
11280
|
executeStep,
|
|
11281
|
+
replayCase,
|
|
11281
11282
|
fieldNames,
|
|
11282
11283
|
helperRunner,
|
|
11283
11284
|
resolveCheckouts,
|
package/dist/cli.js
CHANGED
|
@@ -15,13 +15,14 @@ import {
|
|
|
15
15
|
mirrorCheckTargets,
|
|
16
16
|
openRunSession,
|
|
17
17
|
prepareBrowsers,
|
|
18
|
+
replayCase,
|
|
18
19
|
replayFlow,
|
|
19
20
|
resolveCheckouts,
|
|
20
21
|
resolveService,
|
|
21
22
|
runtimeValue,
|
|
22
23
|
start,
|
|
23
24
|
status
|
|
24
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-N3P4O3OH.js";
|
|
25
26
|
|
|
26
27
|
// src/cli.ts
|
|
27
28
|
import { parseArgs } from "util";
|
|
@@ -39,7 +40,7 @@ async function handle(message, tools, info) {
|
|
|
39
40
|
const version = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
|
|
40
41
|
return reply({
|
|
41
42
|
protocolVersion: version,
|
|
42
|
-
capabilities: { tools: { listChanged:
|
|
43
|
+
capabilities: { tools: { listChanged: true } },
|
|
43
44
|
serverInfo: { name: info.name, version: info.version },
|
|
44
45
|
instructions: info.instructions
|
|
45
46
|
});
|
|
@@ -95,6 +96,10 @@ function serve(tools, info, io = {}) {
|
|
|
95
96
|
const task = handle(message, tools, info).then((answer) => {
|
|
96
97
|
if (answer) output.write(`${JSON.stringify(answer)}
|
|
97
98
|
`);
|
|
99
|
+
if (message.method === "notifications/initialized") {
|
|
100
|
+
output.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}
|
|
101
|
+
`);
|
|
102
|
+
}
|
|
98
103
|
});
|
|
99
104
|
pending.add(task);
|
|
100
105
|
void task.finally(() => pending.delete(task));
|
|
@@ -168,17 +173,73 @@ var ServiceClient = class {
|
|
|
168
173
|
{ method: "DELETE" }
|
|
169
174
|
);
|
|
170
175
|
}
|
|
171
|
-
saveFlow(projectId, title, flow) {
|
|
176
|
+
saveFlow(projectId, title, flow, where = {}, secretValues = {}) {
|
|
172
177
|
return this.call(
|
|
173
178
|
`/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
|
|
174
|
-
{ method: "POST", body: JSON.stringify({ title, flow }) }
|
|
179
|
+
{ method: "POST", body: JSON.stringify({ title, flow, ...where, secretValues }) }
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
/** A sign-in made from a recorded login flow. */
|
|
183
|
+
createSignIn(projectId, input) {
|
|
184
|
+
return this.call(
|
|
185
|
+
`/api/agent/sign-ins?projectId=${encodeURIComponent(projectId)}`,
|
|
186
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
/** An account for a sign-in; secret values are sealed by the service and
|
|
190
|
+
* never come back. */
|
|
191
|
+
createAccount(projectId, input) {
|
|
192
|
+
return this.call(
|
|
193
|
+
`/api/agent/accounts?projectId=${encodeURIComponent(projectId)}`,
|
|
194
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
/** A helper, its secrets sealed by the service. */
|
|
198
|
+
createHelper(projectId, input) {
|
|
199
|
+
return this.call(
|
|
200
|
+
`/api/agent/helpers?projectId=${encodeURIComponent(projectId)}`,
|
|
201
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
/** What a new flow starts after: the flows, in run order, and their steps. */
|
|
205
|
+
flowsAfter(projectId, after) {
|
|
206
|
+
return this.call(
|
|
207
|
+
`/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
|
|
208
|
+
{ method: "POST", body: JSON.stringify({ after }) }
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
/** Build a test case from flows — as a dry run, or saved. */
|
|
212
|
+
buildCase(projectId, spec, dryRun) {
|
|
213
|
+
return this.call(`/api/agent/cases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ ...spec, dryRun }) });
|
|
214
|
+
}
|
|
215
|
+
/** Gather Claude's open branches here into one. */
|
|
216
|
+
gatherChanges(projectId, opts = {}) {
|
|
217
|
+
return this.call(
|
|
218
|
+
`/api/agent/changes/gather?projectId=${encodeURIComponent(projectId)}`,
|
|
219
|
+
{ method: "POST", body: JSON.stringify(opts) }
|
|
175
220
|
);
|
|
176
221
|
}
|
|
177
222
|
};
|
|
178
223
|
|
|
179
224
|
// src/mcp/session.ts
|
|
180
225
|
var MAX_TREE = 14e3;
|
|
226
|
+
var MAX_STEP_VIEW = 6e3;
|
|
227
|
+
function topLevelBlocks(tree) {
|
|
228
|
+
const blocks = [];
|
|
229
|
+
for (const line of tree.split("\n")) {
|
|
230
|
+
if (line.startsWith("- ") || blocks.length === 0) blocks.push([line]);
|
|
231
|
+
else blocks[blocks.length - 1].push(line);
|
|
232
|
+
}
|
|
233
|
+
const named = (b, kinds) => kinds.some((k) => b[0].startsWith(`- ${k}`));
|
|
234
|
+
return {
|
|
235
|
+
dialogs: blocks.filter((b) => named(b, ["dialog", "alertdialog"])).map((b) => b.join("\n")),
|
|
236
|
+
navigation: blocks.find((b) => named(b, ["navigation", "banner"]))?.join("\n") ?? null
|
|
237
|
+
};
|
|
238
|
+
}
|
|
181
239
|
var STEP_TIMEOUT_MS = 1e4;
|
|
240
|
+
var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|one[-_\s]?time[-_\s]?(code|pass)|security[-_\s]?(code|answer)|cvv|cvc|\bpin\b/i;
|
|
241
|
+
var TYPED = "typed_";
|
|
242
|
+
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
182
243
|
var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
|
|
183
244
|
var isAction = (value) => ACTIONS.includes(String(value));
|
|
184
245
|
var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
|
|
@@ -202,12 +263,18 @@ function labelTree(tree, fields) {
|
|
|
202
263
|
for (const f of fields) out = out.split(`- ${f.role} ${JSON.stringify(f.hint)}`).join(`- ${f.role} ${JSON.stringify(f.label)}`);
|
|
203
264
|
return out;
|
|
204
265
|
}
|
|
266
|
+
function describeRows(rows) {
|
|
267
|
+
return rows.map((r) => [
|
|
268
|
+
`Row ${r.row}: "${r.flow}" (${r.browser})`,
|
|
269
|
+
...r.checks.length ? r.checks.map((c) => ` ${c.on ? "[on] " : "[off]"} ${c.n}. ${c.check}`) : [" (no checks in this flow)"]
|
|
270
|
+
].join("\n")).join("\n");
|
|
271
|
+
}
|
|
205
272
|
function describe(step) {
|
|
206
273
|
const target = step.name ? `${step.role || "element"} "${step.name}"` : step.role || "";
|
|
207
274
|
const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
|
|
208
275
|
switch (step.type) {
|
|
209
276
|
case "fill":
|
|
210
|
-
return `type "${step.value}" into ${target}${where}`;
|
|
277
|
+
return step.secret ? `type a secret (\u2022\u2022\u2022\u2022\u2022\u2022) into ${target}${where}` : `type "${step.value}" into ${target}${where}`;
|
|
211
278
|
case "select":
|
|
212
279
|
return `choose "${step.value}" in ${target}${where}`;
|
|
213
280
|
case "check":
|
|
@@ -238,8 +305,24 @@ var AuthoringSession = class {
|
|
|
238
305
|
page = null;
|
|
239
306
|
values = /* @__PURE__ */ new Map();
|
|
240
307
|
resolvers = null;
|
|
308
|
+
lastTree = null;
|
|
309
|
+
/** Values typed into secret fields, by their `typed:` reference. */
|
|
310
|
+
typed = /* @__PURE__ */ new Map();
|
|
241
311
|
draft = null;
|
|
242
312
|
catalog = null;
|
|
313
|
+
/** Text going back to Claude, with every secret typed here masked — a
|
|
314
|
+
* password box's value is in the page's tree, and a careless form puts it
|
|
315
|
+
* in the address. */
|
|
316
|
+
redact(text2) {
|
|
317
|
+
let out = text2;
|
|
318
|
+
for (const value of this.typed.values()) {
|
|
319
|
+
if (value.length < 3) continue;
|
|
320
|
+
out = out.split(value).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
321
|
+
const encoded = encodeURIComponent(value);
|
|
322
|
+
if (encoded !== value) out = out.split(encoded).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
243
326
|
get open() {
|
|
244
327
|
return this.run !== null;
|
|
245
328
|
}
|
|
@@ -252,11 +335,11 @@ var AuthoringSession = class {
|
|
|
252
335
|
* helper or sign-in appears, never kept beyond this process. */
|
|
253
336
|
async prepare(extra = []) {
|
|
254
337
|
const draft = this.draft;
|
|
255
|
-
const prepared = await this.service.prepare(draft.projectId, [...draft.steps, ...extra], draft.signIn ?? null);
|
|
338
|
+
const prepared = await this.service.prepare(draft.projectId, withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]), draft.signIn ?? null);
|
|
256
339
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
257
340
|
this.resolvers = {
|
|
258
341
|
resolveAuth: authenticationResolver(prepared.authentications),
|
|
259
|
-
resolveSecret: (ref) => prepared.secrets[ref],
|
|
342
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
|
|
260
343
|
...runHelper ? { runHelper } : {}
|
|
261
344
|
};
|
|
262
345
|
return this.resolvers;
|
|
@@ -272,6 +355,8 @@ var AuthoringSession = class {
|
|
|
272
355
|
this.catalog = catalog;
|
|
273
356
|
this.draft = { ...input, steps: [] };
|
|
274
357
|
this.values = /* @__PURE__ */ new Map();
|
|
358
|
+
this.typed = /* @__PURE__ */ new Map();
|
|
359
|
+
this.lastTree = null;
|
|
275
360
|
const base = this.baseUrl();
|
|
276
361
|
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
277
362
|
this.page = this.run.page;
|
|
@@ -300,23 +385,74 @@ var AuthoringSession = class {
|
|
|
300
385
|
} else {
|
|
301
386
|
await this.page.goto(new URL(input.startPath || "/", base).href, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
302
387
|
}
|
|
388
|
+
if (input.preparation?.length) {
|
|
389
|
+
const resolvers = await this.prepare(input.preparation);
|
|
390
|
+
for (const step of input.preparation) {
|
|
391
|
+
try {
|
|
392
|
+
this.page = await executeStep(this.page, step, {
|
|
393
|
+
pages: this.run.pages,
|
|
394
|
+
...resolvers,
|
|
395
|
+
values: this.values,
|
|
396
|
+
baseUrl: base,
|
|
397
|
+
timeoutMs: 2e4,
|
|
398
|
+
navigationTimeoutMs: 45e3
|
|
399
|
+
});
|
|
400
|
+
} catch (error) {
|
|
401
|
+
throw new Error(`The flows this one starts after did not finish, at "${step.name || step.type}": ${error.message.split("\n")[0]}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
await this.settle();
|
|
405
|
+
}
|
|
303
406
|
return this.snapshot();
|
|
304
407
|
}
|
|
305
408
|
/** The page as Claude reads it: where it is, and what is on it by role and name. */
|
|
306
409
|
async snapshot() {
|
|
410
|
+
return this.view(true);
|
|
411
|
+
}
|
|
412
|
+
/** WHAT CHANGED, after a step — not the whole page again.
|
|
413
|
+
*
|
|
414
|
+
* Every step used to answer with the full tree, which on a long storefront
|
|
415
|
+
* is twenty thousand characters repeated forty times to say "the box now
|
|
416
|
+
* has text in it". So a step answers with what it is about: an open dialog
|
|
417
|
+
* (with the navigation, which says who is signed in), or the lines that
|
|
418
|
+
* appeared and disappeared — and only after a real navigation, when most of
|
|
419
|
+
* the page is new, the page itself, capped. read_page is always the whole
|
|
420
|
+
* thing. */
|
|
421
|
+
async view(full) {
|
|
307
422
|
const { page, draft } = this.requireOpen();
|
|
308
423
|
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
309
|
-
|
|
424
|
+
const tree = labelTree(
|
|
310
425
|
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
311
426
|
await fieldNames(page)
|
|
312
427
|
);
|
|
313
|
-
if (tree.length > MAX_TREE) tree = `${tree.slice(0, MAX_TREE)}
|
|
314
|
-
\u2026 (cut \u2014 the page is long; act on what is above, or scroll with press "PageDown")`;
|
|
315
428
|
const title = await page.title().catch(() => "");
|
|
316
|
-
|
|
429
|
+
const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
|
|
317
430
|
Steps so far: ${draft.steps.length}
|
|
318
431
|
|
|
319
|
-
|
|
432
|
+
`;
|
|
433
|
+
const before = this.lastTree;
|
|
434
|
+
this.lastTree = tree;
|
|
435
|
+
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
436
|
+
\u2026 (cut \u2014 read_page shows the whole page)` : t;
|
|
437
|
+
if (full || before === null) return head + cap(tree, MAX_TREE);
|
|
438
|
+
const { dialogs, navigation } = topLevelBlocks(tree);
|
|
439
|
+
if (dialogs.length) {
|
|
440
|
+
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog and the navigation \u2014 read_page shows the whole page)";
|
|
441
|
+
}
|
|
442
|
+
const was = new Set(before.split("\n"));
|
|
443
|
+
const now = new Set(tree.split("\n"));
|
|
444
|
+
const added = tree.split("\n").filter((l) => !was.has(l));
|
|
445
|
+
const removed = before.split("\n").filter((l) => !now.has(l));
|
|
446
|
+
if (added.length === 0 && removed.length === 0) return head + "(the page did not change \u2014 read_page shows it)";
|
|
447
|
+
if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
|
|
448
|
+
return head + [
|
|
449
|
+
...navigation ? [navigation, ""] : [],
|
|
450
|
+
added.length ? `Appeared:
|
|
451
|
+
${added.join("\n")}` : "",
|
|
452
|
+
removed.length ? `Gone:
|
|
453
|
+
${removed.join("\n")}` : "",
|
|
454
|
+
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
455
|
+
].filter(Boolean).join("\n");
|
|
320
456
|
}
|
|
321
457
|
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
322
458
|
async act(input) {
|
|
@@ -329,13 +465,17 @@ ${tree}`;
|
|
|
329
465
|
throw new Error(`${input.action} needs a value`);
|
|
330
466
|
}
|
|
331
467
|
const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
468
|
+
const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
|
|
469
|
+
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
470
|
+
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
332
471
|
const step = RecordedStepSchema.parse({
|
|
333
|
-
id
|
|
472
|
+
id,
|
|
334
473
|
type: input.action,
|
|
335
474
|
...target,
|
|
336
475
|
...scopeOf(input.within),
|
|
337
|
-
value: input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
|
|
338
|
-
secret
|
|
476
|
+
value: secret ? "" : input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
|
|
477
|
+
secret,
|
|
478
|
+
...secret ? { secretRef: `${TYPED}${id}` } : {},
|
|
339
479
|
legacySelector: "",
|
|
340
480
|
proposals: [],
|
|
341
481
|
// A path is the application's own, so the step replays on whichever
|
|
@@ -348,19 +488,21 @@ ${tree}`;
|
|
|
348
488
|
values: this.values,
|
|
349
489
|
baseUrl: this.baseUrl(),
|
|
350
490
|
...this.resolvers ?? {},
|
|
491
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
|
|
351
492
|
timeoutMs: STEP_TIMEOUT_MS,
|
|
352
493
|
navigationTimeoutMs: 45e3
|
|
353
494
|
});
|
|
354
495
|
} catch (error) {
|
|
496
|
+
if (secret) this.typed.delete(`${TYPED}${id}`);
|
|
355
497
|
throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
|
|
356
498
|
|
|
357
|
-
${await this.
|
|
499
|
+
${await this.view(false)}`);
|
|
358
500
|
}
|
|
359
501
|
await this.settle();
|
|
360
502
|
draft.steps.push(step);
|
|
361
503
|
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
362
504
|
|
|
363
|
-
${await this.
|
|
505
|
+
${await this.view(false)}`;
|
|
364
506
|
}
|
|
365
507
|
/** Check something about the page now, and keep the check if it holds. */
|
|
366
508
|
async check(input) {
|
|
@@ -437,7 +579,7 @@ ${await this.snapshot()}`;
|
|
|
437
579
|
draft.steps.push(step);
|
|
438
580
|
return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got.value}" (${got.detail}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
|
|
439
581
|
|
|
440
|
-
${await this.
|
|
582
|
+
${await this.view(false)}` : "");
|
|
441
583
|
}
|
|
442
584
|
list() {
|
|
443
585
|
const draft = this.draft;
|
|
@@ -465,6 +607,7 @@ ${this.list()}`;
|
|
|
465
607
|
const resolvers = await this.prepare();
|
|
466
608
|
const steps = [
|
|
467
609
|
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: { ...draft.signIn, from: "loginPage" }, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
610
|
+
...draft.preparation ?? [],
|
|
468
611
|
...draft.steps
|
|
469
612
|
];
|
|
470
613
|
const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
|
|
@@ -473,25 +616,83 @@ ${this.list()}`;
|
|
|
473
616
|
${lines.join("\n")}`;
|
|
474
617
|
}
|
|
475
618
|
/** Save the draft as a flow on a new branch, for a person to review. */
|
|
476
|
-
async save(title) {
|
|
619
|
+
async save(title, where = {}) {
|
|
477
620
|
const { draft } = this.requireOpen();
|
|
478
621
|
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
479
622
|
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
480
623
|
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
481
624
|
}
|
|
482
|
-
const
|
|
625
|
+
const checksOff = draft.steps.map((step) => step.type === "assert" ? { ...step, proposals: step.proposals.map((p) => ({ ...p, enabled: false })) } : step);
|
|
626
|
+
const checkCount = checksOff.filter((step) => step.type === "assert").length;
|
|
627
|
+
const secretValues = {};
|
|
628
|
+
for (const step of checksOff) {
|
|
629
|
+
const value = step.secretRef ? this.typed.get(step.secretRef) : void 0;
|
|
630
|
+
if (value !== void 0) secretValues[step.id] = value;
|
|
631
|
+
}
|
|
632
|
+
const saved = await this.service.saveFlow(draft.projectId, title || "Tests written with Claude", {
|
|
483
633
|
name: draft.name,
|
|
484
634
|
...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
|
|
485
635
|
startPath: draft.startPath,
|
|
486
|
-
steps:
|
|
487
|
-
...draft.signIn ? { signIn: draft.signIn } : {}
|
|
488
|
-
|
|
636
|
+
steps: withoutTyped(checksOff),
|
|
637
|
+
...draft.signIn ? { signIn: draft.signIn } : {},
|
|
638
|
+
...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
|
|
639
|
+
}, where, secretValues);
|
|
489
640
|
const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
|
|
641
|
+
const what = saved.replaced ? `Replaced the earlier "${draft.name}"` : `Saved "${draft.name}"`;
|
|
642
|
+
const sealed = Object.keys(secretValues).length;
|
|
643
|
+
const vault = sealed ? ` ${sealed} secret value${sealed === 1 ? " was" : "s were"} put in the project's vault, not the flow.` : "";
|
|
644
|
+
const where_ = saved.newBranch ? `on a new branch, ${saved.branch}` : `on Claude's working branch, ${saved.branch}`;
|
|
490
645
|
const others = await this.service.agentChanges(draft.projectId).then((r) => r.changes.filter((c) => c.id !== saved.changeId)).catch(() => []);
|
|
491
646
|
const note = others.length ? `
|
|
492
|
-
Claude
|
|
493
|
-
|
|
647
|
+
Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
|
|
648
|
+
const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
|
|
649
|
+
return `${what} ${where_}.${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
|
|
494
650
|
Review and publish it here: ${review}${note}`;
|
|
651
|
+
}
|
|
652
|
+
/** A test case built from flows, without saving: its rows and every check,
|
|
653
|
+
* numbered, with which are on — what save_case's `checks` refer to. */
|
|
654
|
+
async previewCase(projectId, spec) {
|
|
655
|
+
const built = await this.service.buildCase(projectId, spec, true);
|
|
656
|
+
return `"${spec.name}" in ${spec.folder} would be:
|
|
657
|
+
${describeRows(built.rows)}`;
|
|
658
|
+
}
|
|
659
|
+
/** RUN THE CASE, THEN SAVE IT. Built as a dry run, replayed in a fresh
|
|
660
|
+
* browser exactly as a test run does it, and saved onto Claude's working
|
|
661
|
+
* branch only when every row passed. */
|
|
662
|
+
async saveCase(projectId, spec, catalog) {
|
|
663
|
+
const built = await this.service.buildCase(projectId, spec, true);
|
|
664
|
+
const testCase = built.testCase;
|
|
665
|
+
const steps = testCase.occurrences.flatMap((o) => o.steps);
|
|
666
|
+
const prepared = await this.service.prepare(projectId, steps, null);
|
|
667
|
+
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
668
|
+
const base = catalog.baseUrl;
|
|
669
|
+
if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
|
|
670
|
+
const outcome = await replayCase({
|
|
671
|
+
occurrences: testCase.occurrences,
|
|
672
|
+
...testCase.actors ? { actors: testCase.actors } : {},
|
|
673
|
+
...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
|
|
674
|
+
baseUrl: base,
|
|
675
|
+
headless: true,
|
|
676
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
677
|
+
resolveSecret: (ref) => prepared.secrets[ref],
|
|
678
|
+
...runHelper ? { runHelper } : {}
|
|
679
|
+
});
|
|
680
|
+
const lines = outcome.occurrences.map((o, i) => {
|
|
681
|
+
const failed = o.steps.find((st) => st.status === "failed");
|
|
682
|
+
const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
|
|
683
|
+
return `${o.status === "passed" ? "\u2713" : o.status === "failed" ? "\u2717" : "\xB7"} row ${i + 1} \u2014 ${o.name}${why}`;
|
|
684
|
+
});
|
|
685
|
+
if (outcome.status !== "passed") {
|
|
686
|
+
return `The case did not pass, so it was NOT saved.
|
|
687
|
+
${lines.join("\n")}
|
|
688
|
+
|
|
689
|
+
${describeRows(built.rows)}`;
|
|
690
|
+
}
|
|
691
|
+
const saved = await this.service.buildCase(projectId, spec, false);
|
|
692
|
+
const review = `${this.service.origin}${saved.path}`;
|
|
693
|
+
return `The case passed from the start${saved.replaced ? " and replaced the earlier version" : ""}, and is saved on ${saved.newBranch ? "a new branch" : "Claude's working branch"}. Nothing is published yet.
|
|
694
|
+
${lines.join("\n")}
|
|
695
|
+
Review and publish it here: ${review}`;
|
|
495
696
|
}
|
|
496
697
|
async close() {
|
|
497
698
|
const run = this.run;
|
|
@@ -511,43 +712,107 @@ Review and publish it here: ${review}${note}`;
|
|
|
511
712
|
|
|
512
713
|
// src/mcp/tools.ts
|
|
513
714
|
var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
|
|
514
|
-
Every step you take is performed first and kept only if it worked, so
|
|
515
|
-
|
|
516
|
-
and publishes in the Flowriki dashboard \u2014 nothing you do is
|
|
715
|
+
Every step you take is performed first and kept only if it worked, so what you
|
|
716
|
+
save already ran once. Everything goes to ONE working branch per project that a
|
|
717
|
+
person reviews and publishes in the Flowriki dashboard \u2014 nothing you do is
|
|
718
|
+
published.
|
|
517
719
|
|
|
518
720
|
A new project \u2014 "set up Flowriki for this site, in this repository" \u2014 is made
|
|
519
721
|
with create_project; it then becomes the project the other tools work on.
|
|
520
722
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
723
|
+
THE MODEL \u2014 get this right before recording anything:
|
|
724
|
+
- A FLOW is a reusable building block: one piece of a journey ("Open the account
|
|
725
|
+
dialog", "Fill the sign-up form", "Sign in with a wrong password"). A flow can
|
|
726
|
+
START AFTER other flows (start_flow's "after"), so it holds only its own new
|
|
727
|
+
steps and links to the ones before it. A path that behaves differently \u2014 a
|
|
728
|
+
wrong password, a duplicate email \u2014 is its own flow, because the page does
|
|
729
|
+
something different.
|
|
730
|
+
- A flow records checks (add_check) that prove each thing happened, but they
|
|
731
|
+
are SAVED SWITCHED OFF: which ones matter depends on the test.
|
|
732
|
+
- A TEST CASE is the test. It strings flows together as rows (in the same
|
|
733
|
+
browser, or a new one for a second person) and switches on the checks it is
|
|
734
|
+
about. Build it with preview_case, then save_case \u2014 which runs it first.
|
|
735
|
+
- SIGN-IN is shared setup: a flow or case that needs somebody signed in uses a
|
|
736
|
+
sign-in profile from the catalog (start_flow's signIn), never typed login steps.
|
|
737
|
+
None yet? Make it: record the login as a flow that ends with a check proving
|
|
738
|
+
it worked (the account menu, a heading, the URL), then create_sign_in from it
|
|
739
|
+
and create_account for each person who signs in (a customer, an admin). The
|
|
740
|
+
login flow types into its fields; the account supplies the values.
|
|
741
|
+
- HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
|
|
742
|
+
with create_helper when the catalog has none that fits.
|
|
743
|
+
- PASSWORDS AND KEYS never go into a flow or a reply. Typed into a password
|
|
744
|
+
field, a value is kept as a secret and saved to the project's vault; given to
|
|
745
|
+
create_account or create_helper, it is sealed there too and never shown back.
|
|
746
|
+
Only use credentials the person gave you or that the app itself shows for
|
|
747
|
+
testing (a demo login) \u2014 never guess or reuse anyone's real password.
|
|
748
|
+
- FOLDERS do not nest. Name each one after the product area and what is in it,
|
|
749
|
+
the way a tester would look for it \u2014 "Account \u2013 Sign up", "Account \u2013 Sign in",
|
|
750
|
+
"Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
|
|
751
|
+
cases built from them use the same names. Keep one area per folder.
|
|
752
|
+
|
|
753
|
+
HOW TO WORK:
|
|
754
|
+
1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
|
|
755
|
+
branch \u2014 rather than writing it again.
|
|
756
|
+
2. Before recording, say the plan to the person: the folders, the building-block
|
|
757
|
+
flows (and what each starts after), and the test cases (rows + what each
|
|
758
|
+
checks). Then record the blocks, earliest first.
|
|
759
|
+
3. Record with start_flow (pass "after" for the flows it continues from), read
|
|
760
|
+
the page with read_page, and act using the role and name EXACTLY as the page
|
|
761
|
+
shows them. If a name appears more than once, pass "within" with the name of
|
|
762
|
+
the region, dialog or form \u2014 or "dialog" / "navigation".
|
|
763
|
+
4. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
534
764
|
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
535
765
|
catalog, then type {{its saveAs name}} into the field that needs it.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
can go this way; anything a person touched is theirs to delete.
|
|
766
|
+
5. Add checks for what each step proves (a heading, a message, a value, the
|
|
767
|
+
URL). replay_flow, then save_flow. A flow must record at least one check.
|
|
768
|
+
6. Build each test case: preview_case lists its rows and every check, numbered;
|
|
769
|
+
save_case with the numbers to switch on runs the whole case and saves it only
|
|
770
|
+
if it passed. Give the person the review link.
|
|
771
|
+
7. Saving the same name again replaces it on the working branch. Old branches
|
|
772
|
+
of yours? gather_branches puts them into one; discard_branch deletes one \u2014
|
|
773
|
+
only after the person asks or agrees.
|
|
545
774
|
Test data you invent (a new user's email or password for a sign-up) is fine to
|
|
546
775
|
type. For anything that must be NEW on every run \u2014 a sign-up's email or
|
|
547
776
|
username \u2014 use {{unique}}: it is different on every run and the same within
|
|
548
777
|
one, e.g. qa+{{unique}}@ the domain of the project's mailbox helper, so the
|
|
549
778
|
helper can read that address's verification email with {"email": "qa+{{unique}}@\u2026"}.
|
|
550
779
|
A test that types a fixed new email passes once and fails every run after.`;
|
|
780
|
+
var CASE_INPUT = {
|
|
781
|
+
type: "object",
|
|
782
|
+
properties: {
|
|
783
|
+
name: { type: "string", description: 'The test case: the scenario it proves, e.g. "Wrong password is refused".' },
|
|
784
|
+
folder: { type: "string", description: 'The case folder \u2014 area and topic, e.g. "Account \u2013 Sign in". Made if new.' },
|
|
785
|
+
description: { type: "string" },
|
|
786
|
+
rows: {
|
|
787
|
+
type: "array",
|
|
788
|
+
description: "The flows to run, in order. Each row runs in the same browser as the row above unless newBrowser is true (a second person).",
|
|
789
|
+
items: {
|
|
790
|
+
type: "object",
|
|
791
|
+
properties: {
|
|
792
|
+
flow: { type: "string", description: "A flow's name or id from project_catalog." },
|
|
793
|
+
newBrowser: { type: "boolean" },
|
|
794
|
+
checks: {
|
|
795
|
+
description: `Which of this row's checks to switch on: their numbers from preview_case, or "all". The rest stay off.`,
|
|
796
|
+
anyOf: [{ type: "array", items: { type: "number" } }, { type: "string", enum: ["all"] }]
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
required: ["flow"],
|
|
800
|
+
additionalProperties: false
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
branch: { type: "string", description: "Optional: save onto this branch (chg_\u2026) instead of the working one." },
|
|
804
|
+
projectId: { type: "string" }
|
|
805
|
+
},
|
|
806
|
+
required: ["name", "folder", "rows"],
|
|
807
|
+
additionalProperties: false
|
|
808
|
+
};
|
|
809
|
+
var caseSpec = (args) => ({
|
|
810
|
+
name: String(args["name"] ?? ""),
|
|
811
|
+
folder: String(args["folder"] ?? ""),
|
|
812
|
+
...args["description"] ? { description: String(args["description"]) } : {},
|
|
813
|
+
rows: Array.isArray(args["rows"]) ? args["rows"] : [],
|
|
814
|
+
...args["branch"] ? { changeId: String(args["branch"]) } : {}
|
|
815
|
+
});
|
|
551
816
|
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
552
817
|
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
553
818
|
function authoringTools(service, session) {
|
|
@@ -569,7 +834,7 @@ function authoringTools(service, session) {
|
|
|
569
834
|
catalogs.set(projectId, catalog);
|
|
570
835
|
return catalog;
|
|
571
836
|
};
|
|
572
|
-
|
|
837
|
+
const tools = [
|
|
573
838
|
{
|
|
574
839
|
name: "list_projects",
|
|
575
840
|
title: "List Flowriki projects",
|
|
@@ -621,11 +886,23 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
621
886
|
Features:${c.features.length ? "" : " none yet"}`,
|
|
622
887
|
...c.features.map((f) => ` - ${f.name} (id ${f.id})${f.description ? ` \u2014 ${f.description}` : ""}
|
|
623
888
|
${flowsBy(f.id)}`),
|
|
889
|
+
...c.draft ? [
|
|
890
|
+
`
|
|
891
|
+
On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet published:`,
|
|
892
|
+
...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
|
|
893
|
+
...c.draft.flows.length ? c.draft.flows.map((f) => ` - flow: ${f.name} (${f.steps} steps, feature ${f.featureId}${f.after?.length ? `, after ${f.after.map((a) => `"${a}"`).join(", ")}` : ""})`) : [" (no flows yet)"],
|
|
894
|
+
...c.draft.caseFolders?.length ? [` New case folders: ${c.draft.caseFolders.map((f) => f.name).join(", ")}`] : [],
|
|
895
|
+
...(c.draft.cases ?? []).map((t) => ` - case: ${t.name} (folder ${t.folderId}) \u2014 rows: ${t.rows.join(" \u2192 ")}`)
|
|
896
|
+
] : [],
|
|
897
|
+
`
|
|
898
|
+
Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
|
|
899
|
+
...(c.caseFolders ?? []).map((f) => ` - ${f.name} (id ${f.id})
|
|
900
|
+
${(c.cases ?? []).filter((t) => t.folderId === f.id).map((t) => ` - ${t.name} \u2014 rows: ${t.rows.join(" \u2192 ")}`).join("\n")}`),
|
|
624
901
|
`
|
|
625
902
|
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
626
|
-
...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId}): ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
|
|
903
|
+
...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId})${s.fields?.length ? ` \u2014 fields ${s.fields.map((f) => `"${f}"`).join(", ")}` : ""}: ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
|
|
627
904
|
`
|
|
628
|
-
Helpers:${c.helpers.length ? "" : " none \u2014
|
|
905
|
+
Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
629
906
|
...c.helpers.map((h) => ` - ${h.name} (id ${h.id}) \u2014 ${h.what}${h.inputs.length ? `; needs ${h.inputs.map((n) => `{{${n}}}`).join(", ")}` : ""}${h.description ? ` \u2014 ${h.description}` : ""}`)
|
|
630
907
|
].filter(Boolean).join("\n"));
|
|
631
908
|
}
|
|
@@ -640,6 +917,11 @@ Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers scr
|
|
|
640
917
|
name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
|
|
641
918
|
feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
|
|
642
919
|
startPath: { type: "string", description: 'Where it starts, relative to the base URL. Default "/".' },
|
|
920
|
+
after: {
|
|
921
|
+
type: "array",
|
|
922
|
+
items: { type: "string" },
|
|
923
|
+
description: "Flows this one starts after (names or ids from project_catalog). They are replayed first, so recording begins where they end; what THEY start after is added too. The saved flow links to them."
|
|
924
|
+
},
|
|
643
925
|
signIn: {
|
|
644
926
|
type: "object",
|
|
645
927
|
description: "Start signed in, with a profile (and optionally an account) from project_catalog.",
|
|
@@ -662,14 +944,18 @@ Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers scr
|
|
|
662
944
|
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
663
945
|
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
664
946
|
}
|
|
947
|
+
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
948
|
+
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
665
949
|
const page = await session.start({
|
|
666
950
|
projectId,
|
|
951
|
+
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
667
952
|
name: str(args["name"]) ?? "Untitled flow",
|
|
668
953
|
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
669
954
|
startPath: str(args["startPath"]) || "/",
|
|
670
955
|
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
671
956
|
}, catalog);
|
|
672
|
-
|
|
957
|
+
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
958
|
+
return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} The browser is open.
|
|
673
959
|
|
|
674
960
|
${page}`);
|
|
675
961
|
}
|
|
@@ -693,7 +979,8 @@ ${page}`);
|
|
|
693
979
|
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
|
|
694
980
|
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
695
981
|
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
696
|
-
value: { type: "string" }
|
|
982
|
+
value: { type: "string" },
|
|
983
|
+
secret: { type: "boolean", description: "fill only: the value is a secret (a password, PIN, key) \u2014 kept out of the flow and saved to the vault. Fields named like a password are secret without this." }
|
|
697
984
|
},
|
|
698
985
|
required: ["action"],
|
|
699
986
|
additionalProperties: false
|
|
@@ -705,7 +992,8 @@ ${page}`);
|
|
|
705
992
|
...str(args["role"]) ? { role: str(args["role"]) } : {},
|
|
706
993
|
...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
|
|
707
994
|
...str(args["within"]) ? { within: str(args["within"]) } : {},
|
|
708
|
-
...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {}
|
|
995
|
+
...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {},
|
|
996
|
+
...typeof args["secret"] === "boolean" ? { secret: args["secret"] } : {}
|
|
709
997
|
}));
|
|
710
998
|
}
|
|
711
999
|
},
|
|
@@ -783,9 +1071,20 @@ ${page}`);
|
|
|
783
1071
|
{
|
|
784
1072
|
name: "save_flow",
|
|
785
1073
|
title: "Save the flow to a branch",
|
|
786
|
-
description: "Save the flow
|
|
787
|
-
inputSchema: {
|
|
788
|
-
|
|
1074
|
+
description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. Returns the review link. Refused if the flow checks nothing.",
|
|
1075
|
+
inputSchema: {
|
|
1076
|
+
type: "object",
|
|
1077
|
+
properties: {
|
|
1078
|
+
title: { type: "string", description: "The branch's title, used only when a new branch is started." },
|
|
1079
|
+
branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
|
|
1080
|
+
newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." }
|
|
1081
|
+
},
|
|
1082
|
+
additionalProperties: false
|
|
1083
|
+
},
|
|
1084
|
+
run: async (args) => text(await session.save(str(args["title"]), {
|
|
1085
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
|
|
1086
|
+
...args["newBranch"] === true ? { newBranch: true } : {}
|
|
1087
|
+
}))
|
|
789
1088
|
},
|
|
790
1089
|
{
|
|
791
1090
|
name: "list_my_branches",
|
|
@@ -800,6 +1099,30 @@ ${page}`);
|
|
|
800
1099
|
return text(changes.map((c) => `- ${c.id} \u2014 "${c.title}" (${new Date(c.createdAt).toISOString().slice(0, 16).replace("T", " ")}) ${service.origin}${c.path}`).join("\n"));
|
|
801
1100
|
}
|
|
802
1101
|
},
|
|
1102
|
+
{
|
|
1103
|
+
name: "gather_branches",
|
|
1104
|
+
title: "Gather Claude's branches into one",
|
|
1105
|
+
description: "Move the flows from Claude's open branches on this project into one branch, and delete the emptied ones \u2014 so the person reviews and publishes once. Only Claude's own, unedited branches; anything a person touched stops it.",
|
|
1106
|
+
inputSchema: {
|
|
1107
|
+
type: "object",
|
|
1108
|
+
properties: {
|
|
1109
|
+
into: { type: "string", description: "The branch to gather into (chg_\u2026). Default: Claude's oldest open branch." },
|
|
1110
|
+
title: { type: "string", description: 'A title for the gathered branch, e.g. "Sign in and sign up tests".' },
|
|
1111
|
+
projectId: { type: "string" }
|
|
1112
|
+
},
|
|
1113
|
+
additionalProperties: false
|
|
1114
|
+
},
|
|
1115
|
+
annotations: { destructiveHint: true },
|
|
1116
|
+
run: async (args) => {
|
|
1117
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1118
|
+
const done = await service.gatherChanges(projectId, {
|
|
1119
|
+
...str(args["into"]) ? { into: str(args["into"]) } : {},
|
|
1120
|
+
...str(args["title"]) ? { title: str(args["title"]) } : {}
|
|
1121
|
+
});
|
|
1122
|
+
catalogs.delete(projectId);
|
|
1123
|
+
return text(`Gathered ${done.gathered.length} branch${done.gathered.length === 1 ? "" : "es"} into "${done.title}" (${done.changeId}). Review and publish it here: ${service.origin}${done.path}`);
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
803
1126
|
{
|
|
804
1127
|
name: "discard_branch",
|
|
805
1128
|
title: "Delete one of Claude's branches",
|
|
@@ -818,6 +1141,124 @@ ${page}`);
|
|
|
818
1141
|
return text(`Deleted ${changeId} and its branch ${deleted}.`);
|
|
819
1142
|
}
|
|
820
1143
|
},
|
|
1144
|
+
{
|
|
1145
|
+
name: "preview_case",
|
|
1146
|
+
title: "Preview a test case",
|
|
1147
|
+
description: "Build a test case from flows without saving or running it: its rows (each flow with what it starts after folded in, and which browser it runs in) and every check, numbered, with which are on. Use the numbers in save_case.",
|
|
1148
|
+
inputSchema: CASE_INPUT,
|
|
1149
|
+
annotations: { readOnlyHint: true },
|
|
1150
|
+
run: async (args) => {
|
|
1151
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1152
|
+
return text(await session.previewCase(projectId, caseSpec(args)));
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
{
|
|
1156
|
+
name: "save_case",
|
|
1157
|
+
title: "Run and save a test case",
|
|
1158
|
+
description: "Build the test case, run it from the start in a fresh browser exactly as a test run will, and \u2014 only if every row passed \u2014 save it onto Claude's working branch. Saving the same name in the same folder again replaces it. Refused if it switches on no checks.",
|
|
1159
|
+
inputSchema: CASE_INPUT,
|
|
1160
|
+
annotations: { openWorldHint: true },
|
|
1161
|
+
run: async (args) => {
|
|
1162
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1163
|
+
const out = await session.saveCase(projectId, caseSpec(args), await catalogOf(projectId, true));
|
|
1164
|
+
catalogs.delete(projectId);
|
|
1165
|
+
return text(out);
|
|
1166
|
+
}
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
name: "create_sign_in",
|
|
1170
|
+
title: "Make a sign-in profile",
|
|
1171
|
+
description: "Turn a saved login flow into a sign-in profile that flows and test cases can start signed in with. The flow must not itself sign in or start after other flows, and must end with a check proving the login worked \u2014 its last check is switched on here. Returns the fields an account fills in. Making the same name again replaces Claude's earlier one.",
|
|
1172
|
+
inputSchema: {
|
|
1173
|
+
type: "object",
|
|
1174
|
+
properties: {
|
|
1175
|
+
projectId: { type: "string" },
|
|
1176
|
+
flow: { type: "string", description: "The login flow's name or id (project_catalog)." },
|
|
1177
|
+
name: { type: "string", description: `What a tester calls this way of signing in, e.g. "Customer sign-in". Default: the flow's name.` },
|
|
1178
|
+
description: { type: "string" },
|
|
1179
|
+
signOutFlow: { type: "string", description: "Optional: a flow that signs out, by name or id." },
|
|
1180
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1181
|
+
},
|
|
1182
|
+
required: ["flow"],
|
|
1183
|
+
additionalProperties: false
|
|
1184
|
+
},
|
|
1185
|
+
run: async (args) => {
|
|
1186
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1187
|
+
const made = await service.createSignIn(projectId, {
|
|
1188
|
+
flow: str(args["flow"]) ?? "",
|
|
1189
|
+
...str(args["name"]) ? { name: str(args["name"]) } : {},
|
|
1190
|
+
...str(args["description"]) ? { description: str(args["description"]) } : {},
|
|
1191
|
+
...str(args["signOutFlow"]) ? { signOutFlow: str(args["signOutFlow"]) } : {},
|
|
1192
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1193
|
+
});
|
|
1194
|
+
catalogs.delete(projectId);
|
|
1195
|
+
return text(`${made.replaced ? "Replaced" : "Made"} the sign-in "${made.name}" (profileId ${made.profileId}) from "${made.flow}", on ${made.newBranch ? "a new branch" : "Claude's working branch"} ${made.branch}.
|
|
1196
|
+
An account fills in: ${made.fields.map((f) => `"${f.label}"${f.secret ? " (secret)" : ""}`).join(", ") || "nothing \u2014 the flow types no values"}.
|
|
1197
|
+
Next: create_account for each person who signs in this way.`);
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
name: "create_account",
|
|
1202
|
+
title: "Make a sign-in account",
|
|
1203
|
+
description: "An account for a sign-in profile: who signs in, and the values its login flow types, by field label (create_sign_in and project_catalog list them). Secret fields are sealed in the project's vault and never shown again. Then start_flow / test cases can use signIn {profileId, accountId}. Making the same name again updates Claude's earlier one.",
|
|
1204
|
+
inputSchema: {
|
|
1205
|
+
type: "object",
|
|
1206
|
+
properties: {
|
|
1207
|
+
projectId: { type: "string" },
|
|
1208
|
+
signIn: { type: "string", description: "The sign-in profile's name or id." },
|
|
1209
|
+
name: { type: "string", description: 'Who this is, e.g. "Demo customer", "Kitchen admin".' },
|
|
1210
|
+
values: { type: "object", additionalProperties: { type: "string" }, description: 'Field label \u2192 value, e.g. {"Email": "demo@example.com", "Password": "\u2026"}.' },
|
|
1211
|
+
environments: { type: "array", items: { type: "string" }, description: "Optional: the environments this account exists on. Default: all." },
|
|
1212
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1213
|
+
},
|
|
1214
|
+
required: ["signIn", "name", "values"],
|
|
1215
|
+
additionalProperties: false
|
|
1216
|
+
},
|
|
1217
|
+
run: async (args) => {
|
|
1218
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1219
|
+
const values = Object.fromEntries(Object.entries(args["values"] && typeof args["values"] === "object" ? args["values"] : {}).map(([k, v]) => [k, String(v)]));
|
|
1220
|
+
const made = await service.createAccount(projectId, {
|
|
1221
|
+
signIn: str(args["signIn"]) ?? "",
|
|
1222
|
+
name: str(args["name"]) ?? "",
|
|
1223
|
+
values,
|
|
1224
|
+
...Array.isArray(args["environments"]) ? { environments: args["environments"].map(String) } : {},
|
|
1225
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1226
|
+
});
|
|
1227
|
+
catalogs.delete(projectId);
|
|
1228
|
+
return text(`${made.replaced ? "Updated" : "Made"} the account "${made.name}" (accountId ${made.accountId}) for "${made.signIn}", on Claude's working branch ${made.branch}. Filled: ${made.filled.join(", ")} \u2014 secrets are in the vault, not the repository.
|
|
1229
|
+
Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
name: "create_helper",
|
|
1234
|
+
title: "Make a helper",
|
|
1235
|
+
description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
|
|
1236
|
+
inputSchema: {
|
|
1237
|
+
type: "object",
|
|
1238
|
+
properties: {
|
|
1239
|
+
projectId: { type: "string" },
|
|
1240
|
+
name: { type: "string", description: 'e.g. "QA inbox", "Admin authenticator".' },
|
|
1241
|
+
description: { type: "string" },
|
|
1242
|
+
config: { type: "object", description: "The helper's settings, by kind (see above). Never put a secret here." },
|
|
1243
|
+
secretValues: { type: "object", additionalProperties: { type: "string" }, description: "The secret for the kind, keyed as above." },
|
|
1244
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1245
|
+
},
|
|
1246
|
+
required: ["name", "config"],
|
|
1247
|
+
additionalProperties: false
|
|
1248
|
+
},
|
|
1249
|
+
run: async (args) => {
|
|
1250
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1251
|
+
const made = await service.createHelper(projectId, {
|
|
1252
|
+
name: str(args["name"]) ?? "",
|
|
1253
|
+
...str(args["description"]) ? { description: str(args["description"]) } : {},
|
|
1254
|
+
config: args["config"] && typeof args["config"] === "object" ? args["config"] : {},
|
|
1255
|
+
...args["secretValues"] && typeof args["secretValues"] === "object" ? { secretValues: Object.fromEntries(Object.entries(args["secretValues"]).map(([k, v]) => [k, String(v)])) } : {},
|
|
1256
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1257
|
+
});
|
|
1258
|
+
catalogs.delete(projectId);
|
|
1259
|
+
return text(`${made.replaced ? "Updated" : "Made"} the ${made.kind} helper "${made.name}" (id ${made.helperId}) on Claude's working branch ${made.branch}.${made.inputs.length ? ` A step supplies ${made.inputs.map((n) => `{{${n}}}`).join(", ")}.` : ""} Use it with use_helper.`);
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
821
1262
|
{
|
|
822
1263
|
name: "close_browser",
|
|
823
1264
|
title: "Close the browser",
|
|
@@ -831,6 +1272,18 @@ ${page}`);
|
|
|
831
1272
|
}
|
|
832
1273
|
}
|
|
833
1274
|
];
|
|
1275
|
+
return tools.map((tool) => ({
|
|
1276
|
+
...tool,
|
|
1277
|
+
run: async (args) => {
|
|
1278
|
+
let result;
|
|
1279
|
+
try {
|
|
1280
|
+
result = await tool.run(args);
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
throw new Error(session.redact(error.message));
|
|
1283
|
+
}
|
|
1284
|
+
return { ...result, content: result.content.map((c) => c.type === "text" && c.text !== void 0 ? { ...c, text: session.redact(c.text) } : c) };
|
|
1285
|
+
}
|
|
1286
|
+
}));
|
|
834
1287
|
}
|
|
835
1288
|
|
|
836
1289
|
// src/mcp/index.ts
|
package/dist/engine.js
CHANGED
package/package.json
CHANGED