@flowriki/agent 0.1.0-beta.44 → 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/{chunk-E24RRF2Q.js → chunk-LIURHWRP.js} +3010 -688
- package/dist/cli.js +496 -37
- package/dist/engine.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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. */
|
|
@@ -230,6 +250,17 @@ var ServiceClient = class {
|
|
|
230
250
|
{ method: "POST", body: JSON.stringify(body) }
|
|
231
251
|
);
|
|
232
252
|
}
|
|
253
|
+
updateRequest(projectId, id) {
|
|
254
|
+
return this.call(
|
|
255
|
+
`/api/agent/update-requests/${encodeURIComponent(id)}?projectId=${encodeURIComponent(projectId)}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
proposeUpdates(projectId, id, body) {
|
|
259
|
+
return this.call(
|
|
260
|
+
`/api/agent/update-requests/${encodeURIComponent(id)}/proposals?projectId=${encodeURIComponent(projectId)}`,
|
|
261
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
262
|
+
);
|
|
263
|
+
}
|
|
233
264
|
readCases(projectId, refs) {
|
|
234
265
|
const ids = refs.filter((r) => /^tc_/.test(r.trim()));
|
|
235
266
|
const names = refs.filter((r) => !/^tc_/.test(r.trim()));
|
|
@@ -350,7 +381,31 @@ var STEP_TIMEOUT_MS = 1e4;
|
|
|
350
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;
|
|
351
382
|
var TYPED = "typed_";
|
|
352
383
|
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
353
|
-
var ACTIONS = [
|
|
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
|
+
];
|
|
354
409
|
var isAction = (value) => ACTIONS.includes(String(value));
|
|
355
410
|
var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
|
|
356
411
|
function scopeOf(within) {
|
|
@@ -393,6 +448,24 @@ function numberDuplicates(tree) {
|
|
|
393
448
|
return `${l} [nth ${at} of ${total} with this name]`;
|
|
394
449
|
}).join("\n");
|
|
395
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
|
+
}
|
|
396
469
|
function describeRows(rows) {
|
|
397
470
|
return rows.map((r) => [
|
|
398
471
|
`Row ${r.row}: "${r.flow}" (${r.browser})`,
|
|
@@ -420,6 +493,26 @@ function describe(step) {
|
|
|
420
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(", ")}`;
|
|
421
494
|
case "useHelper":
|
|
422
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`;
|
|
423
516
|
default:
|
|
424
517
|
return `${step.type} ${target}${where}`;
|
|
425
518
|
}
|
|
@@ -433,6 +526,8 @@ var AuthoringSession = class {
|
|
|
433
526
|
opts;
|
|
434
527
|
run = null;
|
|
435
528
|
page = null;
|
|
529
|
+
/** The phone, when the flow being written runs on one. */
|
|
530
|
+
mobile = null;
|
|
436
531
|
values = /* @__PURE__ */ new Map();
|
|
437
532
|
resolvers = null;
|
|
438
533
|
lastTree = null;
|
|
@@ -454,7 +549,11 @@ var AuthoringSession = class {
|
|
|
454
549
|
return out;
|
|
455
550
|
}
|
|
456
551
|
get open() {
|
|
457
|
-
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 };
|
|
458
557
|
}
|
|
459
558
|
requireOpen() {
|
|
460
559
|
if (!this.run || !this.page || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
@@ -465,7 +564,12 @@ var AuthoringSession = class {
|
|
|
465
564
|
* helper or sign-in appears, never kept beyond this process. */
|
|
466
565
|
async prepare(extra = []) {
|
|
467
566
|
const draft = this.draft;
|
|
468
|
-
const prepared = await this.service.prepare(
|
|
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
|
+
);
|
|
469
573
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
470
574
|
this.resolvers = {
|
|
471
575
|
resolveAuth: authenticationResolver(prepared.authentications),
|
|
@@ -487,6 +591,7 @@ var AuthoringSession = class {
|
|
|
487
591
|
this.values = /* @__PURE__ */ new Map();
|
|
488
592
|
this.typed = /* @__PURE__ */ new Map();
|
|
489
593
|
this.lastTree = null;
|
|
594
|
+
if (isMobile(input.platform)) return this.startMobile(input.platform, catalog);
|
|
490
595
|
const base = this.baseUrl();
|
|
491
596
|
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
492
597
|
this.page = this.run.page;
|
|
@@ -549,17 +654,7 @@ var AuthoringSession = class {
|
|
|
549
654
|
* the page is new, the page itself, capped. read_page is always the whole
|
|
550
655
|
* thing. */
|
|
551
656
|
async view(full) {
|
|
552
|
-
const {
|
|
553
|
-
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
554
|
-
const tree = numberDuplicates(labelTree(
|
|
555
|
-
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
556
|
-
await fieldNames(page)
|
|
557
|
-
));
|
|
558
|
-
const title = await page.title().catch(() => "");
|
|
559
|
-
const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
|
|
560
|
-
Steps so far: ${draft.steps.length}
|
|
561
|
-
|
|
562
|
-
`;
|
|
657
|
+
const { tree, head } = this.mobile ? await this.mobileTree() : await this.webTree();
|
|
563
658
|
const before = this.lastTree;
|
|
564
659
|
this.lastTree = tree;
|
|
565
660
|
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
@@ -584,8 +679,24 @@ ${removed.join("\n")}` : "",
|
|
|
584
679
|
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
585
680
|
].filter(Boolean).join("\n");
|
|
586
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
|
+
}
|
|
587
695
|
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
588
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);
|
|
589
700
|
const { page, draft, run } = this.requireOpen();
|
|
590
701
|
const navigating = input.action === "navigate" || input.action === "back" || input.action === "forward";
|
|
591
702
|
if (!navigating && input.action !== "press" && !(input.role && input.name)) {
|
|
@@ -639,6 +750,7 @@ ${await this.view(false)}`;
|
|
|
639
750
|
}
|
|
640
751
|
/** Check something about the page now, and keep the check if it holds. */
|
|
641
752
|
async check(input) {
|
|
753
|
+
if (this.mobile) return this.mobileCheck(input);
|
|
642
754
|
const { page, draft } = this.requireOpen();
|
|
643
755
|
if ((input.kind === "heading" || input.kind === "message" || input.kind === "screen") && input.op && input.op !== "equals" && !input.role) {
|
|
644
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}`);
|
|
@@ -674,6 +786,7 @@ ${await this.view(false)}`;
|
|
|
674
786
|
/** Get a value from outside the page — an email code, a 2FA code, a text,
|
|
675
787
|
* an API reply — and keep the step that gets it. */
|
|
676
788
|
async useHelper(input) {
|
|
789
|
+
if (this.mobile) return this.mobileHelper(input);
|
|
677
790
|
const { page, draft, run } = this.requireOpen();
|
|
678
791
|
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
679
792
|
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
@@ -725,7 +838,8 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
|
725
838
|
/** Drop a step from the draft. The browser is not wound back — replay to
|
|
726
839
|
* see the flow as it now stands. */
|
|
727
840
|
remove(number) {
|
|
728
|
-
const
|
|
841
|
+
const draft = this.draft;
|
|
842
|
+
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
729
843
|
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
730
844
|
const [gone] = draft.steps.splice(number - 1, 1);
|
|
731
845
|
return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
|
|
@@ -735,6 +849,7 @@ ${this.list()}`;
|
|
|
735
849
|
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
736
850
|
* run will — the proof the steps work together, not just one at a time. */
|
|
737
851
|
async replay() {
|
|
852
|
+
if (this.mobile) return this.mobileReplay();
|
|
738
853
|
const { draft } = this.requireOpen();
|
|
739
854
|
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
740
855
|
const resolvers = await this.prepare();
|
|
@@ -750,7 +865,8 @@ ${lines.join("\n")}`;
|
|
|
750
865
|
}
|
|
751
866
|
/** Save the draft as a flow on a new branch, for a person to review. */
|
|
752
867
|
async save(title, where = {}) {
|
|
753
|
-
const
|
|
868
|
+
const draft = this.draft;
|
|
869
|
+
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
754
870
|
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
755
871
|
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
756
872
|
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
@@ -766,6 +882,7 @@ ${lines.join("\n")}`;
|
|
|
766
882
|
name: draft.name,
|
|
767
883
|
...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
|
|
768
884
|
startPath: draft.startPath,
|
|
885
|
+
...isMobile(draft.platform) ? { platform: draft.platform } : {},
|
|
769
886
|
steps: withoutTyped(checksOff),
|
|
770
887
|
...draft.signIn ? { signIn: draft.signIn } : {},
|
|
771
888
|
...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
|
|
@@ -799,8 +916,26 @@ ${describeRows(built.rows)}`;
|
|
|
799
916
|
const prepared = await this.service.prepare(projectId, steps, null);
|
|
800
917
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
801
918
|
const base = catalog.baseUrl;
|
|
802
|
-
|
|
803
|
-
|
|
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
|
+
} : {},
|
|
804
939
|
occurrences: testCase.occurrences,
|
|
805
940
|
...testCase.actors ? { actors: testCase.actors } : {},
|
|
806
941
|
...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
|
|
@@ -809,7 +944,7 @@ ${describeRows(built.rows)}`;
|
|
|
809
944
|
resolveAuth: authenticationResolver(prepared.authentications),
|
|
810
945
|
resolveSecret: (ref) => prepared.secrets[ref],
|
|
811
946
|
...runHelper ? { runHelper } : {}
|
|
812
|
-
});
|
|
947
|
+
}).finally(() => lane?.close());
|
|
813
948
|
const lines = outcome.occurrences.map((o, i) => {
|
|
814
949
|
const failed = o.steps.find((st) => st.status === "failed");
|
|
815
950
|
const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
|
|
@@ -829,11 +964,196 @@ Review and publish it here: ${review}`;
|
|
|
829
964
|
}
|
|
830
965
|
async close() {
|
|
831
966
|
const run = this.run;
|
|
967
|
+
const mobile = this.mobile;
|
|
832
968
|
this.run = null;
|
|
833
969
|
this.page = null;
|
|
970
|
+
this.mobile = null;
|
|
834
971
|
this.resolvers = null;
|
|
972
|
+
if (mobile) await mobile.close().catch(() => void 0);
|
|
835
973
|
if (run) await closeRunSession(run, { passed: true }).catch(() => void 0);
|
|
836
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
|
+
}
|
|
837
1157
|
/** Let a click's consequences land before the page is read again. */
|
|
838
1158
|
async settle() {
|
|
839
1159
|
const page = this.page;
|
|
@@ -905,16 +1225,45 @@ as learned. Keep what you learn with write_knowledge (features/<name>.md,
|
|
|
905
1225
|
memory/decisions.md, a session summary), on Claude's branch for review \u2014
|
|
906
1226
|
never a password, key or token.
|
|
907
1227
|
|
|
1228
|
+
UPDATING TEST CASES: when asked to update test cases (upd_\u2026), read the
|
|
1229
|
+
request with read_update_request, then propose_case_updates with each changed
|
|
1230
|
+
case's complete new steps \u2014 keep what the change does not touch word for
|
|
1231
|
+
word, so the person sees exactly what moved. Leave out cases that need no
|
|
1232
|
+
change. You never change a test case yourself; the person accepts.
|
|
1233
|
+
|
|
908
1234
|
CONVERTING A SPREADSHEET: when asked to convert an import (imp_\u2026), read
|
|
909
1235
|
every part with read_import_file, then save_import_cases in batches. Group
|
|
910
1236
|
cases into features by product area (a sheet or section usually is one). Each
|
|
911
1237
|
case is one scenario: name it after what it proves. Steps: preconditions and
|
|
912
1238
|
setup as Given; ONE action per When / And \u2014 split steps that do several
|
|
913
1239
|
things; each expected result as Then / And, after the action it follows.
|
|
914
|
-
Keep every case and every step's meaning, and its id from the file in row
|
|
915
|
-
|
|
1240
|
+
Keep every case and every step's meaning, and its id from the file in row \u2014
|
|
1241
|
+
that id is how a later import of the same file knows the case is already
|
|
1242
|
+
there; never invent a step or drop one. A case found in several sheets is
|
|
1243
|
+
converted once. Use the project's existing feature names (project_catalog)
|
|
1244
|
+
when a case belongs in one, rather than a new look-alike. Rows that are not test cases (titles,
|
|
916
1245
|
notes, empty) are left out. Say in note what you could not convert.
|
|
917
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
|
+
|
|
918
1267
|
HOW TO WORK:
|
|
919
1268
|
1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
|
|
920
1269
|
branch \u2014 rather than writing it again.
|
|
@@ -1043,9 +1392,23 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1043
1392
|
run: async (args) => {
|
|
1044
1393
|
const projectId = await projectFor(args["projectId"]);
|
|
1045
1394
|
const c = await catalogOf(projectId, true);
|
|
1046
|
-
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
|
+
});
|
|
1047
1408
|
return text([
|
|
1048
|
-
`Project ${projectId}.
|
|
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,
|
|
1049
1412
|
c.environments.length ? `Environments: ${c.environments.map((e) => `${e.name} ${e.baseUrl}`).join(", ")}` : "",
|
|
1050
1413
|
`
|
|
1051
1414
|
Features:${c.features.length ? "" : " none yet"}`,
|
|
@@ -1075,13 +1438,15 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
|
1075
1438
|
{
|
|
1076
1439
|
name: "start_flow",
|
|
1077
1440
|
title: "Start writing a flow",
|
|
1078
|
-
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.",
|
|
1079
1442
|
inputSchema: {
|
|
1080
1443
|
type: "object",
|
|
1081
1444
|
properties: {
|
|
1082
1445
|
name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
|
|
1083
1446
|
feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
|
|
1084
|
-
startPath: { type: "string", description:
|
|
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.` },
|
|
1085
1450
|
after: {
|
|
1086
1451
|
type: "array",
|
|
1087
1452
|
items: { type: "string" },
|
|
@@ -1111,16 +1476,23 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
|
1111
1476
|
}
|
|
1112
1477
|
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1113
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;
|
|
1114
1485
|
const page = await session.start({
|
|
1115
1486
|
projectId,
|
|
1487
|
+
...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
|
|
1116
1488
|
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1117
1489
|
name: str(args["name"]) ?? "Untitled flow",
|
|
1118
1490
|
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1119
|
-
startPath: str(args["startPath"]) || "/",
|
|
1491
|
+
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1120
1492
|
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1121
1493
|
}, catalog);
|
|
1122
1494
|
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1123
|
-
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."}
|
|
1124
1496
|
|
|
1125
1497
|
${page}`);
|
|
1126
1498
|
}
|
|
@@ -1128,7 +1500,7 @@ ${page}`);
|
|
|
1128
1500
|
{
|
|
1129
1501
|
name: "read_page",
|
|
1130
1502
|
title: "Read the page",
|
|
1131
|
-
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.",
|
|
1132
1504
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1133
1505
|
annotations: { readOnlyHint: true },
|
|
1134
1506
|
run: async () => text(await session.snapshot())
|
|
@@ -1136,11 +1508,11 @@ ${page}`);
|
|
|
1136
1508
|
{
|
|
1137
1509
|
name: "act",
|
|
1138
1510
|
title: "Do one thing on the page",
|
|
1139
|
-
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).`,
|
|
1140
1512
|
inputSchema: {
|
|
1141
1513
|
type: "object",
|
|
1142
1514
|
properties: {
|
|
1143
|
-
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"] },
|
|
1144
1516
|
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
|
|
1145
1517
|
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1146
1518
|
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
@@ -1152,7 +1524,7 @@ ${page}`);
|
|
|
1152
1524
|
additionalProperties: false
|
|
1153
1525
|
},
|
|
1154
1526
|
run: async (args) => {
|
|
1155
|
-
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");
|
|
1156
1528
|
return text(await session.act({
|
|
1157
1529
|
action: args["action"],
|
|
1158
1530
|
...str(args["role"]) ? { role: str(args["role"]) } : {},
|
|
@@ -1167,7 +1539,7 @@ ${page}`);
|
|
|
1167
1539
|
{
|
|
1168
1540
|
name: "add_check",
|
|
1169
1541
|
title: "Add a check",
|
|
1170
|
-
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.",
|
|
1171
1543
|
inputSchema: {
|
|
1172
1544
|
type: "object",
|
|
1173
1545
|
properties: {
|
|
@@ -1401,6 +1773,53 @@ ${got.text}`);
|
|
|
1401
1773
|
return text(`Saved ${saved.added} more \u2014 ${saved.total} test cases so far.${saved.done ? " Done: the person can now look them over and import them." : " Keep going, and pass done: true with the last batch."}`);
|
|
1402
1774
|
}
|
|
1403
1775
|
},
|
|
1776
|
+
{
|
|
1777
|
+
name: "read_update_request",
|
|
1778
|
+
title: "Read what should change in some test cases",
|
|
1779
|
+
description: "Read a request to update test cases (its id is upd_\u2026): what the person wants changed, and each test case as it is now \u2014 its id, feature, name and business steps.",
|
|
1780
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, request: { type: "string", description: "upd_\u2026" } }, required: ["request"], additionalProperties: false },
|
|
1781
|
+
annotations: { readOnlyHint: true },
|
|
1782
|
+
run: async (args) => {
|
|
1783
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1784
|
+
const r = await service.updateRequest(projectId, str(args["request"]) ?? "");
|
|
1785
|
+
return text([
|
|
1786
|
+
`What should change: ${r.instruction}`,
|
|
1787
|
+
...r.cases.map((c) => [`
|
|
1788
|
+
${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no steps written)"]].join("\n"))
|
|
1789
|
+
].join("\n"));
|
|
1790
|
+
}
|
|
1791
|
+
},
|
|
1792
|
+
{
|
|
1793
|
+
name: "propose_case_updates",
|
|
1794
|
+
title: "Propose new steps for test cases",
|
|
1795
|
+
description: "Propose the new business steps for test cases in an update request (upd_\u2026): for each case changed, its id and ALL its steps as Given / When / Then / And lines (the whole case, not only what changed). Cases that need no change are left out. The person sees each before and after and accepts the ones they want; nothing changes until they do. done: true with the last batch; note: anything you could not do.",
|
|
1796
|
+
inputSchema: {
|
|
1797
|
+
type: "object",
|
|
1798
|
+
properties: {
|
|
1799
|
+
projectId: { type: "string" },
|
|
1800
|
+
request: { type: "string", description: "upd_\u2026" },
|
|
1801
|
+
updates: {
|
|
1802
|
+
type: "array",
|
|
1803
|
+
items: {
|
|
1804
|
+
type: "object",
|
|
1805
|
+
properties: { case: { type: "string", description: "The test case's id, tc_\u2026" }, steps: { type: "array", items: { type: "string" } }, note: { type: "string", description: "Why, in a few words." } },
|
|
1806
|
+
required: ["case", "steps"],
|
|
1807
|
+
additionalProperties: false
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
done: { type: "boolean" },
|
|
1811
|
+
note: { type: "string" }
|
|
1812
|
+
},
|
|
1813
|
+
required: ["request", "updates"],
|
|
1814
|
+
additionalProperties: false
|
|
1815
|
+
},
|
|
1816
|
+
run: async (args) => {
|
|
1817
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1818
|
+
const updates = Array.isArray(args["updates"]) ? args["updates"] : [];
|
|
1819
|
+
const r = await service.proposeUpdates(projectId, str(args["request"]) ?? "", { updates, ...args["done"] === true ? { done: true } : {}, ...typeof args["note"] === "string" ? { note: args["note"] } : {} });
|
|
1820
|
+
return text(`Proposed new steps for ${r.proposed} of ${r.of} test cases.${r.done ? " Done: the person can now accept them." : " Pass done: true with the last batch."}`);
|
|
1821
|
+
}
|
|
1822
|
+
},
|
|
1404
1823
|
{
|
|
1405
1824
|
name: "read_test_cases",
|
|
1406
1825
|
title: "Read test cases",
|
|
@@ -1654,7 +2073,7 @@ ${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") |
|
|
|
1654
2073
|
{
|
|
1655
2074
|
name: "close_browser",
|
|
1656
2075
|
title: "Close the browser",
|
|
1657
|
-
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.",
|
|
1658
2077
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1659
2078
|
annotations: { destructiveHint: true },
|
|
1660
2079
|
run: async () => {
|
|
@@ -1725,6 +2144,10 @@ ${bold("USAGE")}
|
|
|
1725
2144
|
flowriki-agent doctor diagnose setup problems
|
|
1726
2145
|
flowriki-agent prepare download the browser recording needs
|
|
1727
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
|
|
1728
2151
|
flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
|
|
1729
2152
|
Claude Code or Claude Desktop, not by you:
|
|
1730
2153
|
claude mcp add flowriki -- flowriki-agent mcp
|
|
@@ -1920,6 +2343,42 @@ Run \`flowriki-agent --help\`.`);
|
|
|
1920
2343
|
}
|
|
1921
2344
|
return 0;
|
|
1922
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
|
+
}
|
|
1923
2382
|
case "projects": {
|
|
1924
2383
|
const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
|
|
1925
2384
|
for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);
|