@flowriki/agent 0.1.0-beta.34 → 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.
@@ -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.34";
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-7OODSC7P.js";
25
+ } from "./chunk-N3P4O3OH.js";
25
26
 
26
27
  // src/cli.ts
27
28
  import { parseArgs } from "util";
@@ -172,12 +173,45 @@ var ServiceClient = class {
172
173
  { method: "DELETE" }
173
174
  );
174
175
  }
175
- saveFlow(projectId, title, flow, where = {}) {
176
+ saveFlow(projectId, title, flow, where = {}, secretValues = {}) {
176
177
  return this.call(
177
178
  `/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
178
- { method: "POST", body: JSON.stringify({ title, flow, ...where }) }
179
+ { method: "POST", body: JSON.stringify({ title, flow, ...where, secretValues }) }
179
180
  );
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
+ }
181
215
  /** Gather Claude's open branches here into one. */
182
216
  gatherChanges(projectId, opts = {}) {
183
217
  return this.call(
@@ -203,6 +237,9 @@ function topLevelBlocks(tree) {
203
237
  };
204
238
  }
205
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);
206
243
  var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
207
244
  var isAction = (value) => ACTIONS.includes(String(value));
208
245
  var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
@@ -226,12 +263,18 @@ function labelTree(tree, fields) {
226
263
  for (const f of fields) out = out.split(`- ${f.role} ${JSON.stringify(f.hint)}`).join(`- ${f.role} ${JSON.stringify(f.label)}`);
227
264
  return out;
228
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
+ }
229
272
  function describe(step) {
230
273
  const target = step.name ? `${step.role || "element"} "${step.name}"` : step.role || "";
231
274
  const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
232
275
  switch (step.type) {
233
276
  case "fill":
234
- 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}`;
235
278
  case "select":
236
279
  return `choose "${step.value}" in ${target}${where}`;
237
280
  case "check":
@@ -263,8 +306,23 @@ var AuthoringSession = class {
263
306
  values = /* @__PURE__ */ new Map();
264
307
  resolvers = null;
265
308
  lastTree = null;
309
+ /** Values typed into secret fields, by their `typed:` reference. */
310
+ typed = /* @__PURE__ */ new Map();
266
311
  draft = null;
267
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
+ }
268
326
  get open() {
269
327
  return this.run !== null;
270
328
  }
@@ -277,11 +335,11 @@ var AuthoringSession = class {
277
335
  * helper or sign-in appears, never kept beyond this process. */
278
336
  async prepare(extra = []) {
279
337
  const draft = this.draft;
280
- 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);
281
339
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
282
340
  this.resolvers = {
283
341
  resolveAuth: authenticationResolver(prepared.authentications),
284
- resolveSecret: (ref) => prepared.secrets[ref],
342
+ resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
285
343
  ...runHelper ? { runHelper } : {}
286
344
  };
287
345
  return this.resolvers;
@@ -297,6 +355,7 @@ var AuthoringSession = class {
297
355
  this.catalog = catalog;
298
356
  this.draft = { ...input, steps: [] };
299
357
  this.values = /* @__PURE__ */ new Map();
358
+ this.typed = /* @__PURE__ */ new Map();
300
359
  this.lastTree = null;
301
360
  const base = this.baseUrl();
302
361
  this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
@@ -326,6 +385,24 @@ var AuthoringSession = class {
326
385
  } else {
327
386
  await this.page.goto(new URL(input.startPath || "/", base).href, { waitUntil: "domcontentloaded", timeout: 45e3 });
328
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
+ }
329
406
  return this.snapshot();
330
407
  }
331
408
  /** The page as Claude reads it: where it is, and what is on it by role and name. */
@@ -388,13 +465,17 @@ ${removed.join("\n")}` : "",
388
465
  throw new Error(`${input.action} needs a value`);
389
466
  }
390
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 ?? "");
391
471
  const step = RecordedStepSchema.parse({
392
- id: `s${draft.steps.length + 1}_${Date.now().toString(36)}`,
472
+ id,
393
473
  type: input.action,
394
474
  ...target,
395
475
  ...scopeOf(input.within),
396
- value: input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
397
- secret: false,
476
+ value: secret ? "" : input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
477
+ secret,
478
+ ...secret ? { secretRef: `${TYPED}${id}` } : {},
398
479
  legacySelector: "",
399
480
  proposals: [],
400
481
  // A path is the application's own, so the step replays on whichever
@@ -407,10 +488,12 @@ ${removed.join("\n")}` : "",
407
488
  values: this.values,
408
489
  baseUrl: this.baseUrl(),
409
490
  ...this.resolvers ?? {},
491
+ resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
410
492
  timeoutMs: STEP_TIMEOUT_MS,
411
493
  navigationTimeoutMs: 45e3
412
494
  });
413
495
  } catch (error) {
496
+ if (secret) this.typed.delete(`${TYPED}${id}`);
414
497
  throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
415
498
 
416
499
  ${await this.view(false)}`);
@@ -524,6 +607,7 @@ ${this.list()}`;
524
607
  const resolvers = await this.prepare();
525
608
  const steps = [
526
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 ?? [],
527
611
  ...draft.steps
528
612
  ];
529
613
  const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
@@ -538,21 +622,77 @@ ${lines.join("\n")}`;
538
622
  if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
539
623
  throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
540
624
  }
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
+ }
541
632
  const saved = await this.service.saveFlow(draft.projectId, title || "Tests written with Claude", {
542
633
  name: draft.name,
543
634
  ...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
544
635
  startPath: draft.startPath,
545
- steps: draft.steps,
546
- ...draft.signIn ? { signIn: draft.signIn } : {}
547
- }, where);
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);
548
640
  const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
549
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.` : "";
550
644
  const where_ = saved.newBranch ? `on a new branch, ${saved.branch}` : `on Claude's working branch, ${saved.branch}`;
551
645
  const others = await this.service.agentChanges(draft.projectId).then((r) => r.changes.filter((c) => c.id !== saved.changeId)).catch(() => []);
552
646
  const note = others.length ? `
553
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.` : "";
554
- return `${what} ${where_}. Nothing is published yet.
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.
555
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}`;
556
696
  }
557
697
  async close() {
558
698
  const run = this.run;
@@ -572,47 +712,107 @@ Review and publish it here: ${review}${note}`;
572
712
 
573
713
  // src/mcp/tools.ts
574
714
  var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
575
- Every step you take is performed first and kept only if it worked, so the flow
576
- you save already ran once. Saved flows go to a NEW branch that a person reviews
577
- and publishes in the Flowriki dashboard \u2014 nothing you do is published.
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.
578
719
 
579
720
  A new project \u2014 "set up Flowriki for this site, in this repository" \u2014 is made
580
721
  with create_project; it then becomes the project the other tools work on.
581
722
 
582
- How to write a good Flowriki test:
583
- 1. Call project_catalog first. Reuse an existing feature for the flow when one
584
- fits; otherwise name a new one. Check the existing flows so you do not write
585
- one that already exists.
586
- 2. One flow is one user journey with one purpose ("Sign up and verify email",
587
- "Pay by card"), named the way a tester would say it.
588
- 3. If the journey needs somebody signed in, pass signIn with a profile and an
589
- account from the catalog to start_flow. Never type real credentials.
590
- 4. Read the page with read_page and act using the role and name EXACTLY as the
591
- page shows them (e.g. button "Continue", textbox "Email"). If a name appears
592
- more than once, pass "within" with the name of the region, dialog or form
593
- that holds the one you mean.
594
- 5. For things outside the page \u2014 an emailed code or link, an authenticator
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
595
764
  code, a text message, an API call \u2014 use use_helper with a helper from the
596
765
  catalog, then type {{its saveAs name}} into the field that needs it.
597
- 6. End with at least one check (add_check) that proves the journey worked:
598
- a heading, a message, the URL. Saving is refused for a flow that checks nothing.
599
- 7. Before saving, call replay_flow to run the whole flow from the start. If a
600
- step fails there, fix it (remove_step, act again) and replay again.
601
- 8. Save with save_flow and give the person the review link it returns. Saves go
602
- to ONE working branch per project \u2014 everything you write here lands on it,
603
- for one review. Saving a flow with the same name again replaces it there.
604
- The catalog lists what is already on that branch: reuse its features, and
605
- do not write a flow twice.
606
- 9. Old branches of yours from before (one per flow)? gather_branches puts them
607
- into one. discard_branch deletes one \u2014 only after the person asks for it or
608
- agrees. Only Claude's own unedited branches can be gathered or deleted;
609
- anything a person touched is theirs.
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.
610
774
  Test data you invent (a new user's email or password for a sign-up) is fine to
611
775
  type. For anything that must be NEW on every run \u2014 a sign-up's email or
612
776
  username \u2014 use {{unique}}: it is different on every run and the same within
613
777
  one, e.g. qa+{{unique}}@ the domain of the project's mailbox helper, so the
614
778
  helper can read that address's verification email with {"email": "qa+{{unique}}@\u2026"}.
615
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
+ });
616
816
  var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
617
817
  var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
618
818
  function authoringTools(service, session) {
@@ -634,7 +834,7 @@ function authoringTools(service, session) {
634
834
  catalogs.set(projectId, catalog);
635
835
  return catalog;
636
836
  };
637
- return [
837
+ const tools = [
638
838
  {
639
839
  name: "list_projects",
640
840
  title: "List Flowriki projects",
@@ -690,13 +890,19 @@ ${flowsBy(f.id)}`),
690
890
  `
691
891
  On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet published:`,
692
892
  ...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
693
- ...c.draft.flows.length ? c.draft.flows.map((f) => ` - ${f.name} (${f.steps} steps, feature ${f.featureId})`) : [" (no flows yet)"]
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 ")}`)
694
896
  ] : [],
