@flowriki/agent 0.1.0-beta.45 → 0.1.0-beta.46

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/cli.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  AGENT_VERSION,
4
+ CLOUD_PROVIDERS,
5
+ CLOUD_PROVIDER_NAME,
6
+ MobileLane,
4
7
  RecordedStepSchema,
5
8
  authenticationResolver,
6
9
  checkAssertion,
10
+ checkMobileAssertion,
7
11
  closeRunSession,
8
12
  connect,
9
13
  connectionFor,
@@ -12,18 +16,32 @@ import {
12
16
  executeStep,
13
17
  fieldNames,
14
18
  helperRunner,
19
+ isMobile,
20
+ listDevices,
21
+ locate,
15
22
  mirrorCheckTargets,
23
+ mobileDoctor,
24
+ mobileOf,
25
+ mobileSetup,
26
+ openMobileSession,
16
27
  openRunSession,
17
28
  parseGherkin,
29
+ performMobileStep,
30
+ platformsOf,
18
31
  prepareBrowsers,
19
32
  replayCase,
20
33
  replayFlow,
34
+ replayMobileCase,
21
35
  resolveCheckouts,
22
36
  resolveService,
37
+ runMobileSteps,
23
38
  runtimeValue,
39
+ screenLines,
24
40
  start,
25
- status
26
- } from "./chunk-JJB45ZTL.js";
41
+ status,
42
+ stepRefusedOn,
43
+ targetFor
44
+ } from "./chunk-LIURHWRP.js";
27
45
 
28
46
  // src/cli.ts
29
47
  import { parseArgs } from "util";
