@flowriki/agent 0.1.0-beta.45 → 0.1.0-beta.47
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/README.md +8 -0
- package/dist/{chunk-JJB45ZTL.js → chunk-AMXSHZYE.js} +3053 -659
- package/dist/cli.js +668 -43
- package/dist/engine.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,29 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
AGENT_LATEST_URL,
|
|
3
4
|
AGENT_VERSION,
|
|
5
|
+
CLOUD_PROVIDERS,
|
|
6
|
+
CLOUD_PROVIDER_NAME,
|
|
7
|
+
MobileLane,
|
|
4
8
|
RecordedStepSchema,
|
|
9
|
+
agentUpdateFor,
|
|
5
10
|
authenticationResolver,
|
|
6
11
|
checkAssertion,
|
|
12
|
+
checkMobileAssertion,
|
|
7
13
|
closeRunSession,
|
|
8
14
|
connect,
|
|
9
15
|
connectionFor,
|
|
16
|
+
dataDir,
|
|
10
17
|
disconnect,
|
|
11
18
|
doctor,
|
|
12
19
|
executeStep,
|
|
13
20
|
fieldNames,
|
|
14
21
|
helperRunner,
|
|
22
|
+
isMobile,
|
|
23
|
+
listDevices,
|
|
24
|
+
locate,
|
|
15
25
|
mirrorCheckTargets,
|
|
26
|
+
mobileDoctor,
|
|
27
|
+
mobileOf,
|
|
28
|
+
mobileSetup,
|
|
29
|
+
openMobileSession,
|
|
16
30
|
openRunSession,
|
|
17
31
|
parseGherkin,
|
|
32
|
+
performMobileStep,
|
|
33
|
+
platformsOf,
|
|
18
34
|
prepareBrowsers,
|
|
19
35
|
replayCase,
|
|
20
36
|
replayFlow,
|
|
37
|
+
replayMobileCase,
|
|
21
38
|
resolveCheckouts,
|
|
22
39
|
resolveService,
|
|
40
|
+
runMobileSteps,
|
|
23
41
|
runtimeValue,
|
|
42
|
+
screenLines,
|
|
24
43
|
start,
|
|
25
|
-
status
|
|
26
|
-
|
|
44
|
+
status,
|
|
45
|
+
stepRefusedOn,
|
|
46
|
+
targetFor
|
|
47
|
+
} from "./chunk-AMXSHZYE.js";
|
|
27
48
|
|
|
28
49
|
// src/cli.ts
|
|
29
50
|
import { parseArgs } from "util";
|
|
@@ -119,8 +140,8 @@ var ServiceClient = class {
|
|
|
119
140
|
get origin() {
|
|
120
141
|
return this.connection.service;
|
|
121
142
|
}
|
|
122
|
-
async call(
|
|
123
|
-
const res = await this.fetcher(`${this.connection.service}${
|
|
143
|
+
async call(path2, init = {}) {
|
|
144
|
+
const res = await this.fetcher(`${this.connection.service}${path2}`, {
|
|
124
145
|
...init,
|
|
125
146
|
headers: {
|
|
126
147
|
"x-flowriki-device": this.connection.deviceId,
|
|
@@ -154,10 +175,12 @@ var ServiceClient = class {
|
|
|
154
175
|
catalog(projectId) {
|
|
155
176
|
return this.call(`/api/agent/catalog?projectId=${encodeURIComponent(projectId)}`);
|
|
156
177
|
}
|
|
157
|
-
|
|
178
|
+
/** `cloud` asks for that device farm's access key too, for a flow written
|
|
179
|
+
* on one of its phones. */
|
|
180
|
+
prepare(projectId, steps, signIn, extra = {}) {
|
|
158
181
|
return this.call(`/api/agent/prepare?projectId=${encodeURIComponent(projectId)}`, {
|
|
159
182
|
method: "POST",
|
|
160
|
-
body: JSON.stringify({ steps, ...signIn ? { signIn } : {} })
|
|
183
|
+
body: JSON.stringify({ steps, ...signIn ? { signIn } : {}, ...extra.cloud ? { cloud: extra.cloud } : {} })
|
|
161
184
|
});
|
|
162
185
|
}
|
|
163
186
|
/** This person's open branches that Claude wrote on this project. */
|
|
@@ -260,9 +283,9 @@ var ServiceClient = class {
|
|
|
260
283
|
}
|
|
261
284
|
// ── Manual cycles and bugs: the dashboard's own routes, behind this
|
|
262
285
|
// machine's credential, so the assistant can do what a person can. ──────
|
|
263
|
-
scoped(
|
|
286
|
+
scoped(path2, projectId, extra = {}) {
|
|
264
287
|
const query = new URLSearchParams({ projectId, ...extra });
|
|
265
|
-
return `/api/agent${
|
|
288
|
+
return `/api/agent${path2}?${query}`;
|
|
266
289
|
}
|
|
267
290
|
cycles(projectId) {
|
|
268
291
|
return this.call(this.scoped("/cycles", projectId));
|
|
@@ -301,8 +324,8 @@ var ServiceClient = class {
|
|
|
301
324
|
knowledgePages(projectId) {
|
|
302
325
|
return this.call(this.scoped("/knowledge", projectId));
|
|
303
326
|
}
|
|
304
|
-
knowledgePage(projectId,
|
|
305
|
-
return this.call(this.scoped("/knowledge/page", projectId, { path }));
|
|
327
|
+
knowledgePage(projectId, path2) {
|
|
328
|
+
return this.call(this.scoped("/knowledge/page", projectId, { path: path2 }));
|
|
306
329
|
}
|
|
307
330
|
writeKnowledge(projectId, body) {
|
|
308
331
|
return this.call(`/api/agent/knowledge?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(body) });
|
|
@@ -361,7 +384,31 @@ var STEP_TIMEOUT_MS = 1e4;
|
|
|
361
384
|
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
385
|
var TYPED = "typed_";
|
|
363
386
|
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
364
|
-
var ACTIONS = [
|
|
387
|
+
var ACTIONS = [
|
|
388
|
+
"click",
|
|
389
|
+
"dblclick",
|
|
390
|
+
"rightclick",
|
|
391
|
+
"hover",
|
|
392
|
+
"fill",
|
|
393
|
+
"select",
|
|
394
|
+
"check",
|
|
395
|
+
"press",
|
|
396
|
+
"navigate",
|
|
397
|
+
"back",
|
|
398
|
+
"forward",
|
|
399
|
+
// On a phone: a tap is a click, and these have no web equivalent.
|
|
400
|
+
"tap",
|
|
401
|
+
"longPress",
|
|
402
|
+
"swipe",
|
|
403
|
+
"scroll",
|
|
404
|
+
"hideKeyboard",
|
|
405
|
+
"launchApp",
|
|
406
|
+
"terminateApp",
|
|
407
|
+
"resetApp",
|
|
408
|
+
"background",
|
|
409
|
+
"rotate",
|
|
410
|
+
"permission"
|
|
411
|
+
];
|
|
365
412
|
var isAction = (value) => ACTIONS.includes(String(value));
|
|
366
413
|
var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
|
|
367
414
|
function scopeOf(within) {
|
|
@@ -404,6 +451,24 @@ function numberDuplicates(tree) {
|
|
|
404
451
|
return `${l} [nth ${at} of ${total} with this name]`;
|
|
405
452
|
}).join("\n");
|
|
406
453
|
}
|
|
454
|
+
function deviceFrom(said, platform, devices, mobile) {
|
|
455
|
+
const want = said?.trim();
|
|
456
|
+
if (!want) return mobile?.devices[platform];
|
|
457
|
+
const lower = want.toLowerCase();
|
|
458
|
+
const mine = devices.filter((d) => d.platform === platform);
|
|
459
|
+
const local = mine.find((d) => d.id.toLowerCase() === lower || d.name.toLowerCase() === lower) ?? mine.find((d) => d.name.toLowerCase().includes(lower));
|
|
460
|
+
if (local) return { kind: "local", id: local.id, name: local.name };
|
|
461
|
+
const farm = /^([^:]+):\s*(.+?)(?:\s+(\d+(?:\.\d+)*))?$/.exec(want);
|
|
462
|
+
const provider = farm && CLOUD_PROVIDERS.find((p) => p === farm[1].trim().toLowerCase().replace(/\s+/g, "") || CLOUD_PROVIDER_NAME[p].toLowerCase() === farm[1].trim().toLowerCase());
|
|
463
|
+
if (farm && provider) {
|
|
464
|
+
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`);
|
|
465
|
+
return { kind: "cloud", provider, device: farm[2].trim(), ...farm[3] ? { osVersion: farm[3] } : {} };
|
|
466
|
+
}
|
|
467
|
+
throw new Error(`no ${platform === "ios" ? "iOS" : "Android"} device called "${want}" \u2014 connected: ${mine.map((d) => d.name).join(", ") || "none"}`);
|
|
468
|
+
}
|
|
469
|
+
async function devicesSummary() {
|
|
470
|
+
return listDevices().catch(() => []);
|
|
471
|
+
}
|
|
407
472
|
function describeRows(rows) {
|
|
408
473
|
return rows.map((r) => [
|
|
409
474
|
`Row ${r.row}: "${r.flow}" (${r.browser})`,
|
|
@@ -431,6 +496,26 @@ function describe(step) {
|
|
|
431
496
|
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
497
|
case "useHelper":
|
|
433
498
|
return `use helper ${step.name || step.helper?.helperId} \u2192 {{${step.helper?.saveAs}}}${step.helper?.then === "open" ? ", then open it" : ""}`;
|
|
499
|
+
case "swipe":
|
|
500
|
+
return `swipe ${step.value}${target ? ` on ${target}` : ""}`;
|
|
501
|
+
case "scroll":
|
|
502
|
+
return target ? `scroll to ${target}` : `scroll ${step.value}`;
|
|
503
|
+
case "longPress":
|
|
504
|
+
return `long-press ${target}${where}`;
|
|
505
|
+
case "hideKeyboard":
|
|
506
|
+
return "hide the keyboard";
|
|
507
|
+
case "launchApp":
|
|
508
|
+
return "open the app";
|
|
509
|
+
case "terminateApp":
|
|
510
|
+
return "close the app";
|
|
511
|
+
case "resetApp":
|
|
512
|
+
return "clear the app's data and open it";
|
|
513
|
+
case "background":
|
|
514
|
+
return `send the app to the background for ${step.value || 3}s`;
|
|
515
|
+
case "rotate":
|
|
516
|
+
return `rotate to ${step.value}`;
|
|
517
|
+
case "permission":
|
|
518
|
+
return `${step.value === "deny" ? "deny" : "allow"} the permission prompt`;
|
|
434
519
|
default:
|
|
435
520
|
return `${step.type} ${target}${where}`;
|
|
436
521
|
}
|
|
@@ -444,6 +529,8 @@ var AuthoringSession = class {
|
|
|
444
529
|
opts;
|
|
445
530
|
run = null;
|
|
446
531
|
page = null;
|
|
532
|
+
/** The phone, when the flow being written runs on one. */
|
|
533
|
+
mobile = null;
|
|
447
534
|
values = /* @__PURE__ */ new Map();
|
|
448
535
|
resolvers = null;
|
|
449
536
|
lastTree = null;
|
|
@@ -465,7 +552,11 @@ var AuthoringSession = class {
|
|
|
465
552
|
return out;
|
|
466
553
|
}
|
|
467
554
|
get open() {
|
|
468
|
-
return this.run !== null;
|
|
555
|
+
return this.run !== null || this.mobile !== null;
|
|
556
|
+
}
|
|
557
|
+
requireMobile() {
|
|
558
|
+
if (!this.mobile || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
559
|
+
return { mobile: this.mobile, draft: this.draft };
|
|
469
560
|
}
|
|
470
561
|
requireOpen() {
|
|
471
562
|
if (!this.run || !this.page || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
@@ -476,7 +567,12 @@ var AuthoringSession = class {
|
|
|
476
567
|
* helper or sign-in appears, never kept beyond this process. */
|
|
477
568
|
async prepare(extra = []) {
|
|
478
569
|
const draft = this.draft;
|
|
479
|
-
const prepared = await this.service.prepare(
|
|
570
|
+
const prepared = await this.service.prepare(
|
|
571
|
+
draft.projectId,
|
|
572
|
+
withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]),
|
|
573
|
+
draft.signIn ?? null,
|
|
574
|
+
draft.device?.kind === "cloud" ? { cloud: draft.device.provider } : {}
|
|
575
|
+
);
|
|
480
576
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
481
577
|
this.resolvers = {
|
|
482
578
|
resolveAuth: authenticationResolver(prepared.authentications),
|
|
@@ -498,6 +594,7 @@ var AuthoringSession = class {
|
|
|
498
594
|
this.values = /* @__PURE__ */ new Map();
|
|
499
595
|
this.typed = /* @__PURE__ */ new Map();
|
|
500
596
|
this.lastTree = null;
|
|
597
|
+
if (isMobile(input.platform)) return this.startMobile(input.platform, catalog);
|
|
501
598
|
const base = this.baseUrl();
|
|
502
599
|
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
503
600
|
this.page = this.run.page;
|
|
@@ -560,17 +657,7 @@ var AuthoringSession = class {
|
|
|
560
657
|
* the page is new, the page itself, capped. read_page is always the whole
|
|
561
658
|
* thing. */
|
|
562
659
|
async view(full) {
|
|
563
|
-
const {
|
|
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
|
-
`;
|
|
660
|
+
const { tree, head } = this.mobile ? await this.mobileTree() : await this.webTree();
|
|
574
661
|
const before = this.lastTree;
|
|
575
662
|
this.lastTree = tree;
|
|
576
663
|
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
@@ -595,8 +682,24 @@ ${removed.join("\n")}` : "",
|
|
|
595
682
|
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
596
683
|
].filter(Boolean).join("\n");
|
|
597
684
|
}
|
|
685
|
+
async webTree() {
|
|
686
|
+
const { page, draft } = this.requireOpen();
|
|
687
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
688
|
+
const tree = numberDuplicates(labelTree(
|
|
689
|
+
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
690
|
+
await fieldNames(page)
|
|
691
|
+
));
|
|
692
|
+
const title = await page.title().catch(() => "");
|
|
693
|
+
return { tree, head: `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
|
|
694
|
+
Steps so far: ${draft.steps.length}
|
|
695
|
+
|
|
696
|
+
` };
|
|
697
|
+
}
|
|
598
698
|
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
599
699
|
async act(input) {
|
|
700
|
+
if (this.mobile) return this.mobileAct(input);
|
|
701
|
+
const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
|
|
702
|
+
if (refused) throw new Error(refused);
|
|
600
703
|
const { page, draft, run } = this.requireOpen();
|
|
601
704
|
const navigating = input.action === "navigate" || input.action === "back" || input.action === "forward";
|
|
602
705
|
if (!navigating && input.action !== "press" && !(input.role && input.name)) {
|
|
@@ -650,6 +753,7 @@ ${await this.view(false)}`;
|
|
|
650
753
|
}
|
|
651
754
|
/** Check something about the page now, and keep the check if it holds. */
|
|
652
755
|
async check(input) {
|
|
756
|
+
if (this.mobile) return this.mobileCheck(input);
|
|
653
757
|
const { page, draft } = this.requireOpen();
|
|
654
758
|
if ((input.kind === "heading" || input.kind === "message" || input.kind === "screen") && input.op && input.op !== "equals" && !input.role) {
|
|
655
759
|
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 +789,7 @@ ${await this.view(false)}`;
|
|
|
685
789
|
/** Get a value from outside the page — an email code, a 2FA code, a text,
|
|
686
790
|
* an API reply — and keep the step that gets it. */
|
|
687
791
|
async useHelper(input) {
|
|
792
|
+
if (this.mobile) return this.mobileHelper(input);
|
|
688
793
|
const { page, draft, run } = this.requireOpen();
|
|
689
794
|
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
690
795
|
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
@@ -736,7 +841,8 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
|
736
841
|
/** Drop a step from the draft. The browser is not wound back — replay to
|
|
737
842
|
* see the flow as it now stands. */
|
|
738
843
|
remove(number) {
|
|
739
|
-
const
|
|
844
|
+
const draft = this.draft;
|
|
845
|
+
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
740
846
|
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
741
847
|
const [gone] = draft.steps.splice(number - 1, 1);
|
|
742
848
|
return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
|
|
@@ -746,6 +852,7 @@ ${this.list()}`;
|
|
|
746
852
|
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
747
853
|
* run will — the proof the steps work together, not just one at a time. */
|
|
748
854
|
async replay() {
|
|
855
|
+
if (this.mobile) return this.mobileReplay();
|
|
749
856
|
const { draft } = this.requireOpen();
|
|
750
857
|
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
751
858
|
const resolvers = await this.prepare();
|
|
@@ -761,7 +868,8 @@ ${lines.join("\n")}`;
|
|
|
761
868
|
}
|
|
762
869
|
/** Save the draft as a flow on a new branch, for a person to review. */
|
|
763
870
|
async save(title, where = {}) {
|
|
764
|
-
const
|
|
871
|
+
const draft = this.draft;
|
|
872
|
+
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
765
873
|
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
766
874
|
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
767
875
|
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
@@ -777,6 +885,7 @@ ${lines.join("\n")}`;
|
|
|
777
885
|
name: draft.name,
|
|
778
886
|
...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
|
|
779
887
|
startPath: draft.startPath,
|
|
888
|
+
...isMobile(draft.platform) ? { platform: draft.platform } : {},
|
|
780
889
|
steps: withoutTyped(checksOff),
|
|
781
890
|
...draft.signIn ? { signIn: draft.signIn } : {},
|
|
782
891
|
...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
|
|
@@ -810,8 +919,26 @@ ${describeRows(built.rows)}`;
|
|
|
810
919
|
const prepared = await this.service.prepare(projectId, steps, null);
|
|
811
920
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
812
921
|
const base = catalog.baseUrl;
|
|
813
|
-
|
|
814
|
-
|
|
922
|
+
const phoneRows = testCase.occurrences.filter((o) => isMobile(o.platform));
|
|
923
|
+
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");
|
|
924
|
+
const laneOptions = {
|
|
925
|
+
mobile: catalog.mobile ?? mobileOf(null),
|
|
926
|
+
resolveSecret: (ref) => prepared.secrets[ref],
|
|
927
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
928
|
+
...runHelper ? { runHelper } : {}
|
|
929
|
+
};
|
|
930
|
+
const lane = phoneRows.length ? new MobileLane(laneOptions) : null;
|
|
931
|
+
const opened = /* @__PURE__ */ new Set();
|
|
932
|
+
const outcome = phoneRows.length === testCase.occurrences.length ? await replayMobileCase({ ...laneOptions, occurrences: testCase.occurrences, ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {} }) : await replayCase({
|
|
933
|
+
...lane ? {
|
|
934
|
+
runElsewhere: (occurrence, ctx) => {
|
|
935
|
+
if (!isMobile(occurrence.platform)) return null;
|
|
936
|
+
const key = occurrence.actorId ?? testCase.defaultActorId ?? "default";
|
|
937
|
+
const first = !opened.has(key);
|
|
938
|
+
opened.add(key);
|
|
939
|
+
return lane.run(occurrence, { deviceKey: key, first, ...ctx });
|
|
940
|
+
}
|
|
941
|
+
} : {},
|
|
815
942
|
occurrences: testCase.occurrences,
|
|
816
943
|
...testCase.actors ? { actors: testCase.actors } : {},
|
|
817
944
|
...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
|
|
@@ -820,7 +947,7 @@ ${describeRows(built.rows)}`;
|
|
|
820
947
|
resolveAuth: authenticationResolver(prepared.authentications),
|
|
821
948
|
resolveSecret: (ref) => prepared.secrets[ref],
|
|
822
949
|
...runHelper ? { runHelper } : {}
|
|
823
|
-
});
|
|
950
|
+
}).finally(() => lane?.close());
|
|
824
951
|
const lines = outcome.occurrences.map((o, i) => {
|
|
825
952
|
const failed = o.steps.find((st) => st.status === "failed");
|
|
826
953
|
const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
|
|
@@ -840,11 +967,196 @@ Review and publish it here: ${review}`;
|
|
|
840
967
|
}
|
|
841
968
|
async close() {
|
|
842
969
|
const run = this.run;
|
|
970
|
+
const mobile = this.mobile;
|
|
843
971
|
this.run = null;
|
|
844
972
|
this.page = null;
|
|
973
|
+
this.mobile = null;
|
|
845
974
|
this.resolvers = null;
|
|
975
|
+
if (mobile) await mobile.close().catch(() => void 0);
|
|
846
976
|
if (run) await closeRunSession(run, { passed: true }).catch(() => void 0);
|
|
847
977
|
}
|
|
978
|
+
// ── On a phone ────────────────────────────────────────────────────────
|
|
979
|
+
/** Open the project's app on the chosen phone, signed in and brought to
|
|
980
|
+
* where the flow starts, as on the web. */
|
|
981
|
+
async startMobile(platform, catalog) {
|
|
982
|
+
const draft = this.draft;
|
|
983
|
+
const mobile = catalog.mobile;
|
|
984
|
+
if (!mobile) throw new Error("this project has no phone apps set up \u2014 turn on Android or iOS in Settings \u2192 Mobile");
|
|
985
|
+
const resolvers = draft.signIn || draft.device?.kind === "cloud" ? await this.prepare() : null;
|
|
986
|
+
this.mobile = await openMobileSession({
|
|
987
|
+
platform,
|
|
988
|
+
mobile,
|
|
989
|
+
...draft.device ? { device: draft.device } : {},
|
|
990
|
+
...resolvers ? { resolveSecret: resolvers.resolveSecret } : {}
|
|
991
|
+
});
|
|
992
|
+
const opening = [
|
|
993
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
994
|
+
...draft.preparation ?? []
|
|
995
|
+
];
|
|
996
|
+
if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await this.mobile.openLink(draft.startPath);
|
|
997
|
+
if (opening.length) {
|
|
998
|
+
const ready = await this.prepare(draft.preparation ?? []);
|
|
999
|
+
const done = await runMobileSteps(this.mobile, opening, { values: this.values, ...ready });
|
|
1000
|
+
if (done.status !== "passed") throw new Error(`The app could not be brought to where this flow starts: ${done.error}`);
|
|
1001
|
+
}
|
|
1002
|
+
return this.snapshot();
|
|
1003
|
+
}
|
|
1004
|
+
async mobileTree() {
|
|
1005
|
+
const { mobile, draft } = this.requireMobile();
|
|
1006
|
+
mobile.changed();
|
|
1007
|
+
let screen = await mobile.screen();
|
|
1008
|
+
if (screen.nodes.length === 0) screen = await mobile.settle(8e3);
|
|
1009
|
+
const where = await mobile.currentScreenName();
|
|
1010
|
+
const tree = numberDuplicates(screenLines(screen)) || "(nothing on the screen can be read \u2014 the app may still be loading, or draws without accessibility labels)";
|
|
1011
|
+
return { tree, head: `Screen: ${screen.app || mobile.appId} on ${mobile.deviceName}${where ? ` \u2014 ${where}` : ""}
|
|
1012
|
+
Steps so far: ${draft.steps.length}
|
|
1013
|
+
|
|
1014
|
+
` };
|
|
1015
|
+
}
|
|
1016
|
+
mobileResolvers() {
|
|
1017
|
+
return {
|
|
1018
|
+
values: this.values,
|
|
1019
|
+
...this.resolvers ?? {},
|
|
1020
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref)
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
async mobileAct(input) {
|
|
1024
|
+
const { mobile, draft } = this.requireMobile();
|
|
1025
|
+
const type = input.action === "tap" ? "click" : input.action;
|
|
1026
|
+
const refused = stepRefusedOn(type, mobile.platform);
|
|
1027
|
+
if (refused) throw new Error(refused);
|
|
1028
|
+
const needsElement = ["click", "longPress", "fill", "select", "check"].includes(type);
|
|
1029
|
+
if (needsElement && !(input.role && (input.name || input.nth !== void 0))) {
|
|
1030
|
+
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
1031
|
+
}
|
|
1032
|
+
if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
|
|
1033
|
+
let target = { role: "", name: "" };
|
|
1034
|
+
let password = false;
|
|
1035
|
+
if (input.role) {
|
|
1036
|
+
const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
|
|
1037
|
+
try {
|
|
1038
|
+
const found = await locate(mobile, asked, STEP_TIMEOUT_MS, { scroll: type !== "swipe" });
|
|
1039
|
+
target = { ...targetFor(found.screen, found.node), ...asked };
|
|
1040
|
+
password = found.node.password;
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
const said = error.message.split("\n")[0] ?? "";
|
|
1043
|
+
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." : "";
|
|
1044
|
+
throw new Error(`That did not work, and was not kept: ${said}${several}
|
|
1045
|
+
|
|
1046
|
+
${await this.view(false)}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const secret = type === "fill" && (input.secret ?? (password || SECRET_WORDS.test(input.name ?? "")));
|
|
1050
|
+
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
1051
|
+
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
1052
|
+
const value = secret ? "" : type === "check" ? input.value === "false" ? "false" : "true" : type === "swipe" || type === "scroll" && !input.role ? (input.value || "up").toLowerCase() : input.value ?? "";
|
|
1053
|
+
const step = RecordedStepSchema.parse({
|
|
1054
|
+
id,
|
|
1055
|
+
type,
|
|
1056
|
+
...target,
|
|
1057
|
+
value,
|
|
1058
|
+
secret,
|
|
1059
|
+
...secret ? { secretRef: `${TYPED}${id}` } : {},
|
|
1060
|
+
legacySelector: "",
|
|
1061
|
+
proposals: []
|
|
1062
|
+
});
|
|
1063
|
+
try {
|
|
1064
|
+
await performMobileStep(mobile, step, this.mobileResolvers());
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
if (secret) this.typed.delete(`${TYPED}${id}`);
|
|
1067
|
+
throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
|
|
1068
|
+
|
|
1069
|
+
${await this.view(false)}`);
|
|
1070
|
+
}
|
|
1071
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1072
|
+
draft.steps.push(step);
|
|
1073
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
1074
|
+
|
|
1075
|
+
${await this.view(false)}`;
|
|
1076
|
+
}
|
|
1077
|
+
async mobileCheck(input) {
|
|
1078
|
+
const { mobile, draft } = this.requireMobile();
|
|
1079
|
+
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`);
|
|
1080
|
+
const assertion = RecordedStepSchema.shape.proposals.element.parse({
|
|
1081
|
+
kind: input.kind,
|
|
1082
|
+
value: input.value,
|
|
1083
|
+
enabled: true,
|
|
1084
|
+
...input.op && input.op !== "equals" ? { op: input.op } : {},
|
|
1085
|
+
...input.role ? { target: { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} } } : {}
|
|
1086
|
+
});
|
|
1087
|
+
const filled = (text2) => text2.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => runtimeValue(this.values, name));
|
|
1088
|
+
const expected = Array.isArray(assertion.value) ? assertion.value.map(filled) : filled(assertion.value);
|
|
1089
|
+
try {
|
|
1090
|
+
await checkMobileAssertion(mobile, { ...assertion, value: expected }, 5e3);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
throw new Error(`That is not true of the screen right now, so it was not kept: ${error.message.split("\n")[0]}`);
|
|
1093
|
+
}
|
|
1094
|
+
const step = RecordedStepSchema.parse(mirrorCheckTargets([{
|
|
1095
|
+
id: `c${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
1096
|
+
type: "assert",
|
|
1097
|
+
role: "",
|
|
1098
|
+
name: "",
|
|
1099
|
+
scope: "",
|
|
1100
|
+
value: "",
|
|
1101
|
+
secret: false,
|
|
1102
|
+
legacySelector: "",
|
|
1103
|
+
proposals: [assertion]
|
|
1104
|
+
}])[0]);
|
|
1105
|
+
draft.steps.push(step);
|
|
1106
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
1107
|
+
}
|
|
1108
|
+
async mobileHelper(input) {
|
|
1109
|
+
const { mobile, draft } = this.requireMobile();
|
|
1110
|
+
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
1111
|
+
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
1112
|
+
const missing = helper.inputs.filter((name) => !(input.inputs ?? {})[name]);
|
|
1113
|
+
if (missing.length) throw new Error(`${helper.name} needs ${missing.map((n) => `{{${n}}}`).join(", ")} in inputs`);
|
|
1114
|
+
const step = RecordedStepSchema.parse({
|
|
1115
|
+
id: `h${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
1116
|
+
type: "useHelper",
|
|
1117
|
+
role: "",
|
|
1118
|
+
name: helper.name,
|
|
1119
|
+
scope: "",
|
|
1120
|
+
value: "",
|
|
1121
|
+
secret: false,
|
|
1122
|
+
legacySelector: "",
|
|
1123
|
+
proposals: [],
|
|
1124
|
+
helper: { helperId: helper.id, inputs: input.inputs ?? {}, saveAs: input.saveAs || (helper.kind === "mailbox" ? "code" : "value"), then: input.then ?? "remember" }
|
|
1125
|
+
});
|
|
1126
|
+
await this.prepare([step]);
|
|
1127
|
+
let got;
|
|
1128
|
+
try {
|
|
1129
|
+
got = await performMobileStep(mobile, step, this.mobileResolvers());
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
|
|
1132
|
+
}
|
|
1133
|
+
draft.steps.push(step);
|
|
1134
|
+
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" ? `
|
|
1135
|
+
|
|
1136
|
+
${await this.view(false)}` : "");
|
|
1137
|
+
}
|
|
1138
|
+
/** The whole flow again from the app's start, on the same phone — a phone
|
|
1139
|
+
* holds one session at a time, so the app is closed and opened rather than
|
|
1140
|
+
* a second device started. */
|
|
1141
|
+
async mobileReplay() {
|
|
1142
|
+
const { mobile, draft } = this.requireMobile();
|
|
1143
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
1144
|
+
const resolvers = await this.prepare();
|
|
1145
|
+
const steps = [
|
|
1146
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
1147
|
+
...draft.preparation ?? [],
|
|
1148
|
+
...draft.steps
|
|
1149
|
+
];
|
|
1150
|
+
await mobile.terminateApp().catch(() => void 0);
|
|
1151
|
+
await mobile.launchApp();
|
|
1152
|
+
await mobile.settle();
|
|
1153
|
+
if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await mobile.openLink(draft.startPath);
|
|
1154
|
+
const outcome = await runMobileSteps(mobile, steps, { values: /* @__PURE__ */ new Map(), ...resolvers, resolveSecret: (ref) => this.typed.get(ref) ?? resolvers.resolveSecret(ref) });
|
|
1155
|
+
this.lastTree = null;
|
|
1156
|
+
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}` : ""}`);
|
|
1157
|
+
return `${outcome.status === "passed" ? "The whole flow passed from the app's start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
1158
|
+
${lines.join("\n")}`;
|
|
1159
|
+
}
|
|
848
1160
|
/** Let a click's consequences land before the page is read again. */
|
|
849
1161
|
async settle() {
|
|
850
1162
|
const page = this.page;
|
|
@@ -935,6 +1247,26 @@ converted once. Use the project's existing feature names (project_catalog)
|
|
|
935
1247
|
when a case belongs in one, rather than a new look-alike. Rows that are not test cases (titles,
|
|
936
1248
|
notes, empty) are left out. Say in note what you could not convert.
|
|
937
1249
|
|
|
1250
|
+
PHONE APPS (Android, iOS): a project can test its Android and iOS apps as well
|
|
1251
|
+
as, or instead of, a website \u2014 project_catalog says which, names the app, and
|
|
1252
|
+
lists the phones connected to this computer. start_flow with platform
|
|
1253
|
+
"android" or "ios" opens the app on a phone (device: a name from the catalog,
|
|
1254
|
+
"BrowserStack: Samsung Galaxy S23", or nothing for whatever is connected).
|
|
1255
|
+
read_page then reads the SCREEN as roles and names, just like a page, and act
|
|
1256
|
+
and add_check work the same way. On a phone: tap (= click) a button, fill a
|
|
1257
|
+
textbox, swipe up/down/left/right (value; on an element, or the screen), scroll
|
|
1258
|
+
to an element (role + name) or by direction, longPress, back (Android only),
|
|
1259
|
+
hideKeyboard, permission (value "allow" or "deny") for a system prompt, and
|
|
1260
|
+
launchApp / terminateApp / resetApp / background (value: seconds) / rotate
|
|
1261
|
+
(value: portrait or landscape). navigate opens a deep link. Never tap by
|
|
1262
|
+
position \u2014 use the role and name; [id=\u2026] on a line is the app's own id for it,
|
|
1263
|
+
and is used automatically. A phone app has no URL or page title: check a
|
|
1264
|
+
message (text on the screen), an element's text, or that something is visible.
|
|
1265
|
+
The app keeps its data between runs unless the project says otherwise, so a
|
|
1266
|
+
sign-in step is skipped when the app is already signed in. A test case can
|
|
1267
|
+
mix rows on a phone with rows in a browser (a customer orders in the app, an
|
|
1268
|
+
admin approves on the website).
|
|
1269
|
+
|
|
938
1270
|
HOW TO WORK:
|
|
939
1271
|
1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
|
|
940
1272
|
branch \u2014 rather than writing it again.
|
|
@@ -1063,9 +1395,23 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1063
1395
|
run: async (args) => {
|
|
1064
1396
|
const projectId = await projectFor(args["projectId"]);
|
|
1065
1397
|
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");
|
|
1398
|
+
const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
|
|
1399
|
+
const platforms = platformsOf(c);
|
|
1400
|
+
const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
|
|
1401
|
+
const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
|
|
1402
|
+
const app = p === "android" ? c.mobile?.android?.appPackage : c.mobile?.ios?.bundleId;
|
|
1403
|
+
const here = phones.filter((d) => d.platform === p);
|
|
1404
|
+
const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
|
|
1405
|
+
const chosen = c.mobile?.devices[p];
|
|
1406
|
+
return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not set up \u2014 Settings \u2192 Mobile)"}
|
|
1407
|
+
Phones on this computer: ${here.length ? here.map((d) => `${d.name}${d.state === "ready" ? "" : ` (${d.state})`}`).join(", ") : "none"}` + (farms.length ? `
|
|
1408
|
+
Cloud device farms: ${farms.join(", ")}` : "") + (chosen ? `
|
|
1409
|
+
Runs go to: ${chosen.kind === "local" ? chosen.name || chosen.id : `${CLOUD_PROVIDER_NAME[chosen.provider]}: ${chosen.device}`}` : "");
|
|
1410
|
+
});
|
|
1067
1411
|
return text([
|
|
1068
|
-
`Project ${projectId}.
|
|
1412
|
+
`Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
|
|
1413
|
+
platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}` : "",
|
|
1414
|
+
...phoneLines,
|
|
1069
1415
|
c.environments.length ? `Environments: ${c.environments.map((e) => `${e.name} ${e.baseUrl}`).join(", ")}` : "",
|
|
1070
1416
|
`
|
|
1071
1417
|
Features:${c.features.length ? "" : " none yet"}`,
|
|
@@ -1095,13 +1441,15 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
|
1095
1441
|
{
|
|
1096
1442
|
name: "start_flow",
|
|
1097
1443
|
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.",
|
|
1444
|
+
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
1445
|
inputSchema: {
|
|
1100
1446
|
type: "object",
|
|
1101
1447
|
properties: {
|
|
1102
1448
|
name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
|
|
1103
1449
|
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:
|
|
1450
|
+
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.` },
|
|
1451
|
+
platform: { type: "string", enum: ["web", "android", "ios"], description: "Where the flow runs. Default: web, or the project's only platform." },
|
|
1452
|
+
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
1453
|
after: {
|
|
1106
1454
|
type: "array",
|
|
1107
1455
|
items: { type: "string" },
|
|
@@ -1131,16 +1479,23 @@ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
|
1131
1479
|
}
|
|
1132
1480
|
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1133
1481
|
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1482
|
+
const platforms = platformsOf(catalog);
|
|
1483
|
+
const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
|
|
1484
|
+
const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
|
|
1485
|
+
if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
|
|
1486
|
+
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`);
|
|
1487
|
+
const device = isMobile(platform) ? deviceFrom(str(args["device"]), platform, await devicesSummary(), catalog.mobile) : void 0;
|
|
1134
1488
|
const page = await session.start({
|
|
1135
1489
|
projectId,
|
|
1490
|
+
...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
|
|
1136
1491
|
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1137
1492
|
name: str(args["name"]) ?? "Untitled flow",
|
|
1138
1493
|
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1139
|
-
startPath: str(args["startPath"]) || "/",
|
|
1494
|
+
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1140
1495
|
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1141
1496
|
}, catalog);
|
|
1142
1497
|
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.
|
|
1498
|
+
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
1499
|
|
|
1145
1500
|
${page}`);
|
|
1146
1501
|
}
|
|
@@ -1148,7 +1503,7 @@ ${page}`);
|
|
|
1148
1503
|
{
|
|
1149
1504
|
name: "read_page",
|
|
1150
1505
|
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.",
|
|
1506
|
+
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
1507
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1153
1508
|
annotations: { readOnlyHint: true },
|
|
1154
1509
|
run: async () => text(await session.snapshot())
|
|
@@ -1156,11 +1511,11 @@ ${page}`);
|
|
|
1156
1511
|
{
|
|
1157
1512
|
name: "act",
|
|
1158
1513
|
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.`,
|
|
1514
|
+
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
1515
|
inputSchema: {
|
|
1161
1516
|
type: "object",
|
|
1162
1517
|
properties: {
|
|
1163
|
-
action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"] },
|
|
1518
|
+
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
1519
|
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
|
|
1165
1520
|
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1166
1521
|
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
@@ -1172,7 +1527,7 @@ ${page}`);
|
|
|
1172
1527
|
additionalProperties: false
|
|
1173
1528
|
},
|
|
1174
1529
|
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");
|
|
1530
|
+
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
1531
|
return text(await session.act({
|
|
1177
1532
|
action: args["action"],
|
|
1178
1533
|
...str(args["role"]) ? { role: str(args["role"]) } : {},
|
|
@@ -1187,7 +1542,7 @@ ${page}`);
|
|
|
1187
1542
|
{
|
|
1188
1543
|
name: "add_check",
|
|
1189
1544
|
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.",
|
|
1545
|
+
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
1546
|
inputSchema: {
|
|
1192
1547
|
type: "object",
|
|
1193
1548
|
properties: {
|
|
@@ -1651,7 +2006,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
|
|
|
1651
2006
|
run: async (args) => {
|
|
1652
2007
|
const projectId = await projectFor(args["projectId"]);
|
|
1653
2008
|
const { pages } = await service.knowledgePages(projectId);
|
|
1654
|
-
const read = async (
|
|
2009
|
+
const read = async (path2) => pages.some((p) => p.path === path2) ? (await service.knowledgePage(projectId, path2)).content : null;
|
|
1655
2010
|
const [product, glossary, decisions] = await Promise.all(["product.md", "glossary.md", "memory/decisions.md"].map(read));
|
|
1656
2011
|
const codebases = pages.filter((p) => p.path.startsWith("codebase/"));
|
|
1657
2012
|
const headers = await Promise.all(codebases.map(async (p) => `- ${p.path}: ${(await read(p.path))?.split("\n").filter((l) => l.trim()).slice(0, 3).join(" \xB7 ") ?? ""}`));
|
|
@@ -1721,7 +2076,7 @@ ${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") |
|
|
|
1721
2076
|
{
|
|
1722
2077
|
name: "close_browser",
|
|
1723
2078
|
title: "Close the browser",
|
|
1724
|
-
description: "Close the browser and drop the flow being written, unsaved.",
|
|
2079
|
+
description: "Close the browser \u2014 or the app on the phone \u2014 and drop the flow being written, unsaved.",
|
|
1725
2080
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1726
2081
|
annotations: { destructiveHint: true },
|
|
1727
2082
|
run: async () => {
|
|
@@ -1745,6 +2100,212 @@ ${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") |
|
|
|
1745
2100
|
}));
|
|
1746
2101
|
}
|
|
1747
2102
|
|
|
2103
|
+
// src/mcp/upgrade.ts
|
|
2104
|
+
import { spawn } from "child_process";
|
|
2105
|
+
import readline2 from "readline";
|
|
2106
|
+
var PRIVATE = "flowriki-upgrade";
|
|
2107
|
+
var startWithNpx = (version) => spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["-y", `@flowriki/agent@${version}`, "mcp"], {
|
|
2108
|
+
env: { ...process.env, FLOWRIKI_MCP_CHILD: "1" },
|
|
2109
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
2110
|
+
});
|
|
2111
|
+
var toolShape = (tools) => JSON.stringify(tools.map((t) => [t.name, t.description ?? "", t.inputSchema ?? null]).sort());
|
|
2112
|
+
function serveWithUpgrades(tools, info, opts) {
|
|
2113
|
+
const input = opts.input ?? process.stdin;
|
|
2114
|
+
const output = opts.output ?? process.stdout;
|
|
2115
|
+
const log = opts.log ?? ((line) => process.stderr.write(`flowriki: ${line}
|
|
2116
|
+
`));
|
|
2117
|
+
const send = (m) => output.write(`${JSON.stringify(m)}
|
|
2118
|
+
`);
|
|
2119
|
+
if (!opts.output) {
|
|
2120
|
+
console.log = console.info = console.warn = console.debug = (...args) => {
|
|
2121
|
+
process.stderr.write(`${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
2122
|
+
`);
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
let initialize = null;
|
|
2126
|
+
let localCalls = 0;
|
|
2127
|
+
let ready = null;
|
|
2128
|
+
let active = null;
|
|
2129
|
+
let preparing = false;
|
|
2130
|
+
let notice = null;
|
|
2131
|
+
const forwarded = /* @__PURE__ */ new Set();
|
|
2132
|
+
let timer = null;
|
|
2133
|
+
const withNotice = (answer) => {
|
|
2134
|
+
if (!notice || !answer.result || typeof answer.result !== "object") return answer;
|
|
2135
|
+
const result = answer.result;
|
|
2136
|
+
if (!Array.isArray(result.content)) return answer;
|
|
2137
|
+
const text2 = notice;
|
|
2138
|
+
notice = null;
|
|
2139
|
+
return { ...answer, result: { ...result, content: [{ type: "text", text: text2 }, ...result.content] } };
|
|
2140
|
+
};
|
|
2141
|
+
const prepare = async (version) => {
|
|
2142
|
+
if (preparing || ready || active || !initialize) return;
|
|
2143
|
+
preparing = true;
|
|
2144
|
+
let child = null;
|
|
2145
|
+
try {
|
|
2146
|
+
child = (opts.start ?? startWithNpx)(version);
|
|
2147
|
+
const c = child;
|
|
2148
|
+
const lines2 = readline2.createInterface({ input: c.stdout, crlfDelay: Infinity });
|
|
2149
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
2150
|
+
const exited = new Promise((_, reject) => c.once("exit", (code) => reject(new Error(`it stopped while starting (exit ${code})`))));
|
|
2151
|
+
exited.catch(() => void 0);
|
|
2152
|
+
c.once("error", (e) => waiting.forEach((resolve) => resolve({ jsonrpc: "2.0", error: { message: e.message } })));
|
|
2153
|
+
lines2.on("line", (line) => {
|
|
2154
|
+
let m;
|
|
2155
|
+
try {
|
|
2156
|
+
m = JSON.parse(line);
|
|
2157
|
+
} catch {
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (typeof m.id === "string" && m.id.startsWith(PRIVATE)) {
|
|
2161
|
+
waiting.get(m.id)?.(m);
|
|
2162
|
+
waiting.delete(m.id);
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
if (active?.child !== c) return;
|
|
2166
|
+
if (m.id !== void 0 && m.id !== null) forwarded.delete(m.id);
|
|
2167
|
+
send(m.id !== void 0 && m.id !== null && "result" in m ? withNotice(m) : m);
|
|
2168
|
+
});
|
|
2169
|
+
const ask = (method, params, id) => new Promise((resolve, reject) => {
|
|
2170
|
+
waiting.set(id, resolve);
|
|
2171
|
+
c.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, ...params ? { params } : {} })}
|
|
2172
|
+
`);
|
|
2173
|
+
setTimeout(() => reject(new Error("it did not answer in time")), opts.readyWithinMs ?? 18e4).unref();
|
|
2174
|
+
});
|
|
2175
|
+
const hello = await Promise.race([ask("initialize", initialize.params, `${PRIVATE}-init`), exited]);
|
|
2176
|
+
if (hello.error) throw new Error(JSON.stringify(hello.error));
|
|
2177
|
+
c.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}
|
|
2178
|
+
`);
|
|
2179
|
+
const listed = await Promise.race([ask("tools/list", void 0, `${PRIVATE}-tools`), exited]);
|
|
2180
|
+
const theirs = listed.result?.tools ?? [];
|
|
2181
|
+
ready = { child: c, version, lines: lines2, toolsChanged: toolShape(theirs) !== toolShape(tools) };
|
|
2182
|
+
c.once("exit", () => {
|
|
2183
|
+
if (ready?.child === c) ready = null;
|
|
2184
|
+
if (active?.child === c) {
|
|
2185
|
+
active = null;
|
|
2186
|
+
for (const id of forwarded) send({ jsonrpc: "2.0", id, error: { code: -32603, message: "the updated Flowriki stopped \u2014 ask again" } });
|
|
2187
|
+
forwarded.clear();
|
|
2188
|
+
notice = `The updated Flowriki (${version}) stopped, so this connection carries on with ${opts.current}.`;
|
|
2189
|
+
log(`updated server ${version} exited; back on ${opts.current}`);
|
|
2190
|
+
}
|
|
2191
|
+
});
|
|
2192
|
+
log(`${version} is ready to take over from ${opts.current}`);
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
child?.kill();
|
|
2195
|
+
log(`could not start ${version}: ${error.message}`);
|
|
2196
|
+
notice ??= `Flowriki ${version} is out (this connection runs ${opts.current}) and could not switch over by itself: ${error.message}. Reconnect Flowriki in Claude to start the new version.`;
|
|
2197
|
+
} finally {
|
|
2198
|
+
preparing = false;
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
const maybeSwitch = async (incomingTool) => {
|
|
2202
|
+
if (!ready || active || localCalls > 0) return false;
|
|
2203
|
+
if (!opts.idle() && incomingTool !== "start_flow") return false;
|
|
2204
|
+
const next = ready;
|
|
2205
|
+
ready = null;
|
|
2206
|
+
await opts.handOff().catch(() => void 0);
|
|
2207
|
+
active = next;
|
|
2208
|
+
if (timer) clearInterval(timer);
|
|
2209
|
+
notice = `Flowriki updated itself from ${opts.current} to ${next.version} \u2014 the new version is answering now.` + (next.toolsChanged ? " It brings new or changed tools; if Claude does not offer them yet, reconnect Flowriki in Claude (in Claude Code: /mcp, then Reconnect)." : "");
|
|
2210
|
+
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
2211
|
+
log(`switched to ${next.version}`);
|
|
2212
|
+
return true;
|
|
2213
|
+
};
|
|
2214
|
+
const look = async () => {
|
|
2215
|
+
if (active || ready || preparing) return;
|
|
2216
|
+
const version = await opts.check().catch(() => null);
|
|
2217
|
+
if (version) {
|
|
2218
|
+
await prepare(version);
|
|
2219
|
+
await maybeSwitch();
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
setTimeout(() => void look(), opts.firstCheckMs ?? 15e3).unref();
|
|
2223
|
+
timer = setInterval(() => void look(), opts.everyMs ?? 30 * 6e4);
|
|
2224
|
+
timer.unref();
|
|
2225
|
+
const lines = readline2.createInterface({ input, crlfDelay: Infinity });
|
|
2226
|
+
const pending = /* @__PURE__ */ new Set();
|
|
2227
|
+
lines.on("line", (line) => {
|
|
2228
|
+
if (!line.trim()) return;
|
|
2229
|
+
let message;
|
|
2230
|
+
try {
|
|
2231
|
+
message = JSON.parse(line);
|
|
2232
|
+
} catch {
|
|
2233
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
if (message.method === "initialize") initialize = message;
|
|
2237
|
+
const task = (async () => {
|
|
2238
|
+
const tool = message.method === "tools/call" ? String(message.params?.["name"] ?? "") : void 0;
|
|
2239
|
+
if (!active && tool) await maybeSwitch(tool);
|
|
2240
|
+
if (active) {
|
|
2241
|
+
if (message.id !== void 0 && message.id !== null) forwarded.add(message.id);
|
|
2242
|
+
active.child.stdin.write(`${line}
|
|
2243
|
+
`);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
if (tool) localCalls++;
|
|
2247
|
+
try {
|
|
2248
|
+
const answer = await handle(message, tools, info);
|
|
2249
|
+
if (answer) send(tool ? withNotice(answer) : answer);
|
|
2250
|
+
if (message.method === "notifications/initialized") send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
2251
|
+
} finally {
|
|
2252
|
+
if (tool) localCalls--;
|
|
2253
|
+
}
|
|
2254
|
+
if (tool && ready) await maybeSwitch();
|
|
2255
|
+
})();
|
|
2256
|
+
pending.add(task);
|
|
2257
|
+
void task.finally(() => pending.delete(task));
|
|
2258
|
+
});
|
|
2259
|
+
return new Promise((resolve) => lines.on("close", () => {
|
|
2260
|
+
void Promise.allSettled([...pending]).then(() => {
|
|
2261
|
+
if (timer) clearInterval(timer);
|
|
2262
|
+
const child = active?.child ?? ready?.child;
|
|
2263
|
+
if (!child) return resolve();
|
|
2264
|
+
child.stdin?.end();
|
|
2265
|
+
const done = () => resolve();
|
|
2266
|
+
child.once("exit", done);
|
|
2267
|
+
setTimeout(() => {
|
|
2268
|
+
child.kill();
|
|
2269
|
+
done();
|
|
2270
|
+
}, 5e3).unref();
|
|
2271
|
+
});
|
|
2272
|
+
}));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// src/updates.ts
|
|
2276
|
+
import fs from "fs";
|
|
2277
|
+
import path from "path";
|
|
2278
|
+
var CACHE_MS = 30 * 60 * 1e3;
|
|
2279
|
+
async function latestPublished(opts = {}) {
|
|
2280
|
+
const file = path.join(dataDir(), "latest-agent.json");
|
|
2281
|
+
if (!opts.fresh) {
|
|
2282
|
+
try {
|
|
2283
|
+
const kept = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2284
|
+
if (kept.version && kept.at && Date.now() - kept.at < CACHE_MS) return kept.version;
|
|
2285
|
+
} catch {
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
try {
|
|
2289
|
+
const res = await fetch(AGENT_LATEST_URL, { signal: AbortSignal.timeout(5e3), headers: { accept: "application/json" } });
|
|
2290
|
+
if (!res.ok) return null;
|
|
2291
|
+
const version = (await res.json()).version ?? null;
|
|
2292
|
+
if (version) {
|
|
2293
|
+
try {
|
|
2294
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2295
|
+
fs.writeFileSync(file, JSON.stringify({ version, at: Date.now() }));
|
|
2296
|
+
} catch {
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
return version;
|
|
2300
|
+
} catch {
|
|
2301
|
+
return null;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
async function updateFor(running) {
|
|
2305
|
+
const latest = await latestPublished();
|
|
2306
|
+
return agentUpdateFor(running, latest) ? latest : null;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
1748
2309
|
// src/mcp/index.ts
|
|
1749
2310
|
async function runMcp(opts) {
|
|
1750
2311
|
let service;
|
|
@@ -1768,7 +2329,20 @@ async function runMcp(opts) {
|
|
|
1768
2329
|
};
|
|
1769
2330
|
process.on("SIGINT", () => void closing());
|
|
1770
2331
|
process.on("SIGTERM", () => void closing());
|
|
1771
|
-
|
|
2332
|
+
const info = { name: "flowriki", version: opts.version, instructions: INSTRUCTIONS };
|
|
2333
|
+
if (process.env["FLOWRIKI_MCP_CHILD"] === "1" || process.env["FLOWRIKI_NO_UPDATE"] === "1" || !/^\d+\.\d+\.\d+/.test(opts.version)) {
|
|
2334
|
+
await serve(tools, info);
|
|
2335
|
+
} else {
|
|
2336
|
+
await serveWithUpgrades(tools, info, {
|
|
2337
|
+
current: opts.version,
|
|
2338
|
+
check: () => updateFor(opts.version),
|
|
2339
|
+
idle: () => !session?.open,
|
|
2340
|
+
handOff: async () => {
|
|
2341
|
+
await session?.close();
|
|
2342
|
+
if (session) session.draft = null;
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
1772
2346
|
await session?.close();
|
|
1773
2347
|
return 0;
|
|
1774
2348
|
}
|
|
@@ -1792,6 +2366,10 @@ ${bold("USAGE")}
|
|
|
1792
2366
|
flowriki-agent doctor diagnose setup problems
|
|
1793
2367
|
flowriki-agent prepare download the browser recording needs
|
|
1794
2368
|
flowriki-agent disconnect forget this computer's credential
|
|
2369
|
+
flowriki-agent mobile setup install what testing Android and iOS apps
|
|
2370
|
+
needs (Appium and its drivers)
|
|
2371
|
+
flowriki-agent mobile doctor is this computer ready to test phones
|
|
2372
|
+
flowriki-agent mobile devices the phones, emulators and simulators here
|
|
1795
2373
|
flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
|
|
1796
2374
|
Claude Code or Claude Desktop, not by you:
|
|
1797
2375
|
claude mcp add flowriki -- flowriki-agent mcp
|
|
@@ -1914,6 +2492,17 @@ Run \`flowriki-agent --help\`.`);
|
|
|
1914
2492
|
console.log(dim(` project ${project.name} (${project.root})`));
|
|
1915
2493
|
}
|
|
1916
2494
|
console.log(dim("\n Running in the foreground. Ctrl-C to stop.\n"));
|
|
2495
|
+
let told = null;
|
|
2496
|
+
const tellIfBehind = async () => {
|
|
2497
|
+
const newer = await updateFor(AGENT_VERSION).catch(() => null);
|
|
2498
|
+
if (!newer || newer === told) return;
|
|
2499
|
+
told = newer;
|
|
2500
|
+
console.log(yellow(` ! Flowriki agent ${newer} is out \u2014 this is ${AGENT_VERSION}.`));
|
|
2501
|
+
console.log(dim(` Update: npm install -g @flowriki/agent@latest then stop (Ctrl-C) and start again
|
|
2502
|
+
`));
|
|
2503
|
+
};
|
|
2504
|
+
void tellIfBehind();
|
|
2505
|
+
setInterval(() => void tellIfBehind(), 6 * 60 * 6e4).unref();
|
|
1917
2506
|
let stopping = false;
|
|
1918
2507
|
const shutdown = async (signal) => {
|
|
1919
2508
|
if (stopping) return;
|
|
@@ -1987,6 +2576,42 @@ Run \`flowriki-agent --help\`.`);
|
|
|
1987
2576
|
}
|
|
1988
2577
|
return 0;
|
|
1989
2578
|
}
|
|
2579
|
+
case "mobile": {
|
|
2580
|
+
const sub = positionals[1] ?? "doctor";
|
|
2581
|
+
const report = (found) => {
|
|
2582
|
+
for (const [title, lines] of [["Appium", found.appium], ["Android", found.android], ["iOS", found.ios]]) {
|
|
2583
|
+
console.log(bold(title));
|
|
2584
|
+
for (const line of lines) {
|
|
2585
|
+
console.log(` ${line.ok ? green("\u2713") : line.optional ? yellow("\u2013") : red("\u2717")} ${line.item.padEnd(15)} ${line.ok ? dim(line.detail) : line.detail}`);
|
|
2586
|
+
if (!line.ok && line.fix) console.log(` ${yellow("\u2192")} ${line.fix}`);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
console.log(`
|
|
2590
|
+
Android ${found.androidReady ? green("ready") : yellow("not ready")} iOS ${found.iosReady ? green("ready") : yellow("not ready")}`);
|
|
2591
|
+
return found.androidReady || found.iosReady ? 0 : 1;
|
|
2592
|
+
};
|
|
2593
|
+
if (sub === "setup") return report(await mobileSetup((line) => console.log(dim(` ${line}`))));
|
|
2594
|
+
if (sub === "doctor") {
|
|
2595
|
+
const found = await mobileDoctor();
|
|
2596
|
+
if (values.json) {
|
|
2597
|
+
console.log(JSON.stringify(found, null, 2));
|
|
2598
|
+
return found.androidReady || found.iosReady ? 0 : 1;
|
|
2599
|
+
}
|
|
2600
|
+
return report(found);
|
|
2601
|
+
}
|
|
2602
|
+
if (sub === "devices") {
|
|
2603
|
+
const found = await listDevices();
|
|
2604
|
+
if (values.json) {
|
|
2605
|
+
console.log(JSON.stringify(found, null, 2));
|
|
2606
|
+
return 0;
|
|
2607
|
+
}
|
|
2608
|
+
if (!found.length) console.log(yellow(" No phones, emulators or simulators found."));
|
|
2609
|
+
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}`}`)}`);
|
|
2610
|
+
return 0;
|
|
2611
|
+
}
|
|
2612
|
+
console.error(`unknown: mobile ${sub} \u2014 use setup, doctor or devices`);
|
|
2613
|
+
return 2;
|
|
2614
|
+
}
|
|
1990
2615
|
case "projects": {
|
|
1991
2616
|
const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
|
|
1992
2617
|
for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);
|