695
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")}`),
901
+ `
696
902
  Sign-in profiles:${c.signIns.length ? "" : " none"}`,
697
- ...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"}`),
698
904
  `
699
- Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers screen"}`,
905
+ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
700
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}` : ""}`)
701
907
  ].filter(Boolean).join("\n"));
702
908
  }
@@ -711,6 +917,11 @@ Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers scr
711
917
  name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
712
918
  feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
713
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
+ },
714
925
  signIn: {
715
926
  type: "object",
716
927
  description: "Start signed in, with a profile (and optionally an account) from project_catalog.",
@@ -733,14 +944,18 @@ Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers scr
733
944
  if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
734
945
  throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
735
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;
736
949
  const page = await session.start({
737
950
  projectId,
951
+ ...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
738
952
  name: str(args["name"]) ?? "Untitled flow",
739
953
  ...feature ? { featureId: feature.id } : { featureName: featureAsked },
740
954
  startPath: str(args["startPath"]) || "/",
741
955
  ...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
742
956
  }, catalog);
743
- return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}. The browser is open.
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.
744
959
 
745
960
  ${page}`);
746
961
  }
@@ -764,7 +979,8 @@ ${page}`);
764
979
  role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
765
980
  name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
766
981
  within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
767
- 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." }
768
984
  },
769
985
  required: ["action"],
770
986
  additionalProperties: false
@@ -776,7 +992,8 @@ ${page}`);
776
992
  ...str(args["role"]) ? { role: str(args["role"]) } : {},
777
993
  ...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
778
994
  ...str(args["within"]) ? { within: str(args["within"]) } : {},
779
- ...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"] } : {}
780
997
  }));
781
998
  }
782
999
  },
@@ -924,6 +1141,124 @@ ${page}`);
924
1141
  return text(`Deleted ${changeId} and its branch ${deleted}.`);
925
1142
  }
926
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
+ },
927
1262
  {
928
1263
  name: "close_browser",
929
1264
  title: "Close the browser",
@@ -937,6 +1272,18 @@ ${page}`);
937
1272
  }
938
1273
  }
939
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
+ }));
940
1287
  }
941
1288
 
942
1289
  // src/mcp/index.ts
package/dist/engine.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-7OODSC7P.js";
12
+ } from "./chunk-N3P4O3OH.js";
13
13
  export {
14
14
  AGENT_VERSION,
15
15
  browsersReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.34",
3
+ "version": "0.1.0-beta.36",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",