@@ -154,10 +172,12 @@ var ServiceClient = class {
154
172
  catalog(projectId) {
155
173
  return this.call(`/api/agent/catalog?projectId=${encodeURIComponent(projectId)}`);
156
174
  }
157
- prepare(projectId, steps, signIn) {
175
+ /** `cloud` asks for that device farm's access key too, for a flow written
176
+ * on one of its phones. */
177
+ prepare(projectId, steps, signIn, extra = {}) {
158
178
  return this.call(`/api/agent/prepare?projectId=${encodeURIComponent(projectId)}`, {
159
179
  method: "POST",
160
- body: JSON.stringify({ steps, ...signIn ? { signIn } : {} })
180
+ body: JSON.stringify({ steps, ...signIn ? { signIn } : {}, ...extra.cloud ? { cloud: extra.cloud } : {} })
161
181
  });
162
182
  }
163
183
  /** This person's open branches that Claude wrote on this project. */
@@ -361,7 +381,31 @@ var STEP_TIMEOUT_MS = 1e4;
361
381
  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;
362
382
  var TYPED = "typed_";
363
383
  var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
364
- var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
384
+ var ACTIONS = [
385
+ "click",
386
+ "dblclick",
387
+ "rightclick",
388
+ "hover",
389
+ "fill",
390
+ "select",
391
+ "check",
392
+ "press",
393
+ "navigate",
394
+ "back",
395
+ "forward",
396
+ // On a phone: a tap is a click, and these have no web equivalent.
397
+ "tap",
398
+ "longPress",
399
+ "swipe",
400
+ "scroll",
401
+ "hideKeyboard",
402
+ "launchApp",
403
+ "terminateApp",
404
+ "resetApp",
405
+ "background",
406
+ "rotate",
407
+ "permission"
408
+ ];
365
409
  var isAction = (value) => ACTIONS.includes(String(value));
366
410
  var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
367
411
  function scopeOf(within) {
@@ -404,6 +448,24 @@ function numberDuplicates(tree) {
404
448
  return `${l} [nth ${at} of ${total} with this name]`;
405
449
  }).join("\n");
406
450
  }
451
+ function deviceFrom(said, platform, devices, mobile) {
452
+ const want = said?.trim();
453
+ if (!want) return mobile?.devices[platform];
454
+ const lower = want.toLowerCase();
455
+ const mine = devices.filter((d) => d.platform === platform);
456
+ const local = mine.find((d) => d.id.toLowerCase() === lower || d.name.toLowerCase() === lower) ?? mine.find((d) => d.name.toLowerCase().includes(lower));
457
+ if (local) return { kind: "local", id: local.id, name: local.name };
458
+ const farm = /^([^:]+):\s*(.+?)(?:\s+(\d+(?:\.\d+)*))?$/.exec(want);
459
+ const provider = farm && CLOUD_PROVIDERS.find((p) => p === farm[1].trim().toLowerCase().replace(/\s+/g, "") || CLOUD_PROVIDER_NAME[p].toLowerCase() === farm[1].trim().toLowerCase());
460
+ if (farm && provider) {
461
+ if (!mobile?.cloud.some((c) => c.provider === provider)) throw new Error(`${CLOUD_PROVIDER_NAME[provider]} is not connected to this project \u2014 add it in Settings \u2192 Mobile`);
462
+ return { kind: "cloud", provider, device: farm[2].trim(), ...farm[3] ? { osVersion: farm[3] } : {} };
463
+ }
464
+ throw new Error(`no ${platform === "ios" ? "iOS" : "Android"} device called "${want}" \u2014 connected: ${mine.map((d) => d.name).join(", ") || "none"}`);
465
+ }
466
+ async function devicesSummary() {
467
+ return listDevices().catch(() => []);
468
+ }
407
469
  function describeRows(rows) {
408
470
  return rows.map((r) => [
409
471
  `Row ${r.row}: "${r.flow}" (${r.browser})`,
@@ -431,6 +493,26 @@ function describe(step) {
431
493
  return `check ${step.proposals.map((p) => `${p.kind}${p.op && p.op !== "equals" ? ` ${p.op}` : ""} "${String(p.value)}"${p.target?.name ? ` on ${p.target.role} "${p.target.name}"` : ""}`).join(", ")}`;
432
494
  case "useHelper":
433
495
  return `use helper ${step.name || step.helper?.helperId} \u2192 {{${step.helper?.saveAs}}}${step.helper?.then === "open" ? ", then open it" : ""}`;
496
+ case "swipe":
497
+ return `swipe ${step.value}${target ? ` on ${target}` : ""}`;
498
+ case "scroll":
499
+ return target ? `scroll to ${target}` : `scroll ${step.value}`;
500
+ case "longPress":
501
+ return `long-press ${target}${where}`;
502
+ case "hideKeyboard":
503
+ return "hide the keyboard";
504
+ case "launchApp":
505
+ return "open the app";
506
+ case "terminateApp":
507
+ return "close the app";
508
+ case "resetApp":
509
+ return "clear the app's data and open it";
510
+ case "background":
511
+ return `send the app to the background for ${step.value || 3}s`;
512
+ case "rotate":
513
+ return `rotate to ${step.value}`;
514
+ case "permission":
515
+ return `${step.value === "deny" ? "deny" : "allow"} the permission prompt`;
434
516
  default:
435
517
  return `${step.type} ${target}${where}`;
436
518
  }
@@ -444,6 +526,8 @@ var AuthoringSession = class {
444
526
  opts;
445
527
  run = null;
446
528
  page = null;
529
+ /** The phone, when the flow being written runs on one. */
530
+ mobile = null;
447
531
  values = /* @__PURE__ */ new Map();
448
532
  resolvers = null;
449
533
  lastTree = null;
@@ -465,7 +549,11 @@ var AuthoringSession = class {
465
549
  return out;
466
550
  }
467
551
  get open() {
468
- return this.run !== null;
552
+ return this.run !== null || this.mobile !== null;
553
+ }
554
+ requireMobile() {
555
+ if (!this.mobile || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
556
+ return { mobile: this.mobile, draft: this.draft };
469
557
  }
470
558
  requireOpen() {
471
559
  if (!this.run || !this.page || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
@@ -476,7 +564,12 @@ var AuthoringSession = class {
476
564
  * helper or sign-in appears, never kept beyond this process. */
477
565
  async prepare(extra = []) {
478
566
  const draft = this.draft;
479
- const prepared = await this.service.prepare(draft.projectId, withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]), draft.signIn ?? null);
567
+ const prepared = await this.service.prepare(
568
+ draft.projectId,
569
+ withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]),
570
+ draft.signIn ?? null,
571
+ draft.device?.kind === "cloud" ? { cloud: draft.device.provider } : {}
572
+ );
480
573
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
481
574
  this.resolvers = {
482
575
  resolveAuth: authenticationResolver(prepared.authentications),
@@ -498,6 +591,7 @@ var AuthoringSession = class {
498
591
  this.values = /* @__PURE__ */ new Map();
499
592
  this.typed = /* @__PURE__ */ new Map();
500
593
  this.lastTree = null;
594
+ if (isMobile(input.platform)) return this.startMobile(input.platform, catalog);
501
595
  const base = this.baseUrl();
502
596
  this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
503
597
  this.page = this.run.page;
@@ -560,17 +654,7 @@ var AuthoringSession = class {
560
654
  * the page is new, the page itself, capped. read_page is always the whole
561
655
  * thing. */
562
656
  async view(full) {
563
- const { page, draft } = this.requireOpen();
564
- await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
565
- const tree = numberDuplicates(labelTree(
566
- await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
567
- await fieldNames(page)
568
- ));
569
- const title = await page.title().catch(() => "");
570
- const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
571
- Steps so far: ${draft.steps.length}
572
-
573
- `;
657
+ const { tree, head } = this.mobile ? await this.mobileTree() : await this.webTree();
574
658
  const before = this.lastTree;
575
659
  this.lastTree = tree;
576
660
  const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
@@ -595,8 +679,24 @@ ${removed.join("\n")}` : "",
595
679
  "(changes since the last step \u2014 read_page shows the whole page)"
596
680
  ].filter(Boolean).join("\n");
597
681
  }
682
+ async webTree() {
683
+ const { page, draft } = this.requireOpen();
684
+ await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
685
+ const tree = numberDuplicates(labelTree(
686
+ await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
687
+ await fieldNames(page)
688
+ ));
689
+ const title = await page.title().catch(() => "");
690
+ return { tree, head: `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
691
+ Steps so far: ${draft.steps.length}
692
+
693
+ ` };
694
+ }
598
695
  /** Do one thing on the page, and keep it as a step if it worked. */
599
696
  async act(input) {
697
+ if (this.mobile) return this.mobileAct(input);
698
+ const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
699
+ if (refused) throw new Error(refused);
600
700
  const { page, draft, run } = this.requireOpen();
601
701
  const navigating = input.action === "navigate" || input.action === "back" || input.action === "forward";
602
702
  if (!navigating && input.action !== "press" && !(input.role && input.name)) {
@@ -650,6 +750,7 @@ ${await this.view(false)}`;
650
750
  }
651
751
  /** Check something about the page now, and keep the check if it holds. */
652
752
  async check(input) {
753
+ if (this.mobile) return this.mobileCheck(input);
653
754
  const { page, draft } = this.requireOpen();
654
755
  if ((input.kind === "heading" || input.kind === "message" || input.kind === "screen") && input.op && input.op !== "equals" && !input.role) {
655
756
  throw new Error(`a ${input.kind} check without role and name matches the whole ${input.kind} \u2014 give its full text with op equals, or name the element (role + name) and use kind "text" with op ${input.op}`);
@@ -685,6 +786,7 @@ ${await this.view(false)}`;
685
786
  /** Get a value from outside the page — an email code, a 2FA code, a text,
686
787
  * an API reply — and keep the step that gets it. */
687
788
  async useHelper(input) {
789
+ if (this.mobile) return this.mobileHelper(input);
688
790
  const { page, draft, run } = this.requireOpen();
689
791
  const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
690
792
  if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
@@ -736,7 +838,8 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
736
838
  /** Drop a step from the draft. The browser is not wound back — replay to
737
839
  * see the flow as it now stands. */
738
840
  remove(number) {
739
- const { draft } = this.requireOpen();
841
+ const draft = this.draft;
842
+ if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
740
843
  if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
741
844
  const [gone] = draft.steps.splice(number - 1, 1);
742
845
  return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
@@ -746,6 +849,7 @@ ${this.list()}`;
746
849
  /** Run the whole draft from the start, in a browser of its own, the way a
747
850
  * run will — the proof the steps work together, not just one at a time. */
748
851
  async replay() {
852
+ if (this.mobile) return this.mobileReplay();
749
853
  const { draft } = this.requireOpen();
750
854
  if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
751
855
  const resolvers = await this.prepare();
@@ -761,7 +865,8 @@ ${lines.join("\n")}`;
761
865
  }
762
866
  /** Save the draft as a flow on a new branch, for a person to review. */
763
867
  async save(title, where = {}) {
764
- const { draft } = this.requireOpen();
868
+ const draft = this.draft;
869
+ if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
765
870
  if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
766
871
  if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
767
872
  throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
@@ -777,6 +882,7 @@ ${lines.join("\n")}`;
777
882
  name: draft.name,
778
883
  ...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
779
884
  startPath: draft.startPath,
885
+ ...isMobile(draft.platform) ? { platform: draft.platform } : {},
780
886
  steps: withoutTyped(checksOff),
781
887
  ...draft.signIn ? { signIn: draft.signIn } : {},
782
888
  ...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
@@ -810,8 +916,26 @@ ${describeRows(built.rows)}`;
810
916
  const prepared = await this.service.prepare(projectId, steps, null);
811
917
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
812
918
  const base = catalog.baseUrl;
813
- if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
814
- const outcome = await replayCase({
919
+ const phoneRows = testCase.occurrences.filter((o) => isMobile(o.platform));
920
+ if (!base && phoneRows.length < testCase.occurrences.length) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
921
+ const laneOptions = {
922
+ mobile: catalog.mobile ?? mobileOf(null),
923
+ resolveSecret: (ref) => prepared.secrets[ref],
924
+ resolveAuth: authenticationResolver(prepared.authentications),
925
+ ...runHelper ? { runHelper } : {}
926
+ };
927
+ const lane = phoneRows.length ? new MobileLane(laneOptions) : null;
928
+ const opened = /* @__PURE__ */ new Set();
929
+ const outcome = phoneRows.length === testCase.occurrences.length ? await replayMobileCase({ ...laneOptions, occurrences: testCase.occurrences, ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {} }) : await replayCase({
930
+ ...lane ? {
931
+ runElsewhere: (occurrence, ctx) => {
932
+ if (!isMobile(occurrence.platform)) return null;
933
+ const key = occurrence.actorId ?? testCase.defaultActorId ?? "default";
934
+ const first = !opened.has(key);
935
+ opened.add(key);
936
+ return lane.run(occurrence, { deviceKey: key, first, ...ctx });
937
+ }
938
+ } : {},
815
939
  occurrences: testCase.occurrences,
816
940
  ...testCase.actors ? { actors: testCase.actors } : {},
817
941
  ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
@@ -820,7 +944,7 @@ ${describeRows(built.rows)}`;
820
944
  resolveAuth: authenticationResolver(prepared.authentications),
821
945
  resolveSecret: (ref) => prepared.secrets[ref],
822
946
  ...runHelper ? { runHelper } : {}
823
- });
947
+ }).finally(() => lane?.close());
824
948
  const lines = outcome.occurrences.map((o, i) => {
825
949
  const failed = o.steps.find((st) => st.status === "failed");
826
950
  const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
@@ -840,11 +964,196 @@ Review and publish it here: ${review}`;
840
964
  }
841
965
  async close() {
842
966
  const run = this.run;
967
+ const mobile = this.mobile;
843
968
  this.run = null;
844
969
  this.page = null;
970
+ this.mobile = null;
845
971
  this.resolvers = null;
972
+ if (mobile) await mobile.close().catch(() => void 0);
846
973
  if (run) await closeRunSession(run, { passed: true }).catch(() => void 0);
847
974
  }
975
+ // ── On a phone ────────────────────────────────────────────────────────
976
+ /** Open the project's app on the chosen phone, signed in and brought to
977
+ * where the flow starts, as on the web. */
978
+ async startMobile(platform, catalog) {
979
+ const draft = this.draft;
980
+ const mobile = catalog.mobile;
981
+ if (!mobile) throw new Error("this project has no phone apps set up \u2014 turn on Android or iOS in Settings \u2192 Mobile");
982
+ const resolvers = draft.signIn || draft.device?.kind === "cloud" ? await this.prepare() : null;
983
+ this.mobile = await openMobileSession({
984
+ platform,
985
+ mobile,
986
+ ...draft.device ? { device: draft.device } : {},
987
+ ...resolvers ? { resolveSecret: resolvers.resolveSecret } : {}
988
+ });
989
+ const opening = [
990
+ ...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
991
+ ...draft.preparation ?? []
992
+ ];
993
+ if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await this.mobile.openLink(draft.startPath);
994
+ if (opening.length) {
995
+ const ready = await this.prepare(draft.preparation ?? []);
996
+ const done = await runMobileSteps(this.mobile, opening, { values: this.values, ...ready });
997
+ if (done.status !== "passed") throw new Error(`The app could not be brought to where this flow starts: ${done.error}`);
998
+ }
999
+ return this.snapshot();
1000
+ }
1001
+ async mobileTree() {
1002
+ const { mobile, draft } = this.requireMobile();
1003
+ mobile.changed();
1004
+ let screen = await mobile.screen();
1005
+ if (screen.nodes.length === 0) screen = await mobile.settle(8e3);
1006
+ const where = await mobile.currentScreenName();
1007
+ const tree = numberDuplicates(screenLines(screen)) || "(nothing on the screen can be read \u2014 the app may still be loading, or draws without accessibility labels)";
1008
+ return { tree, head: `Screen: ${screen.app || mobile.appId} on ${mobile.deviceName}${where ? ` \u2014 ${where}` : ""}
1009
+ Steps so far: ${draft.steps.length}
1010
+
1011
+ ` };
1012
+ }
1013
+ mobileResolvers() {
1014
+ return {
1015
+ values: this.values,
1016
+ ...this.resolvers ?? {},
1017
+ resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref)
1018
+ };
1019
+ }
1020
+ async mobileAct(input) {
1021
+ const { mobile, draft } = this.requireMobile();
1022
+ const type = input.action === "tap" ? "click" : input.action;
1023
+ const refused = stepRefusedOn(type, mobile.platform);
1024
+ if (refused) throw new Error(refused);
1025
+ const needsElement = ["click", "longPress", "fill", "select", "check"].includes(type);
1026
+ if (needsElement && !(input.role && (input.name || input.nth !== void 0))) {
1027
+ throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
1028
+ }
1029
+ if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
1030
+ let target = { role: "", name: "" };
1031
+ let password = false;
1032
+ if (input.role) {
1033
+ const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
1034
+ try {
1035
+ const found = await locate(mobile, asked, STEP_TIMEOUT_MS, { scroll: type !== "swipe" });
1036
+ target = { ...targetFor(found.screen, found.node), ...asked };
1037
+ password = found.node.password;
1038
+ } catch (error) {
1039
+ const said = error.message.split("\n")[0] ?? "";
1040
+ const several = /elements match/.test(said) ? "\nSeveral elements share this role and name. Pass nth (0 = the first, in the order read_page lists them) to say which one." : "";
1041
+ throw new Error(`That did not work, and was not kept: ${said}${several}
1042
+
1043
+ ${await this.view(false)}`);
1044
+ }
1045
+ }
1046
+ const secret = type === "fill" && (input.secret ?? (password || SECRET_WORDS.test(input.name ?? "")));
1047
+ const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
1048
+ if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
1049
+ const value = secret ? "" : type === "check" ? input.value === "false" ? "false" : "true" : type === "swipe" || type === "scroll" && !input.role ? (input.value || "up").toLowerCase() : input.value ?? "";
1050
+ const step = RecordedStepSchema.parse({
1051
+ id,
1052
+ type,
1053
+ ...target,
1054
+ value,
1055
+ secret,
1056
+ ...secret ? { secretRef: `${TYPED}${id}` } : {},
1057
+ legacySelector: "",
1058
+ proposals: []
1059
+ });
1060
+ try {
1061
+ await performMobileStep(mobile, step, this.mobileResolvers());
1062
+ } catch (error) {
1063
+ if (secret) this.typed.delete(`${TYPED}${id}`);
1064
+ throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
1065
+
1066
+ ${await this.view(false)}`);
1067
+ }
1068
+ await new Promise((r) => setTimeout(r, 600));
1069
+ draft.steps.push(step);
1070
+ return `Step ${draft.steps.length} kept: ${describe(step)}.
1071
+
1072
+ ${await this.view(false)}`;
1073
+ }
1074
+ async mobileCheck(input) {
1075
+ const { mobile, draft } = this.requireMobile();
1076
+ if (input.kind === "url" || input.kind === "title") throw new Error(`a phone app has no ${input.kind === "url" ? "address" : "page title"} \u2014 check text on the screen (message), a heading's text, or an element`);
1077
+ const assertion = RecordedStepSchema.shape.proposals.element.parse({
1078
+ kind: input.kind,
1079
+ value: input.value,
1080
+ enabled: true,
1081
+ ...input.op && input.op !== "equals" ? { op: input.op } : {},
1082
+ ...input.role ? { target: { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} } } : {}
1083
+ });
1084
+ const filled = (text2) => text2.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => runtimeValue(this.values, name));
1085
+ const expected = Array.isArray(assertion.value) ? assertion.value.map(filled) : filled(assertion.value);
1086
+ try {
1087
+ await checkMobileAssertion(mobile, { ...assertion, value: expected }, 5e3);
1088
+ } catch (error) {
1089
+ throw new Error(`That is not true of the screen right now, so it was not kept: ${error.message.split("\n")[0]}`);
1090
+ }
1091
+ const step = RecordedStepSchema.parse(mirrorCheckTargets([{
1092
+ id: `c${draft.steps.length + 1}_${Date.now().toString(36)}`,
1093
+ type: "assert",
1094
+ role: "",
1095
+ name: "",
1096
+ scope: "",
1097
+ value: "",
1098
+ secret: false,
1099
+ legacySelector: "",
1100
+ proposals: [assertion]
1101
+ }])[0]);
1102
+ draft.steps.push(step);
1103
+ return `Step ${draft.steps.length} kept: ${describe(step)}.`;
1104
+ }
1105
+ async mobileHelper(input) {
1106
+ const { mobile, draft } = this.requireMobile();
1107
+ const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
1108
+ if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
1109
+ const missing = helper.inputs.filter((name) => !(input.inputs ?? {})[name]);
1110
+ if (missing.length) throw new Error(`${helper.name} needs ${missing.map((n) => `{{${n}}}`).join(", ")} in inputs`);
1111
+ const step = RecordedStepSchema.parse({
1112
+ id: `h${draft.steps.length + 1}_${Date.now().toString(36)}`,
1113
+ type: "useHelper",
1114
+ role: "",
1115
+ name: helper.name,
1116
+ scope: "",
1117
+ value: "",
1118
+ secret: false,
1119
+ legacySelector: "",
1120
+ proposals: [],
1121
+ helper: { helperId: helper.id, inputs: input.inputs ?? {}, saveAs: input.saveAs || (helper.kind === "mailbox" ? "code" : "value"), then: input.then ?? "remember" }
1122
+ });
1123
+ await this.prepare([step]);
1124
+ let got;
1125
+ try {
1126
+ got = await performMobileStep(mobile, step, this.mobileResolvers());
1127
+ } catch (error) {
1128
+ throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
1129
+ }
1130
+ draft.steps.push(step);
1131
+ 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" ? `
1132
+
1133
+ ${await this.view(false)}` : "");
1134
+ }
1135
+ /** The whole flow again from the app's start, on the same phone — a phone
1136
+ * holds one session at a time, so the app is closed and opened rather than
1137
+ * a second device started. */
1138
+ async mobileReplay() {
1139
+ const { mobile, draft } = this.requireMobile();
1140
+ if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
1141
+ const resolvers = await this.prepare();
1142
+ const steps = [
1143
+ ...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
1144
+ ...draft.preparation ?? [],
1145
+ ...draft.steps
1146
+ ];
1147
+ await mobile.terminateApp().catch(() => void 0);
1148
+ await mobile.launchApp();
1149
+ await mobile.settle();
1150
+ if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await mobile.openLink(draft.startPath);
1151
+ const outcome = await runMobileSteps(mobile, steps, { values: /* @__PURE__ */ new Map(), ...resolvers, resolveSecret: (ref) => this.typed.get(ref) ?? resolvers.resolveSecret(ref) });
1152
+ this.lastTree = null;
1153
+ const lines = outcome.results.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
1154
+ return `${outcome.status === "passed" ? "The whole flow passed from the app's start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
1155
+ ${lines.join("\n")}`;
1156
+ }
848
1157
  /** Let a click's consequences land before the page is read again. */
849
1158
  async settle() {
850
1159
  const page = this.page;
@@ -935,6 +1244,26 @@ converted once. Use the project's existing feature names (project_catalog)
935
1244
  when a case belongs in one, rather than a new look-alike. Rows that are not test cases (titles,
936
1245
  notes, empty) are left out. Say in note what you could not convert.
937
1246
 
1247
+ PHONE APPS (Android, iOS): a project can test its Android and iOS apps as well
1248
+ as, or instead of, a website \u2014 project_catalog says which, names the app, and
1249
+ lists the phones connected to this computer. start_flow with platform
1250
+ "android" or "ios" opens the app on a phone (device: a name from the catalog,
1251
+ "BrowserStack: Samsung Galaxy S23", or nothing for whatever is connected).
1252
+ read_page then reads the SCREEN as roles and names, just like a page, and act
1253
+ and add_check work the same way. On a phone: tap (= click) a button, fill a
1254
+ textbox, swipe up/down/left/right (value; on an element, or the screen), scroll
1255
+ to an element (role + name) or by direction, longPress, back (Android only),
1256
+ hideKeyboard, permission (value "allow" or "deny") for a system prompt, and
1257
+ launchApp / terminateApp / resetApp / background (value: seconds) / rotate
1258
+ (value: portrait or landscape). navigate opens a deep link. Never tap by
1259
+ position \u2014 use the role and name; [id=\u2026] on a line is the app's own id for it,
1260
+ and is used automatically. A phone app has no URL or page title: check a
1261
+ message (text on the screen), an element's text, or that something is visible.
1262
+ The app keeps its data between runs unless the project says otherwise, so a
1263
+ sign-in step is skipped when the app is already signed in. A test case can
1264
+ mix rows on a phone with rows in a browser (a customer orders in the app, an
1265
+ admin approves on the website).
1266
+
938
1267
  HOW TO WORK:
939
1268
  1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
940
1269
  branch \u2014 rather than writing it again.
@@ -1063,9 +1392,23 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
1063
1392
  run: async (args) => {
1064
1393
  const projectId = await projectFor(args["projectId"]);
1065
1394
  const c = await catalogOf(projectId, true);
1066
- const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps)`).join("\n");
1395
+ const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
1396
+ const platforms = platformsOf(c);
1397
+ const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
1398
+ const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
1399
+ const app = p === "android" ? c.mobile?.android?.appPackage : c.mobile?.ios?.bundleId;
1400
+ const here = phones.filter((d) => d.platform === p);
1401
+ const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
1402
+ const chosen = c.mobile?.devices[p];
1403
+ return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not set up \u2014 Settings \u2192 Mobile)"}
1404
+ Phones on this computer: ${here.length ? here.map((d) => `${d.name}${d.state === "ready" ? "" : ` (${d.state})`}`).join(", ") : "none"}` + (farms.length ? `
1405
+ Cloud device farms: ${farms.join(", ")}` : "") + (chosen ? `
1406
+ Runs go to: ${chosen.kind === "local" ? chosen.name || chosen.id : `${CLOUD_PROVIDER_NAME[chosen.provider]}: ${chosen.device}`}` : "");
1407
+ });
1067
1408
  return text([
1068
- `Project ${projectId}. Base URL: ${c.baseUrl || "(not set)"}`,
1409
+ `Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
1410
+ platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}` : "",
1411
+ ...phoneLines,
1069
1412
  c.environments.length ? `Environments: ${c.environments.map((e) => `${e.name} ${e.baseUrl}`).join(", ")}` : "",
1070
1413
  `
1071
1414
  Features:${c.features.length ? "" : " none yet"}`,
@@ -1095,13 +1438,15 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
1095
1438
  {
1096
1439
  name: "start_flow",
1097
1440
  title: "Start writing a flow",
1098
- description: "Open a browser on this computer at the project's app and start a new flow. Returns the page. Closes any flow that was being written.",
1441
+ description: "Open a browser on this computer at the project's app \u2014 or, with platform android or ios, the project's phone app on a phone \u2014 and start a new flow. Returns the page or screen. Closes any flow that was being written.",
1099
1442
  inputSchema: {
1100
1443
  type: "object",
1101
1444
  properties: {
1102
1445
  name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
1103
1446
  feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
1104
- startPath: { type: "string", description: 'Where it starts, relative to the base URL. Default "/".' },
1447
+ startPath: { type: "string", description: `Where it starts, relative to the base URL. Default "/". On a phone: a deep link to open, or leave out for the app's first screen.` },
1448
+ platform: { type: "string", enum: ["web", "android", "ios"], description: "Where the flow runs. Default: web, or the project's only platform." },
1449
+ device: { type: "string", description: `Phones only: a phone from project_catalog by name, or a cloud farm's as "BrowserStack: Samsung Galaxy S23". Default: the project's choice, then whatever is connected.` },
1105
1450
  after: {
1106
1451
  type: "array",
1107
1452
  items: { type: "string" },
@@ -1131,16 +1476,23 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
1131
1476
  }
1132
1477
  const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
1133
1478
  const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
1479
+ const platforms = platformsOf(catalog);
1480
+ const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
1481
+ const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
1482
+ if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
1483
+ if (!platforms[platform]) throw new Error(`this project does not test ${platform === "web" ? "a website" : `an ${platform === "ios" ? "iOS" : "Android"} app`} \u2014 turn it on in Settings first`);
1484
+ const device = isMobile(platform) ? deviceFrom(str(args["device"]), platform, await devicesSummary(), catalog.mobile) : void 0;
1134
1485
  const page = await session.start({
1135
1486
  projectId,
1487
+ ...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
1136
1488
  ...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
1137
1489
  name: str(args["name"]) ?? "Untitled flow",
1138
1490
  ...feature ? { featureId: feature.id } : { featureName: featureAsked },
1139
- startPath: str(args["startPath"]) || "/",
1491
+ startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
1140
1492
  ...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
1141
1493
  }, catalog);
1142
1494
  const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
1143
- return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} The browser is open.
1495
+ return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} ${isMobile(platform) ? "The app is open on the phone." : "The browser is open."}
1144
1496
 
1145
1497
  ${page}`);
1146
1498
  }
@@ -1148,7 +1500,7 @@ ${page}`);
1148
1500
  {
1149
1501
  name: "read_page",
1150
1502
  title: "Read the page",
1151
- description: "The current page as its accessibility tree: each element's role and name, which is exactly what act and add_check refer to.",
1503
+ description: "The current page \u2014 or phone screen \u2014 as its accessibility tree: each element's role and name, which is exactly what act and add_check refer to.",
1152
1504
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1153
1505
  annotations: { readOnlyHint: true },
1154
1506
  run: async () => text(await session.snapshot())
@@ -1156,11 +1508,11 @@ ${page}`);
1156
1508
  {
1157
1509
  name: "act",
1158
1510
  title: "Do one thing on the page",
1159
- description: `Perform one step and keep it in the flow if it worked. Returns the page afterwards. click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value. back/forward: nothing.`,
1511
+ description: `Perform one step and keep it in the flow if it worked. Returns the page afterwards. click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value (on a phone, a deep link). back/forward: nothing. PHONE ONLY: tap (role + name), longPress (role + name), swipe (value up/down/left/right, optionally on role + name), scroll (to role + name, or value up/down), hideKeyboard, permission (value allow/deny), launchApp, terminateApp, resetApp, background (value: seconds), rotate (value portrait/landscape).`,
1160
1512
  inputSchema: {
1161
1513
  type: "object",
1162
1514
  properties: {
1163
- action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"] },
1515
+ action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward", "tap", "longPress", "swipe", "scroll", "hideKeyboard", "permission", "launchApp", "terminateApp", "resetApp", "background", "rotate"] },
1164
1516
  role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
1165
1517
  name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
1166
1518
  within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
@@ -1172,7 +1524,7 @@ ${page}`);
1172
1524
  additionalProperties: false
1173
1525
  },
1174
1526
  run: async (args) => {
1175
- if (!isAction(args["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward");
1527
+ if (!isAction(args["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward \u2014 or on a phone tap, longPress, swipe, scroll, hideKeyboard, permission, launchApp, terminateApp, resetApp, background, rotate");
1176
1528
  return text(await session.act({
1177
1529
  action: args["action"],
1178
1530
  ...str(args["role"]) ? { role: str(args["role"]) } : {},
@@ -1187,7 +1539,7 @@ ${page}`);
1187
1539
  {
1188
1540
  name: "add_check",
1189
1541
  title: "Add a check",
1190
- description: "Assert something about the page as it is now, and keep it in the flow if it holds. Page checks need only kind + value: heading, url (a path), title, message. Element checks also need role + name: text, value, visible, hidden, checked, count. op: equals (default), contains or matches.",
1542
+ description: "Assert something about the page (or phone screen) as it is now, and keep it in the flow if it holds. Page checks need only kind + value: heading, url (a path), title, message (text shown anywhere \u2014 on a phone, the way to check a screen). Element checks also need role + name: text, value, visible, hidden, checked, count. op: equals (default), contains or matches.",
1191
1543
  inputSchema: {
1192
1544
  type: "object",
1193
1545
  properties: {
@@ -1721,7 +2073,7 @@ ${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") |
1721
2073
  {
1722
2074
  name: "close_browser",
1723
2075
  title: "Close the browser",
1724
- description: "Close the browser and drop the flow being written, unsaved.",
2076
+ description: "Close the browser \u2014 or the app on the phone \u2014 and drop the flow being written, unsaved.",
1725
2077
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1726
2078
  annotations: { destructiveHint: true },
1727
2079
  run: async () => {
@@ -1792,6 +2144,10 @@ ${bold("USAGE")}
1792
2144
  flowriki-agent doctor diagnose setup problems
1793
2145
  flowriki-agent prepare download the browser recording needs
1794
2146
  flowriki-agent disconnect forget this computer's credential
2147
+ flowriki-agent mobile setup install what testing Android and iOS apps
2148
+ needs (Appium and its drivers)
2149
+ flowriki-agent mobile doctor is this computer ready to test phones
2150
+ flowriki-agent mobile devices the phones, emulators and simulators here
1795
2151
  flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
1796
2152
  Claude Code or Claude Desktop, not by you:
1797
2153
  claude mcp add flowriki -- flowriki-agent mcp
@@ -1987,6 +2343,42 @@ Run \`flowriki-agent --help\`.`);
1987
2343
  }
1988
2344
  return 0;
1989
2345
  }
2346
+ case "mobile": {
2347
+ const sub = positionals[1] ?? "doctor";
2348
+ const report = (found) => {
2349
+ for (const [title, lines] of [["Appium", found.appium], ["Android", found.android], ["iOS", found.ios]]) {
2350
+ console.log(bold(title));
2351
+ for (const line of lines) {
2352
+ console.log(` ${line.ok ? green("\u2713") : red("\u2717")} ${line.item.padEnd(15)} ${line.ok ? dim(line.detail) : line.detail}`);
2353
+ if (!line.ok && line.fix) console.log(` ${yellow("\u2192")} ${line.fix}`);
2354
+ }
2355
+ }
2356
+ console.log(`
2357
+ Android ${found.androidReady ? green("ready") : yellow("not ready")} iOS ${found.iosReady ? green("ready") : yellow("not ready")}`);
2358
+ return found.androidReady || found.iosReady ? 0 : 1;
2359
+ };
2360
+ if (sub === "setup") return report(await mobileSetup((line) => console.log(dim(` ${line}`))));
2361
+ if (sub === "doctor") {
2362
+ const found = await mobileDoctor();
2363
+ if (values.json) {
2364
+ console.log(JSON.stringify(found, null, 2));
2365
+ return found.androidReady || found.iosReady ? 0 : 1;
2366
+ }
2367
+ return report(found);
2368
+ }
2369
+ if (sub === "devices") {
2370
+ const found = await listDevices();
2371
+ if (values.json) {
2372
+ console.log(JSON.stringify(found, null, 2));
2373
+ return 0;
2374
+ }
2375
+ if (!found.length) console.log(yellow(" No phones, emulators or simulators found."));
2376
+ for (const d of found) console.log(` ${d.state === "ready" ? green("\u25CF") : dim("\u25CB")} ${d.name.padEnd(28)} ${dim(`${d.platform === "ios" ? "iOS" : "Android"} ${d.osVersion} \xB7 ${d.kind}${d.state === "ready" ? "" : ` \xB7 ${d.state}`}`)}`);
2377
+ return 0;
2378
+ }
2379
+ console.error(`unknown: mobile ${sub} \u2014 use setup, doctor or devices`);
2380
+ return 2;
2381
+ }
1990
2382
  case "projects": {
1991
2383
  const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
1992
2384
  for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);