@flowriki/agent 0.1.0-beta.64 → 0.1.0-beta.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
AGENT_LATEST_URL,
|
|
4
4
|
AGENT_VERSION,
|
|
5
5
|
agentUpdateFor,
|
|
6
|
+
allowCommands,
|
|
6
7
|
authenticationResolver,
|
|
8
|
+
backupsFor,
|
|
7
9
|
checkAssertion,
|
|
8
10
|
closeRunSession,
|
|
9
11
|
connect,
|
|
@@ -17,14 +19,17 @@ import {
|
|
|
17
19
|
helperRunner,
|
|
18
20
|
openRunSession,
|
|
19
21
|
prepareBrowsers,
|
|
22
|
+
readApprovals,
|
|
20
23
|
replayCase,
|
|
21
24
|
replayFlow,
|
|
22
25
|
resolveCheckouts,
|
|
23
26
|
resolveService,
|
|
27
|
+
revokeCommands,
|
|
24
28
|
runtimeValue,
|
|
25
29
|
start,
|
|
26
|
-
status
|
|
27
|
-
|
|
30
|
+
status,
|
|
31
|
+
withBackups
|
|
32
|
+
} from "./chunk-WUK3X43D.js";
|
|
28
33
|
import {
|
|
29
34
|
CLOUD_PROVIDERS,
|
|
30
35
|
CLOUD_PROVIDER_NAME,
|
|
@@ -52,7 +57,7 @@ import {
|
|
|
52
57
|
slugify,
|
|
53
58
|
stepRefusedOn,
|
|
54
59
|
targetFor
|
|
55
|
-
} from "./chunk-
|
|
60
|
+
} from "./chunk-YD7F7JMA.js";
|
|
56
61
|
|
|
57
62
|
// src/cli.ts
|
|
58
63
|
import { parseArgs } from "util";
|
|
@@ -245,6 +250,13 @@ var ServiceClient = class {
|
|
|
245
250
|
{ method: "POST", body: JSON.stringify(input) }
|
|
246
251
|
);
|
|
247
252
|
}
|
|
253
|
+
/** A test case that cannot be automated, and why. */
|
|
254
|
+
reportBlocker(projectId, input) {
|
|
255
|
+
return this.call(
|
|
256
|
+
`/api/agent/cases/blocker?projectId=${encodeURIComponent(projectId)}`,
|
|
257
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
258
|
+
);
|
|
259
|
+
}
|
|
248
260
|
/** A helper, its secrets sealed by the service. */
|
|
249
261
|
createHelper(projectId, input) {
|
|
250
262
|
return this.call(
|
|
@@ -253,6 +265,11 @@ var ServiceClient = class {
|
|
|
253
265
|
);
|
|
254
266
|
}
|
|
255
267
|
/** What a new flow starts after: the flows, in run order, and their steps. */
|
|
268
|
+
/** A saved flow's steps, to fix it from rather than record it again. */
|
|
269
|
+
savedFlow(projectId, name, platform) {
|
|
270
|
+
const q = new URLSearchParams({ projectId, name, ...platform ? { platform } : {} });
|
|
271
|
+
return this.call(`/api/agent/flows/saved?${q}`);
|
|
272
|
+
}
|
|
256
273
|
flowsAfter(projectId, after) {
|
|
257
274
|
return this.call(
|
|
258
275
|
`/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
|
|
@@ -417,6 +434,15 @@ import fs from "fs";
|
|
|
417
434
|
import path from "path";
|
|
418
435
|
var MAX_TREE = 14e3;
|
|
419
436
|
var MAX_STEP_VIEW = 6e3;
|
|
437
|
+
function quiet(tree) {
|
|
438
|
+
return tree.split("\n").filter((line) => {
|
|
439
|
+
const t = line.trim();
|
|
440
|
+
if (/^- \/url:/.test(t)) return false;
|
|
441
|
+
if (/^- img$/.test(t) || /^- img:$/.test(t)) return false;
|
|
442
|
+
if (/^- listitem$/.test(t)) return false;
|
|
443
|
+
return true;
|
|
444
|
+
}).join("\n");
|
|
445
|
+
}
|
|
420
446
|
function topLevelBlocks(tree) {
|
|
421
447
|
const blocks = [];
|
|
422
448
|
for (const line of tree.split("\n")) {
|
|
@@ -430,7 +456,7 @@ function topLevelBlocks(tree) {
|
|
|
430
456
|
};
|
|
431
457
|
}
|
|
432
458
|
var STEP_TIMEOUT_MS = 1e4;
|
|
433
|
-
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;
|
|
459
|
+
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|token|api[-_\s]?key|access[-_\s]?key|card[-_\s]?number|credit[-_\s]?card/i;
|
|
434
460
|
var TYPED = "typed_";
|
|
435
461
|
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
436
462
|
var SIGN_UP = /\b(sign[\s-]?up|register|registration|create (an |your )?account|join)\b/i;
|
|
@@ -608,7 +634,11 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
608
634
|
mobile = null;
|
|
609
635
|
values = /* @__PURE__ */ new Map();
|
|
610
636
|
resolvers = null;
|
|
637
|
+
/** Every helper runner made in this session, so background commands stop with it. */
|
|
638
|
+
helperRunners = [];
|
|
611
639
|
lastTree = null;
|
|
640
|
+
/** The navigation as last shown, so a step repeats it only when it changed. */
|
|
641
|
+
lastNavigation = null;
|
|
612
642
|
/** Values typed into secret fields, by their `typed:` reference. */
|
|
613
643
|
typed = /* @__PURE__ */ new Map();
|
|
614
644
|
draft = null;
|
|
@@ -652,6 +682,7 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
652
682
|
draft.device?.kind === "cloud" ? { cloud: draft.device.provider } : {}
|
|
653
683
|
);
|
|
654
684
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
685
|
+
if (runHelper) this.helperRunners.push(runHelper);
|
|
655
686
|
this.resolvers = {
|
|
656
687
|
resolveAuth: authenticationResolver(prepared.authentications),
|
|
657
688
|
resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
|
|
@@ -670,6 +701,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
670
701
|
* it is kept, so a flow interrupted — the program stopped, the limit hit,
|
|
671
702
|
* the agent restarted — is not lost: starting the same flow again replays
|
|
672
703
|
* what was kept and carries on from there, unless `fresh` says start over. */
|
|
704
|
+
/** Steps of a saved flow still to put back, after one failed on the way. */
|
|
705
|
+
waiting = [];
|
|
673
706
|
async start(input, catalog, opts = {}) {
|
|
674
707
|
await this.close();
|
|
675
708
|
this.catalog = catalog;
|
|
@@ -678,7 +711,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
678
711
|
this.typed = /* @__PURE__ */ new Map();
|
|
679
712
|
this.lastTree = null;
|
|
680
713
|
this.verified = null;
|
|
681
|
-
|
|
714
|
+
this.waiting = [];
|
|
715
|
+
const left = opts.fresh ? null : this.readDraft(input.projectId, input.name) ?? (opts.saved?.length ? { steps: opts.saved } : null);
|
|
682
716
|
if (opts.fresh) this.dropDraft(input.projectId, input.name);
|
|
683
717
|
const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
|
|
684
718
|
this.report();
|
|
@@ -691,7 +725,7 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
691
725
|
async resume(steps, opened) {
|
|
692
726
|
const draft = this.draft;
|
|
693
727
|
let failed = null;
|
|
694
|
-
for (const step of steps) {
|
|
728
|
+
for (const [at, step] of steps.entries()) {
|
|
695
729
|
try {
|
|
696
730
|
if (this.mobile) {
|
|
697
731
|
const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
|
|
@@ -713,17 +747,28 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
713
747
|
this.checkpoint();
|
|
714
748
|
} catch (error) {
|
|
715
749
|
failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
|
|
750
|
+
this.waiting = steps.slice(at + 1);
|
|
716
751
|
break;
|
|
717
752
|
}
|
|
718
753
|
}
|
|
719
754
|
this.checkpoint();
|
|
720
755
|
const kept = draft.steps.length;
|
|
721
|
-
const
|
|
756
|
+
const later = this.waiting.length ? ` ${this.waiting.length} later step${this.waiting.length === 1 ? " is" : "s are"} waiting: do the failed step again the way the page now needs, then continue_flow puts ${this.waiting.length === 1 ? "it" : "them"} back.` : "";
|
|
757
|
+
const head = failed ? `Resumed "${draft.name}": ${kept} of ${steps.length} earlier steps are back; the next one did not work this time and was dropped (${failed}).${later}` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
|
|
722
758
|
const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
|
|
723
759
|
return `${head}
|
|
724
760
|
|
|
725
761
|
${view}`;
|
|
726
762
|
}
|
|
763
|
+
/** Put back the steps that were waiting after a failed one — replayed,
|
|
764
|
+
* kept as they pass; the next that fails is where you take over again. */
|
|
765
|
+
async continueFlow() {
|
|
766
|
+
this.requireOpen();
|
|
767
|
+
const steps = this.waiting;
|
|
768
|
+
if (!steps.length) throw new Error("no steps are waiting \u2014 the flow is whole; add_check, replay_flow and save_flow");
|
|
769
|
+
this.waiting = [];
|
|
770
|
+
return this.resume(steps, "");
|
|
771
|
+
}
|
|
727
772
|
// ── Drafts on disk ──────────────────────────────────────────────────────
|
|
728
773
|
static draftFile(projectId, name) {
|
|
729
774
|
return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
|
|
@@ -945,32 +990,36 @@ Record the flows it needs; on every act, add_check and use_helper pass line: the
|
|
|
945
990
|
const before = this.lastTree;
|
|
946
991
|
this.lastTree = tree;
|
|
947
992
|
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
948
|
-
\u2026 (cut
|
|
949
|
-
if (full || before === null)
|
|
950
|
-
|
|
993
|
+
\u2026 (cut)` : t;
|
|
994
|
+
if (full || before === null) {
|
|
995
|
+
this.lastNavigation = topLevelBlocks(tree).navigation ?? null;
|
|
996
|
+
return head + cap(tree, MAX_TREE);
|
|
997
|
+
}
|
|
998
|
+
const { dialogs, navigation: nav } = topLevelBlocks(tree);
|
|
999
|
+
const navigation = nav && nav !== this.lastNavigation ? nav : null;
|
|
1000
|
+
this.lastNavigation = nav ?? null;
|
|
951
1001
|
if (dialogs.length) {
|
|
952
|
-
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog
|
|
1002
|
+
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog)";
|
|
953
1003
|
}
|
|
954
1004
|
const was = new Set(before.split("\n"));
|
|
955
1005
|
const now = new Set(tree.split("\n"));
|
|
956
1006
|
const added = tree.split("\n").filter((l) => !was.has(l));
|
|
957
1007
|
const removed = before.split("\n").filter((l) => !now.has(l));
|
|
958
|
-
if (added.length === 0 && removed.length === 0) return head + "(the page did not change
|
|
1008
|
+
if (added.length === 0 && removed.length === 0) return head + "(the page did not change)";
|
|
959
1009
|
if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
|
|
960
1010
|
return head + [
|
|
961
1011
|
...navigation ? [navigation, ""] : [],
|
|
962
1012
|
added.length ? `Appeared:
|
|
963
1013
|
${added.join("\n")}` : "",
|
|
964
1014
|
removed.length ? `Gone:
|
|
965
|
-
${removed.join("\n")}` : ""
|
|
966
|
-
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
1015
|
+
${removed.join("\n")}` : ""
|
|
967
1016
|
].filter(Boolean).join("\n");
|
|
968
1017
|
}
|
|
969
1018
|
async webTree() {
|
|
970
1019
|
const { page, draft } = this.requireOpen();
|
|
971
1020
|
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
972
1021
|
const tree = numberDuplicates(labelTree(
|
|
973
|
-
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
1022
|
+
quiet(await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`)),
|
|
974
1023
|
await fieldNames(page)
|
|
975
1024
|
));
|
|
976
1025
|
const title = await page.title().catch(() => "");
|
|
@@ -980,7 +1029,28 @@ Steps so far: ${draft.steps.length}
|
|
|
980
1029
|
` };
|
|
981
1030
|
}
|
|
982
1031
|
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
1032
|
+
/** The step that failed last, and how many times in a row. */
|
|
1033
|
+
failing = null;
|
|
1034
|
+
/** DO ONE STEP — and notice when the same step keeps failing. Retrying it
|
|
1035
|
+
* turn after turn was where whole jobs went: each try re-reads the entire
|
|
1036
|
+
* conversation. On the third failure in a row, the answer says to stop. */
|
|
983
1037
|
async act(input) {
|
|
1038
|
+
const key = [input.action, input.role ?? "", input.name ?? "", input.within ?? "", input.nth ?? ""].join("|");
|
|
1039
|
+
try {
|
|
1040
|
+
const done = await this.actOnce(input);
|
|
1041
|
+
this.failing = null;
|
|
1042
|
+
return done;
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
this.failing = this.failing?.key === key ? { key, times: this.failing.times + 1 } : { key, times: 1 };
|
|
1045
|
+
if (this.failing.times >= 3) {
|
|
1046
|
+
error.message = `${error.message}
|
|
1047
|
+
|
|
1048
|
+
THIS STEP HAS FAILED ${this.failing.times} TIMES IN A ROW. Do not try it again: if another way to reach the same thing is not obvious from the page, call report_blocker for the test case (kind: stuck, with the line) and move on.`;
|
|
1049
|
+
}
|
|
1050
|
+
throw error;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async actOnce(input) {
|
|
984
1054
|
if (this.mobile) return this.mobileAct(input);
|
|
985
1055
|
const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
|
|
986
1056
|
if (refused) throw new Error(refused);
|
|
@@ -992,11 +1062,11 @@ Steps so far: ${draft.steps.length}
|
|
|
992
1062
|
if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
|
|
993
1063
|
throw new Error(`${input.action} needs a value`);
|
|
994
1064
|
}
|
|
995
|
-
const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
1065
|
+
const target = navigating ? { role: "", name: "", scope: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
996
1066
|
const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
|
|
997
1067
|
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
998
1068
|
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
999
|
-
|
|
1069
|
+
let step = RecordedStepSchema.parse({
|
|
1000
1070
|
id,
|
|
1001
1071
|
type: input.action,
|
|
1002
1072
|
...target,
|
|
@@ -1011,6 +1081,7 @@ Steps so far: ${draft.steps.length}
|
|
|
1011
1081
|
// environment the run is pointed at rather than the one written on.
|
|
1012
1082
|
...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
|
|
1013
1083
|
});
|
|
1084
|
+
if (!navigating && input.action !== "press") step = RecordedStepSchema.parse(withBackups(step, await backupsFor(page, step)));
|
|
1014
1085
|
try {
|
|
1015
1086
|
this.page = await executeStep(page, step, {
|
|
1016
1087
|
pages: run.pages,
|
|
@@ -1133,9 +1204,7 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
|
1133
1204
|
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
1134
1205
|
const [gone] = draft.steps.splice(number - 1, 1);
|
|
1135
1206
|
this.checkpoint();
|
|
1136
|
-
return `Removed step ${number} (${describe(gone)}). The browser was not undone
|
|
1137
|
-
|
|
1138
|
-
${this.list()}`;
|
|
1207
|
+
return `Removed step ${number} (${describe(gone)}); ${draft.steps.length} steps now. The browser was not undone.`;
|
|
1139
1208
|
}
|
|
1140
1209
|
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
1141
1210
|
* run will — the proof the steps work together, not just one at a time. */
|
|
@@ -1233,6 +1302,7 @@ ${describeRows(built.rows, built.testCase)}`;
|
|
|
1233
1302
|
const steps = testCase.occurrences.flatMap((o) => o.steps);
|
|
1234
1303
|
const prepared = await this.service.prepare(projectId, steps, null);
|
|
1235
1304
|
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
1305
|
+
if (runHelper) this.helperRunners.push(runHelper);
|
|
1236
1306
|
const base = catalog.baseUrl;
|
|
1237
1307
|
const phoneRows = testCase.occurrences.filter((o) => isMobile(o.platform));
|
|
1238
1308
|
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");
|
|
@@ -1281,6 +1351,7 @@ ${lines.join("\n")}
|
|
|
1281
1351
|
Review and publish it here: ${review}`;
|
|
1282
1352
|
}
|
|
1283
1353
|
async close() {
|
|
1354
|
+
for (const runner of this.helperRunners.splice(0)) runner.close();
|
|
1284
1355
|
if (this.reportTimer) await this.sendReport();
|
|
1285
1356
|
const run = this.run;
|
|
1286
1357
|
const mobile = this.mobile;
|
|
@@ -1347,7 +1418,7 @@ Steps so far: ${draft.steps.length}
|
|
|
1347
1418
|
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
1348
1419
|
}
|
|
1349
1420
|
if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
|
|
1350
|
-
let target = { role: "", name: "" };
|
|
1421
|
+
let target = { role: "", name: "", scope: "" };
|
|
1351
1422
|
let password = false;
|
|
1352
1423
|
if (input.role) {
|
|
1353
1424
|
const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
|
|
@@ -1552,6 +1623,20 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1552
1623
|
account.
|
|
1553
1624
|
- HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
|
|
1554
1625
|
with create_helper when the catalog has none that fits.
|
|
1626
|
+
- HEALING fixes a test's automation, never its business: change only how steps
|
|
1627
|
+
find and reach things (an element renamed or moved, a new screen in the way,
|
|
1628
|
+
a wait). Never change a case's Given / When / Then, what a check expects or
|
|
1629
|
+
which checks are on, and never drop a row \u2014 in a heal the service refuses
|
|
1630
|
+
such a save. If the app itself behaves differently now, report_blocker
|
|
1631
|
+
(app-bug) and leave that case.
|
|
1632
|
+
- WHEN A CASE CANNOT BE AUTOMATED, say so with report_blocker and move on to
|
|
1633
|
+
the next case \u2014 never loop. Report it when the app itself is broken at that
|
|
1634
|
+
step (app-bug), when it needs something only a person has \u2014 a captcha, a
|
|
1635
|
+
real card, a code sent to somebody's own phone (needs-person), when a
|
|
1636
|
+
business step is unclear or does not match the app (unclear-step), when the
|
|
1637
|
+
screen or feature cannot be reached (not-found), or after 3 failed tries at
|
|
1638
|
+
the same step (stuck). Name the line it stopped at and what would unblock it.
|
|
1639
|
+
A case marked manual by a person is not to be automated at all.
|
|
1555
1640
|
- SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
|
|
1556
1641
|
"Select & Customize") are told apart by POSITION, like an XPath index: read_page
|
|
1557
1642
|
marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
|
|
@@ -1571,7 +1656,7 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1571
1656
|
"Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
|
|
1572
1657
|
cases built from them use the same names. Keep one area per folder.
|
|
1573
1658
|
|
|
1574
|
-
KNOWLEDGE: project_brief is the project's memory \u2014
|
|
1659
|
+
KNOWLEDGE: project_brief is the project's memory \u2014 given with the request when there is one (then do not call it again). Search the
|
|
1575
1660
|
knowledge pages with knowledge_search; read one with knowledge_read.
|
|
1576
1661
|
product.md is "About the product": the person's own description of
|
|
1577
1662
|
the product, in their words, as long as they like \u2014 read it first. Beside it,
|
|
@@ -1631,14 +1716,17 @@ mix rows on a phone with rows in a browser (a customer orders in the app, an
|
|
|
1631
1716
|
admin approves on the website).
|
|
1632
1717
|
|
|
1633
1718
|
HOW TO WORK:
|
|
1634
|
-
1.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
checks)
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1719
|
+
1. Reuse what exists \u2014 published, and on Claude's working branch \u2014 rather than
|
|
1720
|
+
writing it again: project_catalog lists it (once is enough; a repeat says
|
|
1721
|
+
whether anything changed).
|
|
1722
|
+
2. Plan the folders, the building-block flows (and what each starts after) and
|
|
1723
|
+
the test cases (rows + what each checks), then record the blocks, earliest
|
|
1724
|
+
first. No need to wait for an answer: nobody is at the keyboard.
|
|
1725
|
+
3. Record with start_flow (pass "after" for the flows it continues from) \u2014 it
|
|
1726
|
+
answers with the page \u2014 and act using the role and name EXACTLY as the page
|
|
1727
|
+
shows them, several steps per act when they are all on the page already.
|
|
1728
|
+
Each step answers with what changed; read_page only when you need the whole
|
|
1729
|
+
page again. If a name appears more than once, pass "within" with the name of
|
|
1642
1730
|
the region, dialog or form \u2014 or "dialog" / "navigation".
|
|
1643
1731
|
4. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
1644
1732
|
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
@@ -1724,8 +1812,19 @@ var caseSpec = (args) => ({
|
|
|
1724
1812
|
});
|
|
1725
1813
|
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
1726
1814
|
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
1815
|
+
var STEP_PROPERTIES = {
|
|
1816
|
+
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"] },
|
|
1817
|
+
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox." },
|
|
1818
|
+
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1819
|
+
within: { type: "string", description: "When the name is not unique: the region, dialog or form that holds it." },
|
|
1820
|
+
nth: { type: "integer", minimum: 0, description: "Which one when several share role and name: 0 is the first ([nth N of M] in read_page)." },
|
|
1821
|
+
value: { type: "string" },
|
|
1822
|
+
secret: { type: "boolean", description: "fill only: a password, PIN or key \u2014 kept out of the flow, saved to the vault." },
|
|
1823
|
+
line: { type: "integer", minimum: 1, description: "The test case line (from start_case) this step carries out." }
|
|
1824
|
+
};
|
|
1727
1825
|
function authoringTools(service, session) {
|
|
1728
|
-
let defaultProject = null;
|
|
1826
|
+
let defaultProject = process.env["FLOWRIKI_PROJECT_ID"]?.trim() || null;
|
|
1827
|
+
const catalogSaid = /* @__PURE__ */ new Map();
|
|
1729
1828
|
const catalogs = /* @__PURE__ */ new Map();
|
|
1730
1829
|
const projectFor = async (asked) => {
|
|
1731
1830
|
const named = str(asked);
|
|
@@ -1819,13 +1918,20 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1819
1918
|
{
|
|
1820
1919
|
name: "project_catalog",
|
|
1821
1920
|
title: "What a test can be built from",
|
|
1822
|
-
description: "
|
|
1823
|
-
inputSchema: { type: "object", properties: {
|
|
1921
|
+
description: "What exists to build from: base URL, features with their flows, test-case folders (counts \u2014 name a folder to list its cases), sign-in profiles with accounts, and helpers. start_flow reads it by itself; call this to see what to reuse. platform: only that platform's flows. A second call in the same job says whether anything changed.",
|
|
1922
|
+
inputSchema: { type: "object", properties: {
|
|
1923
|
+
projectId: { type: "string", description: "Optional when you are on one project." },
|
|
1924
|
+
platform: { type: "string", enum: ["web", "android", "ios"], description: "Only this platform's flows." },
|
|
1925
|
+
folder: { type: "string", description: "List the test cases of this folder (by name)." }
|
|
1926
|
+
}, additionalProperties: false },
|
|
1824
1927
|
annotations: { readOnlyHint: true },
|
|
1825
1928
|
run: async (args) => {
|
|
1826
1929
|
const projectId = await projectFor(args["projectId"]);
|
|
1827
1930
|
const c = await catalogOf(projectId, true);
|
|
1828
|
-
const
|
|
1931
|
+
const onlyPlatform = str(args["platform"]);
|
|
1932
|
+
const onPlatform = (f) => !onlyPlatform || (onlyPlatform === "web" ? !isMobile(f.platform) : f.platform === onlyPlatform);
|
|
1933
|
+
const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId && onPlatform(f)).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
|
|
1934
|
+
const askedFolder = str(args["folder"])?.toLowerCase();
|
|
1829
1935
|
const platforms = platformsOf(c);
|
|
1830
1936
|
const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
|
|
1831
1937
|
const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
|
|
@@ -1834,11 +1940,11 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1834
1940
|
const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
|
|
1835
1941
|
const chosen = c.mobile?.devices[p];
|
|
1836
1942
|
return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not named \u2014 Settings \u2192 Environments)"}
|
|
1837
|
-
Phones on this computer: ${here.
|
|
1943
|
+
Phones on this computer: ${here.filter((d) => d.state === "ready").map((d) => d.name).join(", ") || "none running"}${here.some((d) => d.state !== "ready") ? ` (+${here.filter((d) => d.state !== "ready").length} switched off)` : ""}` + (farms.length ? `
|
|
1838
1944
|
Cloud device farms: ${farms.join(", ")}` : "") + `
|
|
1839
1945
|
Runs: ${c.runWhere?.[p] === "cloud" ? "in the cloud" : "on this computer"}` + (chosen ? ` \u2014 ${chosen.kind === "local" ? chosen.name || chosen.id : `${CLOUD_PROVIDER_NAME[chosen.provider]}: ${chosen.device}`}` : "");
|
|
1840
1946
|
});
|
|
1841
|
-
|
|
1947
|
+
const out = [
|
|
1842
1948
|
`Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
|
|
1843
1949
|
platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}${c.runWhere?.web === "cloud" ? " \u2014 test runs use a cloud browser; recording is on this computer" : ""}` : "",
|
|
1844
1950
|
...phoneLines,
|
|
@@ -1857,15 +1963,27 @@ On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet pub
|
|
|
1857
1963
|
] : [],
|
|
1858
1964
|
`
|
|
1859
1965
|
Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
|
|
1860
|
-
|
|
1861
|
-
|
|
1966
|
+
// COUNTS, unless a folder is asked for: hundreds of cases written
|
|
1967
|
+
// and not automated yet were most of this answer, re-read on every
|
|
1968
|
+
// turn after. read_test_cases has any case's steps.
|
|
1969
|
+
...(c.caseFolders ?? []).map((f) => {
|
|
1970
|
+
const inside = (c.cases ?? []).filter((t) => t.folderId === f.id);
|
|
1971
|
+
const head = ` - ${f.key ? `${f.key} ` : ""}${f.name} (id ${f.id}) \u2014 ${inside.length} case${inside.length === 1 ? "" : "s"}, ${inside.filter((t) => t.rows.length).length} automated`;
|
|
1972
|
+
if (askedFolder !== f.name.toLowerCase()) return head;
|
|
1973
|
+
return `${head}
|
|
1974
|
+
${inside.map((t) => ` - ${t.key ? `${t.key} ` : ""}${t.name} \u2014 ${t.rows.length ? `rows: ${t.rows.join(" \u2192 ")}` : "not automated yet"}`).join("\n")}`;
|
|
1975
|
+
}),
|
|
1862
1976
|
`
|
|
1863
1977
|
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
1864
1978
|
...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId})${s.fields?.length ? ` \u2014 fields ${s.fields.map((f) => `"${f}"`).join(", ")}` : ""}: ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
|
|
1865
1979
|
`
|
|
1866
1980
|
Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
1867
1981
|
...c.helpers.map((h) => ` - ${h.name} (id ${h.id}) \u2014 ${h.what}${h.inputs.length ? `; needs ${h.inputs.map((n) => `{{${n}}}`).join(", ")}` : ""}${h.description ? ` \u2014 ${h.description}` : ""}`)
|
|
1868
|
-
].filter(Boolean).join("\n")
|
|
1982
|
+
].filter(Boolean).join("\n");
|
|
1983
|
+
const key = `${projectId}|${onlyPlatform ?? ""}|${askedFolder ?? ""}`;
|
|
1984
|
+
if (catalogSaid.get(key) === out) return text("Nothing has changed since you last read the catalog in this job.");
|
|
1985
|
+
catalogSaid.set(key, out);
|
|
1986
|
+
return text(out);
|
|
1869
1987
|
}
|
|
1870
1988
|
},
|
|
1871
1989
|
{
|
|
@@ -1957,9 +2075,13 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
|
|
|
1957
2075
|
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
1958
2076
|
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
1959
2077
|
}
|
|
1960
|
-
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1961
|
-
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1962
2078
|
const platforms = platformsOf(catalog);
|
|
2079
|
+
const named = str(args["name"]) ?? "";
|
|
2080
|
+
const wantedPlatform = str(args["platform"]);
|
|
2081
|
+
const saved = args["fresh"] === true ? null : await Promise.resolve().then(() => service.savedFlow(projectId, named, wantedPlatform)).then((r) => r.flow).catch(() => null);
|
|
2082
|
+
const askedAfter = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
2083
|
+
const asked = askedAfter.length ? askedAfter : saved?.after ?? [];
|
|
2084
|
+
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1963
2085
|
const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
|
|
1964
2086
|
const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
|
|
1965
2087
|
if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
|
|
@@ -1970,10 +2092,10 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
|
|
|
1970
2092
|
...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
|
|
1971
2093
|
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1972
2094
|
name: str(args["name"]) ?? "Untitled flow",
|
|
1973
|
-
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1974
|
-
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1975
|
-
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1976
|
-
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
|
|
2095
|
+
...feature ? { featureId: feature.id } : saved ? { featureId: saved.featureId } : { featureName: featureAsked },
|
|
2096
|
+
startPath: str(args["startPath"]) || saved?.startPath || (isMobile(platform) ? "" : "/"),
|
|
2097
|
+
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : saved?.signIn ? { signIn: saved.signIn } : {}
|
|
2098
|
+
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {}, ...saved ? { saved: saved.steps } : {} });
|
|
1977
2099
|
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1978
2100
|
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."}
|
|
1979
2101
|
|
|
@@ -1990,37 +2112,60 @@ ${page}`);
|
|
|
1990
2112
|
},
|
|
1991
2113
|
{
|
|
1992
2114
|
name: "act",
|
|
1993
|
-
title: "Do
|
|
1994
|
-
description: `Perform
|
|
2115
|
+
title: "Do things on the page",
|
|
2116
|
+
description: `Perform a step \u2014 or several, in order, as steps: [...] \u2014 and keep each one that works. Stops at the first that fails; returns one line per step and the page afterwards. Batch steps whose elements are already on the page (filling a form, then submitting). 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).`,
|
|
1995
2117
|
inputSchema: {
|
|
1996
2118
|
type: "object",
|
|
1997
2119
|
properties: {
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
2001
|
-
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
2002
|
-
nth: { type: "integer", minimum: 0, description: "Which one, when several elements share this role and name: 0 is the first in the order read_page lists them (each shared name is marked [nth N of M]). Leave it out when the name is unique." },
|
|
2003
|
-
value: { type: "string" },
|
|
2004
|
-
secret: { type: "boolean", description: "fill only: the value is a secret (a password, PIN, key) \u2014 kept out of the flow and saved to the vault. Fields named like a password are secret without this." },
|
|
2005
|
-
line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
|
|
2120
|
+
...STEP_PROPERTIES,
|
|
2121
|
+
steps: { type: "array", maxItems: 25, items: { type: "object", properties: STEP_PROPERTIES, required: ["action"], additionalProperties: false }, description: "Several steps in order, instead of one." }
|
|
2006
2122
|
},
|
|
2007
|
-
required: ["action"],
|
|
2008
2123
|
additionalProperties: false
|
|
2009
2124
|
},
|
|
2010
2125
|
run: async (args) => {
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2126
|
+
const stepOf = (raw) => {
|
|
2127
|
+
if (!isAction(raw["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");
|
|
2128
|
+
return {
|
|
2129
|
+
action: raw["action"],
|
|
2130
|
+
...str(raw["role"]) ? { role: str(raw["role"]) } : {},
|
|
2131
|
+
...str(raw["name"]) !== void 0 ? { name: str(raw["name"]) } : {},
|
|
2132
|
+
...str(raw["within"]) ? { within: str(raw["within"]) } : {},
|
|
2133
|
+
...Number.isInteger(raw["nth"]) && raw["nth"] >= 0 ? { nth: raw["nth"] } : {},
|
|
2134
|
+
...str(raw["value"]) !== void 0 ? { value: str(raw["value"]) } : {},
|
|
2135
|
+
...typeof raw["secret"] === "boolean" ? { secret: raw["secret"] } : {}
|
|
2136
|
+
};
|
|
2137
|
+
};
|
|
2138
|
+
const batch = Array.isArray(args["steps"]) ? args["steps"].slice(0, 25) : null;
|
|
2139
|
+
if (!batch?.length) return tagged(args, async () => session.act(stepOf(args)));
|
|
2140
|
+
const said = [];
|
|
2141
|
+
let page = "";
|
|
2142
|
+
for (let i = 0; i < batch.length; i++) {
|
|
2143
|
+
const raw = batch[i];
|
|
2144
|
+
const what = `${String(raw["action"])}${str(raw["name"]) ? ` "${str(raw["name"])}"` : str(raw["value"]) && raw["action"] !== "fill" ? ` ${str(raw["value"])}` : ""}`;
|
|
2145
|
+
const before = session.keptCount();
|
|
2146
|
+
try {
|
|
2147
|
+
page = await session.act(stepOf(raw));
|
|
2148
|
+
const line = Number(raw["line"]);
|
|
2149
|
+
said.push(`${i + 1}. ${what} \u2014 kept${Number.isInteger(line) && line > 0 ? session.tagSince(before, line) : ""}`);
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
said.push(`${i + 1}. ${what} \u2014 FAILED: ${error.message}`);
|
|
2152
|
+
if (i < batch.length - 1) said.push(`Steps ${i + 2}\u2013${batch.length} were not done.`);
|
|
2153
|
+
break;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return text(`${said.join("\n")}${page ? `
|
|
2157
|
+
|
|
2158
|
+
The page after the last kept step:
|
|
2159
|
+
${page}` : ""}`);
|
|
2022
2160
|
}
|
|
2023
2161
|
},
|
|
2162
|
+
{
|
|
2163
|
+
name: "continue_flow",
|
|
2164
|
+
title: "Put back the waiting steps",
|
|
2165
|
+
description: "After start_flow reopened a saved flow and one step no longer worked: once you have done that step again the way the page now needs, put back the saved steps that came after it \u2014 replayed and kept as they pass, up to the next that fails.",
|
|
2166
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
2167
|
+
run: async () => text(await session.continueFlow())
|
|
2168
|
+
},
|
|
2024
2169
|
{
|
|
2025
2170
|
name: "add_check",
|
|
2026
2171
|
title: "Add a check",
|
|
@@ -2336,8 +2481,10 @@ ${c.key ? `${c.key} ` : ""}${c.feature} \u203A ${c.name} (${c.id})`, ...c.busine
|
|
|
2336
2481
|
const { cases, missing } = await service.readCases(projectId, refs);
|
|
2337
2482
|
const parts = cases.map((c) => [
|
|
2338
2483
|
`${c.key ? `${c.key} ` : ""}${c.folder} \u203A ${c.name} (${c.id}) \u2014 ${c.automated ? `automated, rows: ${c.rows.join(" \u2192 ")}` : "not automated yet"}`,
|
|
2484
|
+
...c.manual ? [" MANUAL \u2014 a person runs this one by hand. Do not automate it."] : [],
|
|
2485
|
+
...c.blocked ? [` BLOCKED last time (${c.blocked.kind}${c.blocked.line ? `, at line ${c.blocked.line}` : ""}): ${c.blocked.detail}${c.blocked.needs ? ` \u2014 needs: ${c.blocked.needs}` : ""}. Try again only if that has changed; otherwise report_blocker again.`] : [],
|
|
2339
2486
|
...c.description ? [` ${c.description}`] : [],
|
|
2340
|
-
...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2487
|
+
...c.businessSteps.length ? c.businessSteps.map((s, i) => ` ${i + 1}. ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2341
2488
|
].join("\n"));
|
|
2342
2489
|
if (missing.length) parts.push(`Not found: ${missing.join(", ")} \u2014 project_catalog lists the test cases.`);
|
|
2343
2490
|
return text(parts.join("\n\n") || "No test cases found.");
|
|
@@ -2463,10 +2610,43 @@ Next: create_account for each person who signs in this way.`);
|
|
|
2463
2610
|
Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
|
|
2464
2611
|
}
|
|
2465
2612
|
},
|
|
2613
|
+
{
|
|
2614
|
+
name: "report_blocker",
|
|
2615
|
+
title: "Say a test case cannot be automated",
|
|
2616
|
+
description: "Record that a test case could not be automated, and why \u2014 so it is marked Blocked on its page with the line it stopped at and what would unblock it, instead of looking untouched. kind: app-bug (the app itself is broken at that step), needs-person (a captcha, a real card, a code on somebody's own phone \u2014 something only a person has), unclear-step (a business step is unclear or does not match the app), not-found (the screen or feature cannot be reached), stuck (3 failed tries at the same step). Then move on to the next case. Saving the case automated later takes the mark off by itself.",
|
|
2617
|
+
inputSchema: {
|
|
2618
|
+
type: "object",
|
|
2619
|
+
properties: {
|
|
2620
|
+
projectId: { type: "string" },
|
|
2621
|
+
case: { type: "string", description: "The case's id, TC-\u2026 (read_test_cases gives it)." },
|
|
2622
|
+
kind: { type: "string", enum: ["app-bug", "needs-person", "unclear-step", "not-found", "stuck"] },
|
|
2623
|
+
line: { type: "number", description: "The business step it stopped at, counted from 1." },
|
|
2624
|
+
detail: { type: "string", description: "What happened, in a sentence or two: what you did and what the app did." },
|
|
2625
|
+
needs: { type: "string", description: 'What would unblock it, said for a person \u2014 e.g. "a helper for the SMS code", "the step should say which plan to pick".' },
|
|
2626
|
+
platform: { type: "string", enum: ["web", "android", "ios"] },
|
|
2627
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches the case is on." }
|
|
2628
|
+
},
|
|
2629
|
+
required: ["case", "kind", "detail"],
|
|
2630
|
+
additionalProperties: false
|
|
2631
|
+
},
|
|
2632
|
+
run: async (args) => {
|
|
2633
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2634
|
+
const made = await service.reportBlocker(projectId, {
|
|
2635
|
+
case: str(args["case"]) ?? "",
|
|
2636
|
+
kind: str(args["kind"]) ?? "",
|
|
2637
|
+
detail: str(args["detail"]) ?? "",
|
|
2638
|
+
...typeof args["line"] === "number" ? { line: args["line"] } : {},
|
|
2639
|
+
...str(args["needs"]) ? { needs: str(args["needs"]) } : {},
|
|
2640
|
+
...str(args["platform"]) ? { platform: str(args["platform"]) } : {},
|
|
2641
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2642
|
+
});
|
|
2643
|
+
return text(`Marked ${made.key ?? made.caseId} Blocked \u2014 ${made.label}. A person sees it on the case with what it needs. Move on to the next case.`);
|
|
2644
|
+
}
|
|
2645
|
+
},
|
|
2466
2646
|
{
|
|
2467
2647
|
name: "create_helper",
|
|
2468
2648
|
title: "Make a helper",
|
|
2469
|
-
description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
|
|
2649
|
+
description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. command (runs on the tester's computer): {kind, run: {program, args: [..]}, perSystem?: {macos|windows|linux: {program, args}}, folder?, env?, secretEnv?: {NAME: ""}, mode?: run|background, readyPattern?, timeoutSeconds?, expectExit?, extract?: {take: output|pattern|json|nothing, pattern, path}} with secretValues {"env:<NAME>": value}; no shell \u2014 one program, its arguments a list. A command runs only after the person allows it on their computer (flowriki-agent allow "<name>"): when use_helper says it is not allowed, stop and tell them that exact command \u2014 never work around it. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
|
|
2470
2650
|
inputSchema: {
|
|
2471
2651
|
type: "object",
|
|
2472
2652
|
properties: {
|
|
@@ -2496,7 +2676,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
|
|
|
2496
2676
|
{
|
|
2497
2677
|
name: "project_brief",
|
|
2498
2678
|
title: "Read the project's memory",
|
|
2499
|
-
description: "The project's own knowledge, in one call: About the product (product.md, and product/<web|android|ios>.md where a platform differs), memory/decisions.md, what each linked codebase folder was learned as and which platform's code it holds, what each platform looked like when explored (explore/<platform>.md), and the list of feature pages and session summaries. Call it
|
|
2679
|
+
description: "The project's own knowledge, in one call: About the product (product.md, and product/<web|android|ios>.md where a platform differs), memory/decisions.md, what each linked codebase folder was learned as and which platform's code it holds, what each platform looked like when explored (explore/<platform>.md), and the list of feature pages and session summaries. Call it at the start when no brief was given with the request (a given brief IS this). With platform and/or features, the brief is the slice for that job: that platform's part and exploration, those features' notes, the code tagged for the platform, and who can sign in.",
|
|
2500
2680
|
inputSchema: { type: "object", properties: { projectId: { type: "string" }, platform: { type: "string", enum: ["web", "android", "ios"], description: "The platform the job is about." }, features: { type: "array", items: { type: "string" }, description: "The features (or case folders) the job is about, by name." } }, additionalProperties: false },
|
|
2501
2681
|
annotations: { readOnlyHint: true },
|
|
2502
2682
|
run: async (args) => {
|
|
@@ -2839,6 +3019,10 @@ async function runMcp(opts) {
|
|
|
2839
3019
|
const connection = connectionFor(service);
|
|
2840
3020
|
const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
|
|
2841
3021
|
let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
|
|
3022
|
+
const only = (process.env["FLOWRIKI_TOOLS"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
3023
|
+
if (only.length) tools = tools.filter((tool) => only.includes(tool.name));
|
|
3024
|
+
const except = (process.env["FLOWRIKI_TOOLS_EXCEPT"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
3025
|
+
if (except.length) tools = tools.filter((tool) => !except.includes(tool.name));
|
|
2842
3026
|
if (!connection) {
|
|
2843
3027
|
const why = `This computer is not connected to Flowriki at ${service}. Run \`flowriki-agent connect\` in a terminal, approve it in your browser, then restart Claude.`;
|
|
2844
3028
|
tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
|
|
@@ -2868,6 +3052,7 @@ async function runMcp(opts) {
|
|
|
2868
3052
|
}
|
|
2869
3053
|
|
|
2870
3054
|
// src/cli.ts
|
|
3055
|
+
var quoteArg = (arg) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : JSON.stringify(arg);
|
|
2871
3056
|
var paint = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
|
|
2872
3057
|
var bold = paint("1");
|
|
2873
3058
|
var dim = paint("2");
|
|
@@ -2890,6 +3075,11 @@ ${bold("USAGE")}
|
|
|
2890
3075
|
needs (Appium and its drivers)
|
|
2891
3076
|
flowriki-agent mobile doctor is this computer ready to test phones
|
|
2892
3077
|
flowriki-agent mobile devices the phones, emulators and simulators here
|
|
3078
|
+
flowriki-agent commands command helpers waiting to be allowed here,
|
|
3079
|
+
and the ones already allowed
|
|
3080
|
+
flowriki-agent allow <name> allow a command helper to run on this
|
|
3081
|
+
computer \u2014 it is shown to you first
|
|
3082
|
+
flowriki-agent revoke <name> stop allowing one
|
|
2893
3083
|
flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
|
|
2894
3084
|
Claude Code or Claude Desktop, not by you:
|
|
2895
3085
|
claude mcp add flowriki -- flowriki-agent mcp
|
|
@@ -2921,6 +3111,7 @@ async function main(argv) {
|
|
|
2921
3111
|
service: { type: "string" },
|
|
2922
3112
|
json: { type: "boolean" },
|
|
2923
3113
|
"no-open": { type: "boolean" },
|
|
3114
|
+
yes: { type: "boolean", short: "y" },
|
|
2924
3115
|
help: { type: "boolean", short: "h" },
|
|
2925
3116
|
version: { type: "boolean", short: "v" }
|
|
2926
3117
|
}
|
|
@@ -2959,6 +3150,8 @@ Run \`flowriki-agent --help\`.`);
|
|
|
2959
3150
|
console.log(`
|
|
2960
3151
|
${bold("Approve this computer in your browser")}`);
|
|
2961
3152
|
console.log(` ${url}
|
|
3153
|
+
`);
|
|
3154
|
+
if (note.detail?.["code"]) console.log(` ${bold("Your code:")} ${bold(String(note.detail["code"]))} ${dim("\u2014 the page asks for it")}
|
|
2962
3155
|
`);
|
|
2963
3156
|
if (note.detail?.["canOpen"] === false) {
|
|
2964
3157
|
console.log(dim(" Open the approval link in your browser.\n"));
|
|
@@ -3025,11 +3218,16 @@ Run \`flowriki-agent --help\`.`);
|
|
|
3025
3218
|
setInterval(() => void tellIfBehind(), 6 * 60 * 6e4).unref();
|
|
3026
3219
|
let stopping = false;
|
|
3027
3220
|
const shutdown = async (signal) => {
|
|
3028
|
-
if (stopping)
|
|
3221
|
+
if (stopping) {
|
|
3222
|
+
console.log(dim(" stopping now"));
|
|
3223
|
+
process.exit(130);
|
|
3224
|
+
}
|
|
3029
3225
|
stopping = true;
|
|
3030
3226
|
console.log(dim(`
|
|
3031
|
-
${signal} \u2014 stopping`));
|
|
3032
|
-
|
|
3227
|
+
${signal} \u2014 stopping (press Ctrl-C again to stop at once)`));
|
|
3228
|
+
const force = setTimeout(() => process.exit(1), 1e4);
|
|
3229
|
+
force.unref();
|
|
3230
|
+
await started.stop().catch(() => void 0);
|
|
3033
3231
|
process.exit(0);
|
|
3034
3232
|
};
|
|
3035
3233
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
@@ -3132,6 +3330,69 @@ Run \`flowriki-agent --help\`.`);
|
|
|
3132
3330
|
console.error(`unknown: mobile ${sub} \u2014 use setup, doctor or devices`);
|
|
3133
3331
|
return 2;
|
|
3134
3332
|
}
|
|
3333
|
+
// COMMAND HELPERS: written by anybody with the project, run on THIS
|
|
3334
|
+
// computer only once somebody sitting here has seen and allowed them.
|
|
3335
|
+
case "commands": {
|
|
3336
|
+
const { allowed, waiting } = readApprovals();
|
|
3337
|
+
const show = (c) => `${[c.program, ...c.args].map(quoteArg).join(" ")}${c.folder ? dim(` in ${c.folder}`) : ""}${c.mode === "background" ? dim(" (keeps running during the test)") : ""}`;
|
|
3338
|
+
if (values.json) {
|
|
3339
|
+
console.log(JSON.stringify({ allowed: Object.values(allowed), waiting: Object.values(waiting) }, null, 2));
|
|
3340
|
+
return 0;
|
|
3341
|
+
}
|
|
3342
|
+
console.log(bold("Waiting to be allowed"));
|
|
3343
|
+
if (!Object.keys(waiting).length) console.log(dim(" none"));
|
|
3344
|
+
for (const c of Object.values(waiting)) console.log(` ${yellow("\u2022")} ${c.name}
|
|
3345
|
+
${show(c)}`);
|
|
3346
|
+
console.log(bold("\nAllowed on this computer"));
|
|
3347
|
+
if (!Object.keys(allowed).length) console.log(dim(" none"));
|
|
3348
|
+
for (const c of Object.values(allowed)) console.log(` ${green("\u2713")} ${c.name}
|
|
3349
|
+
${show(c)}`);
|
|
3350
|
+
return 0;
|
|
3351
|
+
}
|
|
3352
|
+
case "allow":
|
|
3353
|
+
case "revoke": {
|
|
3354
|
+
const which = positionals.slice(1).join(" ").trim();
|
|
3355
|
+
if (!which) {
|
|
3356
|
+
console.error(`say which: flowriki-agent ${command} "<helper name>" (or all)`);
|
|
3357
|
+
return 2;
|
|
3358
|
+
}
|
|
3359
|
+
if (command === "revoke") {
|
|
3360
|
+
const gone = revokeCommands(which);
|
|
3361
|
+
console.log(gone.length ? `${green("no longer allowed:")} ${gone.map((c) => c.name).join(", ")}` : yellow(`nothing allowed matches "${which}"`));
|
|
3362
|
+
return gone.length ? 0 : 1;
|
|
3363
|
+
}
|
|
3364
|
+
const { waiting } = readApprovals();
|
|
3365
|
+
const wanted = which.toLowerCase();
|
|
3366
|
+
const matching = Object.values(waiting).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
|
|
3367
|
+
if (!matching.length) {
|
|
3368
|
+
console.log(yellow(`nothing is waiting that matches "${which}". A command is listed here after a test or Try it asks to run it \u2014 see flowriki-agent commands.`));
|
|
3369
|
+
return 1;
|
|
3370
|
+
}
|
|
3371
|
+
for (const c of matching) {
|
|
3372
|
+
console.log(`${bold(c.name)}
|
|
3373
|
+
runs ${[c.program, ...c.args].map(quoteArg).join(" ")}
|
|
3374
|
+
in ${c.folder || "the project's folder"}${c.env.length ? `
|
|
3375
|
+
env ${c.env.join(", ")}` : ""}${c.mode === "background" ? "\n keeps running until the test ends" : ""}`);
|
|
3376
|
+
}
|
|
3377
|
+
if (!values.yes) {
|
|
3378
|
+
if (!process.stdin.isTTY) {
|
|
3379
|
+
console.error("add --yes to allow without being asked");
|
|
3380
|
+
return 2;
|
|
3381
|
+
}
|
|
3382
|
+
const { createInterface } = await import("readline/promises");
|
|
3383
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3384
|
+
const answer = (await rl.question(`
|
|
3385
|
+
Allow ${matching.length === 1 ? "this command" : `these ${matching.length} commands`} to run on this computer? [y/N] `)).trim().toLowerCase();
|
|
3386
|
+
rl.close();
|
|
3387
|
+
if (answer !== "y" && answer !== "yes") {
|
|
3388
|
+
console.log("Not allowed.");
|
|
3389
|
+
return 1;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
const done = allowCommands(which);
|
|
3393
|
+
console.log(`${green("allowed:")} ${done.map((c) => c.name).join(", ")}. Tests and Try it can run ${done.length === 1 ? "it" : "them"} here now.`);
|
|
3394
|
+
return 0;
|
|
3395
|
+
}
|
|
3135
3396
|
case "projects": {
|
|
3136
3397
|
const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
|
|
3137
3398
|
for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);
|