@flowriki/agent 0.1.0-beta.63 → 0.1.0-beta.65
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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
AGENT_VERSION,
|
|
5
5
|
agentUpdateFor,
|
|
6
6
|
authenticationResolver,
|
|
7
|
+
backupsFor,
|
|
7
8
|
checkAssertion,
|
|
8
9
|
closeRunSession,
|
|
9
10
|
connect,
|
|
@@ -23,8 +24,9 @@ import {
|
|
|
23
24
|
resolveService,
|
|
24
25
|
runtimeValue,
|
|
25
26
|
start,
|
|
26
|
-
status
|
|
27
|
-
|
|
27
|
+
status,
|
|
28
|
+
withBackups
|
|
29
|
+
} from "./chunk-FJVNOLHT.js";
|
|
28
30
|
import {
|
|
29
31
|
CLOUD_PROVIDERS,
|
|
30
32
|
CLOUD_PROVIDER_NAME,
|
|
@@ -52,7 +54,7 @@ import {
|
|
|
52
54
|
slugify,
|
|
53
55
|
stepRefusedOn,
|
|
54
56
|
targetFor
|
|
55
|
-
} from "./chunk-
|
|
57
|
+
} from "./chunk-PDUMGI6V.js";
|
|
56
58
|
|
|
57
59
|
// src/cli.ts
|
|
58
60
|
import { parseArgs } from "util";
|
|
@@ -245,6 +247,13 @@ var ServiceClient = class {
|
|
|
245
247
|
{ method: "POST", body: JSON.stringify(input) }
|
|
246
248
|
);
|
|
247
249
|
}
|
|
250
|
+
/** A test case that cannot be automated, and why. */
|
|
251
|
+
reportBlocker(projectId, input) {
|
|
252
|
+
return this.call(
|
|
253
|
+
`/api/agent/cases/blocker?projectId=${encodeURIComponent(projectId)}`,
|
|
254
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
248
257
|
/** A helper, its secrets sealed by the service. */
|
|
249
258
|
createHelper(projectId, input) {
|
|
250
259
|
return this.call(
|
|
@@ -253,6 +262,11 @@ var ServiceClient = class {
|
|
|
253
262
|
);
|
|
254
263
|
}
|
|
255
264
|
/** What a new flow starts after: the flows, in run order, and their steps. */
|
|
265
|
+
/** A saved flow's steps, to fix it from rather than record it again. */
|
|
266
|
+
savedFlow(projectId, name, platform) {
|
|
267
|
+
const q = new URLSearchParams({ projectId, name, ...platform ? { platform } : {} });
|
|
268
|
+
return this.call(`/api/agent/flows/saved?${q}`);
|
|
269
|
+
}
|
|
256
270
|
flowsAfter(projectId, after) {
|
|
257
271
|
return this.call(
|
|
258
272
|
`/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
|
|
@@ -308,8 +322,8 @@ var ServiceClient = class {
|
|
|
308
322
|
);
|
|
309
323
|
}
|
|
310
324
|
readCases(projectId, refs) {
|
|
311
|
-
const ids = refs.filter((r) => /^tc_
|
|
312
|
-
const names = refs.filter((r) => !/^tc_
|
|
325
|
+
const ids = refs.filter((r) => /^(tc_|tc-?\d+$|#\d+$)/i.test(r.trim()));
|
|
326
|
+
const names = refs.filter((r) => !/^(tc_|tc-?\d+$|#\d+$)/i.test(r.trim()));
|
|
313
327
|
const q = new URLSearchParams({ projectId, ...ids.length ? { ids: ids.join(",") } : {}, ...names.length ? { names: names.join("|") } : {} });
|
|
314
328
|
return this.call(`/api/agent/cases/read?${q}`);
|
|
315
329
|
}
|
|
@@ -417,6 +431,15 @@ import fs from "fs";
|
|
|
417
431
|
import path from "path";
|
|
418
432
|
var MAX_TREE = 14e3;
|
|
419
433
|
var MAX_STEP_VIEW = 6e3;
|
|
434
|
+
function quiet(tree) {
|
|
435
|
+
return tree.split("\n").filter((line) => {
|
|
436
|
+
const t = line.trim();
|
|
437
|
+
if (/^- \/url:/.test(t)) return false;
|
|
438
|
+
if (/^- img$/.test(t) || /^- img:$/.test(t)) return false;
|
|
439
|
+
if (/^- listitem$/.test(t)) return false;
|
|
440
|
+
return true;
|
|
441
|
+
}).join("\n");
|
|
442
|
+
}
|
|
420
443
|
function topLevelBlocks(tree) {
|
|
421
444
|
const blocks = [];
|
|
422
445
|
for (const line of tree.split("\n")) {
|
|
@@ -430,7 +453,7 @@ function topLevelBlocks(tree) {
|
|
|
430
453
|
};
|
|
431
454
|
}
|
|
432
455
|
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;
|
|
456
|
+
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
457
|
var TYPED = "typed_";
|
|
435
458
|
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
436
459
|
var SIGN_UP = /\b(sign[\s-]?up|register|registration|create (an |your )?account|join)\b/i;
|
|
@@ -609,6 +632,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
609
632
|
values = /* @__PURE__ */ new Map();
|
|
610
633
|
resolvers = null;
|
|
611
634
|
lastTree = null;
|
|
635
|
+
/** The navigation as last shown, so a step repeats it only when it changed. */
|
|
636
|
+
lastNavigation = null;
|
|
612
637
|
/** Values typed into secret fields, by their `typed:` reference. */
|
|
613
638
|
typed = /* @__PURE__ */ new Map();
|
|
614
639
|
draft = null;
|
|
@@ -670,6 +695,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
670
695
|
* it is kept, so a flow interrupted — the program stopped, the limit hit,
|
|
671
696
|
* the agent restarted — is not lost: starting the same flow again replays
|
|
672
697
|
* what was kept and carries on from there, unless `fresh` says start over. */
|
|
698
|
+
/** Steps of a saved flow still to put back, after one failed on the way. */
|
|
699
|
+
waiting = [];
|
|
673
700
|
async start(input, catalog, opts = {}) {
|
|
674
701
|
await this.close();
|
|
675
702
|
this.catalog = catalog;
|
|
@@ -678,7 +705,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
678
705
|
this.typed = /* @__PURE__ */ new Map();
|
|
679
706
|
this.lastTree = null;
|
|
680
707
|
this.verified = null;
|
|
681
|
-
|
|
708
|
+
this.waiting = [];
|
|
709
|
+
const left = opts.fresh ? null : this.readDraft(input.projectId, input.name) ?? (opts.saved?.length ? { steps: opts.saved } : null);
|
|
682
710
|
if (opts.fresh) this.dropDraft(input.projectId, input.name);
|
|
683
711
|
const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
|
|
684
712
|
this.report();
|
|
@@ -691,7 +719,7 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
691
719
|
async resume(steps, opened) {
|
|
692
720
|
const draft = this.draft;
|
|
693
721
|
let failed = null;
|
|
694
|
-
for (const step of steps) {
|
|
722
|
+
for (const [at, step] of steps.entries()) {
|
|
695
723
|
try {
|
|
696
724
|
if (this.mobile) {
|
|
697
725
|
const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
|
|
@@ -713,17 +741,28 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
713
741
|
this.checkpoint();
|
|
714
742
|
} catch (error) {
|
|
715
743
|
failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
|
|
744
|
+
this.waiting = steps.slice(at + 1);
|
|
716
745
|
break;
|
|
717
746
|
}
|
|
718
747
|
}
|
|
719
748
|
this.checkpoint();
|
|
720
749
|
const kept = draft.steps.length;
|
|
721
|
-
const
|
|
750
|
+
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.` : "";
|
|
751
|
+
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
752
|
const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
|
|
723
753
|
return `${head}
|
|
724
754
|
|
|
725
755
|
${view}`;
|
|
726
756
|
}
|
|
757
|
+
/** Put back the steps that were waiting after a failed one — replayed,
|
|
758
|
+
* kept as they pass; the next that fails is where you take over again. */
|
|
759
|
+
async continueFlow() {
|
|
760
|
+
this.requireOpen();
|
|
761
|
+
const steps = this.waiting;
|
|
762
|
+
if (!steps.length) throw new Error("no steps are waiting \u2014 the flow is whole; add_check, replay_flow and save_flow");
|
|
763
|
+
this.waiting = [];
|
|
764
|
+
return this.resume(steps, "");
|
|
765
|
+
}
|
|
727
766
|
// ── Drafts on disk ──────────────────────────────────────────────────────
|
|
728
767
|
static draftFile(projectId, name) {
|
|
729
768
|
return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
|
|
@@ -945,32 +984,36 @@ Record the flows it needs; on every act, add_check and use_helper pass line: the
|
|
|
945
984
|
const before = this.lastTree;
|
|
946
985
|
this.lastTree = tree;
|
|
947
986
|
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
948
|
-
\u2026 (cut
|
|
949
|
-
if (full || before === null)
|
|
950
|
-
|
|
987
|
+
\u2026 (cut)` : t;
|
|
988
|
+
if (full || before === null) {
|
|
989
|
+
this.lastNavigation = topLevelBlocks(tree).navigation ?? null;
|
|
990
|
+
return head + cap(tree, MAX_TREE);
|
|
991
|
+
}
|
|
992
|
+
const { dialogs, navigation: nav } = topLevelBlocks(tree);
|
|
993
|
+
const navigation = nav && nav !== this.lastNavigation ? nav : null;
|
|
994
|
+
this.lastNavigation = nav ?? null;
|
|
951
995
|
if (dialogs.length) {
|
|
952
|
-
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog
|
|
996
|
+
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog)";
|
|
953
997
|
}
|
|
954
998
|
const was = new Set(before.split("\n"));
|
|
955
999
|
const now = new Set(tree.split("\n"));
|
|
956
1000
|
const added = tree.split("\n").filter((l) => !was.has(l));
|
|
957
1001
|
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
|
|
1002
|
+
if (added.length === 0 && removed.length === 0) return head + "(the page did not change)";
|
|
959
1003
|
if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
|
|
960
1004
|
return head + [
|
|
961
1005
|
...navigation ? [navigation, ""] : [],
|
|
962
1006
|
added.length ? `Appeared:
|
|
963
1007
|
${added.join("\n")}` : "",
|
|
964
1008
|
removed.length ? `Gone:
|
|
965
|
-
${removed.join("\n")}` : ""
|
|
966
|
-
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
1009
|
+
${removed.join("\n")}` : ""
|
|
967
1010
|
].filter(Boolean).join("\n");
|
|
968
1011
|
}
|
|
969
1012
|
async webTree() {
|
|
970
1013
|
const { page, draft } = this.requireOpen();
|
|
971
1014
|
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
972
1015
|
const tree = numberDuplicates(labelTree(
|
|
973
|
-
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
1016
|
+
quiet(await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`)),
|
|
974
1017
|
await fieldNames(page)
|
|
975
1018
|
));
|
|
976
1019
|
const title = await page.title().catch(() => "");
|
|
@@ -980,7 +1023,28 @@ Steps so far: ${draft.steps.length}
|
|
|
980
1023
|
` };
|
|
981
1024
|
}
|
|
982
1025
|
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
1026
|
+
/** The step that failed last, and how many times in a row. */
|
|
1027
|
+
failing = null;
|
|
1028
|
+
/** DO ONE STEP — and notice when the same step keeps failing. Retrying it
|
|
1029
|
+
* turn after turn was where whole jobs went: each try re-reads the entire
|
|
1030
|
+
* conversation. On the third failure in a row, the answer says to stop. */
|
|
983
1031
|
async act(input) {
|
|
1032
|
+
const key = [input.action, input.role ?? "", input.name ?? "", input.within ?? "", input.nth ?? ""].join("|");
|
|
1033
|
+
try {
|
|
1034
|
+
const done = await this.actOnce(input);
|
|
1035
|
+
this.failing = null;
|
|
1036
|
+
return done;
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
this.failing = this.failing?.key === key ? { key, times: this.failing.times + 1 } : { key, times: 1 };
|
|
1039
|
+
if (this.failing.times >= 3) {
|
|
1040
|
+
error.message = `${error.message}
|
|
1041
|
+
|
|
1042
|
+
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.`;
|
|
1043
|
+
}
|
|
1044
|
+
throw error;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
async actOnce(input) {
|
|
984
1048
|
if (this.mobile) return this.mobileAct(input);
|
|
985
1049
|
const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
|
|
986
1050
|
if (refused) throw new Error(refused);
|
|
@@ -992,11 +1056,11 @@ Steps so far: ${draft.steps.length}
|
|
|
992
1056
|
if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
|
|
993
1057
|
throw new Error(`${input.action} needs a value`);
|
|
994
1058
|
}
|
|
995
|
-
const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
1059
|
+
const target = navigating ? { role: "", name: "", scope: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
996
1060
|
const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
|
|
997
1061
|
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
998
1062
|
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
999
|
-
|
|
1063
|
+
let step = RecordedStepSchema.parse({
|
|
1000
1064
|
id,
|
|
1001
1065
|
type: input.action,
|
|
1002
1066
|
...target,
|
|
@@ -1011,6 +1075,7 @@ Steps so far: ${draft.steps.length}
|
|
|
1011
1075
|
// environment the run is pointed at rather than the one written on.
|
|
1012
1076
|
...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
|
|
1013
1077
|
});
|
|
1078
|
+
if (!navigating && input.action !== "press") step = RecordedStepSchema.parse(withBackups(step, await backupsFor(page, step)));
|
|
1014
1079
|
try {
|
|
1015
1080
|
this.page = await executeStep(page, step, {
|
|
1016
1081
|
pages: run.pages,
|
|
@@ -1133,9 +1198,7 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
|
1133
1198
|
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
1134
1199
|
const [gone] = draft.steps.splice(number - 1, 1);
|
|
1135
1200
|
this.checkpoint();
|
|
1136
|
-
return `Removed step ${number} (${describe(gone)}). The browser was not undone
|
|
1137
|
-
|
|
1138
|
-
${this.list()}`;
|
|
1201
|
+
return `Removed step ${number} (${describe(gone)}); ${draft.steps.length} steps now. The browser was not undone.`;
|
|
1139
1202
|
}
|
|
1140
1203
|
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
1141
1204
|
* run will — the proof the steps work together, not just one at a time. */
|
|
@@ -1347,7 +1410,7 @@ Steps so far: ${draft.steps.length}
|
|
|
1347
1410
|
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
1348
1411
|
}
|
|
1349
1412
|
if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
|
|
1350
|
-
let target = { role: "", name: "" };
|
|
1413
|
+
let target = { role: "", name: "", scope: "" };
|
|
1351
1414
|
let password = false;
|
|
1352
1415
|
if (input.role) {
|
|
1353
1416
|
const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
|
|
@@ -1552,6 +1615,20 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1552
1615
|
account.
|
|
1553
1616
|
- HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
|
|
1554
1617
|
with create_helper when the catalog has none that fits.
|
|
1618
|
+
- HEALING fixes a test's automation, never its business: change only how steps
|
|
1619
|
+
find and reach things (an element renamed or moved, a new screen in the way,
|
|
1620
|
+
a wait). Never change a case's Given / When / Then, what a check expects or
|
|
1621
|
+
which checks are on, and never drop a row \u2014 in a heal the service refuses
|
|
1622
|
+
such a save. If the app itself behaves differently now, report_blocker
|
|
1623
|
+
(app-bug) and leave that case.
|
|
1624
|
+
- WHEN A CASE CANNOT BE AUTOMATED, say so with report_blocker and move on to
|
|
1625
|
+
the next case \u2014 never loop. Report it when the app itself is broken at that
|
|
1626
|
+
step (app-bug), when it needs something only a person has \u2014 a captcha, a
|
|
1627
|
+
real card, a code sent to somebody's own phone (needs-person), when a
|
|
1628
|
+
business step is unclear or does not match the app (unclear-step), when the
|
|
1629
|
+
screen or feature cannot be reached (not-found), or after 3 failed tries at
|
|
1630
|
+
the same step (stuck). Name the line it stopped at and what would unblock it.
|
|
1631
|
+
A case marked manual by a person is not to be automated at all.
|
|
1555
1632
|
- SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
|
|
1556
1633
|
"Select & Customize") are told apart by POSITION, like an XPath index: read_page
|
|
1557
1634
|
marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
|
|
@@ -1562,12 +1639,16 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1562
1639
|
Only use credentials the person gave you, that the app itself shows for
|
|
1563
1640
|
testing (a demo login), or of a test user you signed up yourself \u2014 never
|
|
1564
1641
|
guess or reuse anyone's real password.
|
|
1642
|
+
- IDS: every test case has an id people use \u2014 TC-12 \u2014 and every feature one \u2014
|
|
1643
|
+
F-3. When the person names one, it is that one: read_test_cases takes ids,
|
|
1644
|
+
and save_case's case fills or redoes exactly that test case. Say the id
|
|
1645
|
+
whenever you name a case back.
|
|
1565
1646
|
- FOLDERS do not nest. Name each one after the product area and what is in it,
|
|
1566
1647
|
the way a tester would look for it \u2014 "Account \u2013 Sign up", "Account \u2013 Sign in",
|
|
1567
1648
|
"Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
|
|
1568
1649
|
cases built from them use the same names. Keep one area per folder.
|
|
1569
1650
|
|
|
1570
|
-
KNOWLEDGE: project_brief is the project's memory \u2014
|
|
1651
|
+
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
|
|
1571
1652
|
knowledge pages with knowledge_search; read one with knowledge_read.
|
|
1572
1653
|
product.md is "About the product": the person's own description of
|
|
1573
1654
|
the product, in their words, as long as they like \u2014 read it first. Beside it,
|
|
@@ -1627,14 +1708,17 @@ mix rows on a phone with rows in a browser (a customer orders in the app, an
|
|
|
1627
1708
|
admin approves on the website).
|
|
1628
1709
|
|
|
1629
1710
|
HOW TO WORK:
|
|
1630
|
-
1.
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
checks)
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1711
|
+
1. Reuse what exists \u2014 published, and on Claude's working branch \u2014 rather than
|
|
1712
|
+
writing it again: project_catalog lists it (once is enough; a repeat says
|
|
1713
|
+
whether anything changed).
|
|
1714
|
+
2. Plan the folders, the building-block flows (and what each starts after) and
|
|
1715
|
+
the test cases (rows + what each checks), then record the blocks, earliest
|
|
1716
|
+
first. No need to wait for an answer: nobody is at the keyboard.
|
|
1717
|
+
3. Record with start_flow (pass "after" for the flows it continues from) \u2014 it
|
|
1718
|
+
answers with the page \u2014 and act using the role and name EXACTLY as the page
|
|
1719
|
+
shows them, several steps per act when they are all on the page already.
|
|
1720
|
+
Each step answers with what changed; read_page only when you need the whole
|
|
1721
|
+
page again. If a name appears more than once, pass "within" with the name of
|
|
1638
1722
|
the region, dialog or form \u2014 or "dialog" / "navigation".
|
|
1639
1723
|
4. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
1640
1724
|
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
@@ -1701,6 +1785,7 @@ var CASE_INPUT = {
|
|
|
1701
1785
|
}
|
|
1702
1786
|
},
|
|
1703
1787
|
platform: { type: "string", enum: ["web", "android", "ios"], description: "The platform this case is for, when the project tests several: its rows are that platform's flows. Its label \u2014 not words in its name." },
|
|
1788
|
+
case: { type: "string", description: "The test case to fill or redo, by its id \u2014 TC-12 \u2014 wherever it is: automating or re-automating a case you were asked about by id. Default: the case of this name in this folder." },
|
|
1704
1789
|
branch: { type: "string", description: "Optional: save onto this branch (chg_\u2026) instead of the working one." },
|
|
1705
1790
|
projectId: { type: "string" }
|
|
1706
1791
|
},
|
|
@@ -1714,12 +1799,24 @@ var caseSpec = (args) => ({
|
|
|
1714
1799
|
rows: Array.isArray(args["rows"]) ? args["rows"] : [],
|
|
1715
1800
|
...Array.isArray(args["businessSteps"]) ? { businessSteps: args["businessSteps"] } : {},
|
|
1716
1801
|
...args["branch"] ? { changeId: String(args["branch"]) } : {},
|
|
1717
|
-
...args["platform"] === "web" || args["platform"] === "android" || args["platform"] === "ios" ? { platform: args["platform"] } : {}
|
|
1802
|
+
...args["platform"] === "web" || args["platform"] === "android" || args["platform"] === "ios" ? { platform: args["platform"] } : {},
|
|
1803
|
+
...typeof args["case"] === "string" && args["case"].trim() ? { case: args["case"].trim() } : {}
|
|
1718
1804
|
});
|
|
1719
1805
|
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
1720
1806
|
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
1807
|
+
var STEP_PROPERTIES = {
|
|
1808
|
+
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"] },
|
|
1809
|
+
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox." },
|
|
1810
|
+
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1811
|
+
within: { type: "string", description: "When the name is not unique: the region, dialog or form that holds it." },
|
|
1812
|
+
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)." },
|
|
1813
|
+
value: { type: "string" },
|
|
1814
|
+
secret: { type: "boolean", description: "fill only: a password, PIN or key \u2014 kept out of the flow, saved to the vault." },
|
|
1815
|
+
line: { type: "integer", minimum: 1, description: "The test case line (from start_case) this step carries out." }
|
|
1816
|
+
};
|
|
1721
1817
|
function authoringTools(service, session) {
|
|
1722
|
-
let defaultProject = null;
|
|
1818
|
+
let defaultProject = process.env["FLOWRIKI_PROJECT_ID"]?.trim() || null;
|
|
1819
|
+
const catalogSaid = /* @__PURE__ */ new Map();
|
|
1723
1820
|
const catalogs = /* @__PURE__ */ new Map();
|
|
1724
1821
|
const projectFor = async (asked) => {
|
|
1725
1822
|
const named = str(asked);
|
|
@@ -1813,13 +1910,20 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1813
1910
|
{
|
|
1814
1911
|
name: "project_catalog",
|
|
1815
1912
|
title: "What a test can be built from",
|
|
1816
|
-
description: "
|
|
1817
|
-
inputSchema: { type: "object", properties: {
|
|
1913
|
+
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.",
|
|
1914
|
+
inputSchema: { type: "object", properties: {
|
|
1915
|
+
projectId: { type: "string", description: "Optional when you are on one project." },
|
|
1916
|
+
platform: { type: "string", enum: ["web", "android", "ios"], description: "Only this platform's flows." },
|
|
1917
|
+
folder: { type: "string", description: "List the test cases of this folder (by name)." }
|
|
1918
|
+
}, additionalProperties: false },
|
|
1818
1919
|
annotations: { readOnlyHint: true },
|
|
1819
1920
|
run: async (args) => {
|
|
1820
1921
|
const projectId = await projectFor(args["projectId"]);
|
|
1821
1922
|
const c = await catalogOf(projectId, true);
|
|
1822
|
-
const
|
|
1923
|
+
const onlyPlatform = str(args["platform"]);
|
|
1924
|
+
const onPlatform = (f) => !onlyPlatform || (onlyPlatform === "web" ? !isMobile(f.platform) : f.platform === onlyPlatform);
|
|
1925
|
+
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");
|
|
1926
|
+
const askedFolder = str(args["folder"])?.toLowerCase();
|
|
1823
1927
|
const platforms = platformsOf(c);
|
|
1824
1928
|
const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
|
|
1825
1929
|
const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
|
|
@@ -1828,11 +1932,11 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1828
1932
|
const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
|
|
1829
1933
|
const chosen = c.mobile?.devices[p];
|
|
1830
1934
|
return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not named \u2014 Settings \u2192 Environments)"}
|
|
1831
|
-
Phones on this computer: ${here.
|
|
1935
|
+
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 ? `
|
|
1832
1936
|
Cloud device farms: ${farms.join(", ")}` : "") + `
|
|
1833
1937
|
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}`}` : "");
|
|
1834
1938
|
});
|
|
1835
|
-
|
|
1939
|
+
const out = [
|
|
1836
1940
|
`Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
|
|
1837
1941
|
platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}${c.runWhere?.web === "cloud" ? " \u2014 test runs use a cloud browser; recording is on this computer" : ""}` : "",
|
|
1838
1942
|
...phoneLines,
|
|
@@ -1847,19 +1951,31 @@ On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet pub
|
|
|
1847
1951
|
...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
|
|
1848
1952
|
...c.draft.flows.length ? c.draft.flows.map((f) => ` - flow: ${f.name} (${f.steps} steps, feature ${f.featureId}${f.after?.length ? `, after ${f.after.map((a) => `"${a}"`).join(", ")}` : ""})`) : [" (no flows yet)"],
|
|
1849
1953
|
...c.draft.caseFolders?.length ? [` New case folders: ${c.draft.caseFolders.map((f) => f.name).join(", ")}`] : [],
|
|
1850
|
-
...(c.draft.cases ?? []).map((t) => ` - case: ${t.name} (folder ${t.folderId}) \u2014 rows: ${t.rows.join(" \u2192 ")}`)
|
|
1954
|
+
...(c.draft.cases ?? []).map((t) => ` - case: ${t.key ? `${t.key} ` : ""}${t.name} (folder ${t.folderId}) \u2014 rows: ${t.rows.join(" \u2192 ")}`)
|
|
1851
1955
|
] : [],
|
|
1852
1956
|
`
|
|
1853
1957
|
Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
|
|
1854
|
-
|
|
1855
|
-
|
|
1958
|
+
// COUNTS, unless a folder is asked for: hundreds of cases written
|
|
1959
|
+
// and not automated yet were most of this answer, re-read on every
|
|
1960
|
+
// turn after. read_test_cases has any case's steps.
|
|
1961
|
+
...(c.caseFolders ?? []).map((f) => {
|
|
1962
|
+
const inside = (c.cases ?? []).filter((t) => t.folderId === f.id);
|
|
1963
|
+
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`;
|
|
1964
|
+
if (askedFolder !== f.name.toLowerCase()) return head;
|
|
1965
|
+
return `${head}
|
|
1966
|
+
${inside.map((t) => ` - ${t.key ? `${t.key} ` : ""}${t.name} \u2014 ${t.rows.length ? `rows: ${t.rows.join(" \u2192 ")}` : "not automated yet"}`).join("\n")}`;
|
|
1967
|
+
}),
|
|
1856
1968
|
`
|
|
1857
1969
|
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
1858
1970
|
...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"}`),
|
|
1859
1971
|
`
|
|
1860
1972
|
Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
1861
1973
|
...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}` : ""}`)
|
|
1862
|
-
].filter(Boolean).join("\n")
|
|
1974
|
+
].filter(Boolean).join("\n");
|
|
1975
|
+
const key = `${projectId}|${onlyPlatform ?? ""}|${askedFolder ?? ""}`;
|
|
1976
|
+
if (catalogSaid.get(key) === out) return text("Nothing has changed since you last read the catalog in this job.");
|
|
1977
|
+
catalogSaid.set(key, out);
|
|
1978
|
+
return text(out);
|
|
1863
1979
|
}
|
|
1864
1980
|
},
|
|
1865
1981
|
{
|
|
@@ -1951,9 +2067,13 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
|
|
|
1951
2067
|
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
1952
2068
|
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
1953
2069
|
}
|
|
1954
|
-
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1955
|
-
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1956
2070
|
const platforms = platformsOf(catalog);
|
|
2071
|
+
const named = str(args["name"]) ?? "";
|
|
2072
|
+
const wantedPlatform = str(args["platform"]);
|
|
2073
|
+
const saved = args["fresh"] === true ? null : await Promise.resolve().then(() => service.savedFlow(projectId, named, wantedPlatform)).then((r) => r.flow).catch(() => null);
|
|
2074
|
+
const askedAfter = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
2075
|
+
const asked = askedAfter.length ? askedAfter : saved?.after ?? [];
|
|
2076
|
+
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1957
2077
|
const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
|
|
1958
2078
|
const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
|
|
1959
2079
|
if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
|
|
@@ -1964,10 +2084,10 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
|
|
|
1964
2084
|
...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
|
|
1965
2085
|
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1966
2086
|
name: str(args["name"]) ?? "Untitled flow",
|
|
1967
|
-
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1968
|
-
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1969
|
-
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1970
|
-
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
|
|
2087
|
+
...feature ? { featureId: feature.id } : saved ? { featureId: saved.featureId } : { featureName: featureAsked },
|
|
2088
|
+
startPath: str(args["startPath"]) || saved?.startPath || (isMobile(platform) ? "" : "/"),
|
|
2089
|
+
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : saved?.signIn ? { signIn: saved.signIn } : {}
|
|
2090
|
+
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {}, ...saved ? { saved: saved.steps } : {} });
|
|
1971
2091
|
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1972
2092
|
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."}
|
|
1973
2093
|
|
|
@@ -1984,37 +2104,60 @@ ${page}`);
|
|
|
1984
2104
|
},
|
|
1985
2105
|
{
|
|
1986
2106
|
name: "act",
|
|
1987
|
-
title: "Do
|
|
1988
|
-
description: `Perform
|
|
2107
|
+
title: "Do things on the page",
|
|
2108
|
+
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).`,
|
|
1989
2109
|
inputSchema: {
|
|
1990
2110
|
type: "object",
|
|
1991
2111
|
properties: {
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1995
|
-
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
1996
|
-
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." },
|
|
1997
|
-
value: { type: "string" },
|
|
1998
|
-
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." },
|
|
1999
|
-
line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
|
|
2112
|
+
...STEP_PROPERTIES,
|
|
2113
|
+
steps: { type: "array", maxItems: 25, items: { type: "object", properties: STEP_PROPERTIES, required: ["action"], additionalProperties: false }, description: "Several steps in order, instead of one." }
|
|
2000
2114
|
},
|
|
2001
|
-
required: ["action"],
|
|
2002
2115
|
additionalProperties: false
|
|
2003
2116
|
},
|
|
2004
2117
|
run: async (args) => {
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2118
|
+
const stepOf = (raw) => {
|
|
2119
|
+
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");
|
|
2120
|
+
return {
|
|
2121
|
+
action: raw["action"],
|
|
2122
|
+
...str(raw["role"]) ? { role: str(raw["role"]) } : {},
|
|
2123
|
+
...str(raw["name"]) !== void 0 ? { name: str(raw["name"]) } : {},
|
|
2124
|
+
...str(raw["within"]) ? { within: str(raw["within"]) } : {},
|
|
2125
|
+
...Number.isInteger(raw["nth"]) && raw["nth"] >= 0 ? { nth: raw["nth"] } : {},
|
|
2126
|
+
...str(raw["value"]) !== void 0 ? { value: str(raw["value"]) } : {},
|
|
2127
|
+
...typeof raw["secret"] === "boolean" ? { secret: raw["secret"] } : {}
|
|
2128
|
+
};
|
|
2129
|
+
};
|
|
2130
|
+
const batch = Array.isArray(args["steps"]) ? args["steps"].slice(0, 25) : null;
|
|
2131
|
+
if (!batch?.length) return tagged(args, async () => session.act(stepOf(args)));
|
|
2132
|
+
const said = [];
|
|
2133
|
+
let page = "";
|
|
2134
|
+
for (let i = 0; i < batch.length; i++) {
|
|
2135
|
+
const raw = batch[i];
|
|
2136
|
+
const what = `${String(raw["action"])}${str(raw["name"]) ? ` "${str(raw["name"])}"` : str(raw["value"]) && raw["action"] !== "fill" ? ` ${str(raw["value"])}` : ""}`;
|
|
2137
|
+
const before = session.keptCount();
|
|
2138
|
+
try {
|
|
2139
|
+
page = await session.act(stepOf(raw));
|
|
2140
|
+
const line = Number(raw["line"]);
|
|
2141
|
+
said.push(`${i + 1}. ${what} \u2014 kept${Number.isInteger(line) && line > 0 ? session.tagSince(before, line) : ""}`);
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
said.push(`${i + 1}. ${what} \u2014 FAILED: ${error.message}`);
|
|
2144
|
+
if (i < batch.length - 1) said.push(`Steps ${i + 2}\u2013${batch.length} were not done.`);
|
|
2145
|
+
break;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return text(`${said.join("\n")}${page ? `
|
|
2149
|
+
|
|
2150
|
+
The page after the last kept step:
|
|
2151
|
+
${page}` : ""}`);
|
|
2016
2152
|
}
|
|
2017
2153
|
},
|
|
2154
|
+
{
|
|
2155
|
+
name: "continue_flow",
|
|
2156
|
+
title: "Put back the waiting steps",
|
|
2157
|
+
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.",
|
|
2158
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
2159
|
+
run: async () => text(await session.continueFlow())
|
|
2160
|
+
},
|
|
2018
2161
|
{
|
|
2019
2162
|
name: "add_check",
|
|
2020
2163
|
title: "Add a check",
|
|
@@ -2278,7 +2421,7 @@ The change document "${doc.name}" \u2014 part ${part} of ${parts}${part < parts
|
|
|
2278
2421
|
${doc.text.slice((part - 1) * PART, part * PART)}
|
|
2279
2422
|
\u2014 end of part ${part} \u2014`] : [],
|
|
2280
2423
|
...r.cases.map((c) => [`
|
|
2281
|
-
${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no steps written)"]].join("\n"))
|
|
2424
|
+
${c.key ? `${c.key} ` : ""}${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no steps written)"]].join("\n"))
|
|
2282
2425
|
].join("\n"));
|
|
2283
2426
|
}
|
|
2284
2427
|
},
|
|
@@ -2316,10 +2459,10 @@ ${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.business
|
|
|
2316
2459
|
{
|
|
2317
2460
|
name: "read_test_cases",
|
|
2318
2461
|
title: "Read test cases",
|
|
2319
|
-
description: "Read test cases by id
|
|
2462
|
+
description: "Read test cases by id \u2014 TC-12 as people say it, or tc_\u2026 \u2014 or exact name: their feature, business steps (Given / When / Then), whether they are automated, and the flows they run. Read a case's steps before automating it.",
|
|
2320
2463
|
inputSchema: {
|
|
2321
2464
|
type: "object",
|
|
2322
|
-
properties: { projectId: { type: "string" }, cases: { type: "array", items: { type: "string" }, description: "Ids or names." } },
|
|
2465
|
+
properties: { projectId: { type: "string" }, cases: { type: "array", items: { type: "string" }, description: "Ids (TC-12) or names." } },
|
|
2323
2466
|
required: ["cases"],
|
|
2324
2467
|
additionalProperties: false
|
|
2325
2468
|
},
|
|
@@ -2329,9 +2472,11 @@ ${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.business
|
|
|
2329
2472
|
const refs = Array.isArray(args["cases"]) ? args["cases"].map(String) : [];
|
|
2330
2473
|
const { cases, missing } = await service.readCases(projectId, refs);
|
|
2331
2474
|
const parts = cases.map((c) => [
|
|
2332
|
-
`${c.folder} \u203A ${c.name} (${c.id}) \u2014 ${c.automated ? `automated, rows: ${c.rows.join(" \u2192 ")}` : "not automated yet"}`,
|
|
2475
|
+
`${c.key ? `${c.key} ` : ""}${c.folder} \u203A ${c.name} (${c.id}) \u2014 ${c.automated ? `automated, rows: ${c.rows.join(" \u2192 ")}` : "not automated yet"}`,
|
|
2476
|
+
...c.manual ? [" MANUAL \u2014 a person runs this one by hand. Do not automate it."] : [],
|
|
2477
|
+
...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.`] : [],
|
|
2333
2478
|
...c.description ? [` ${c.description}`] : [],
|
|
2334
|
-
...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2479
|
+
...c.businessSteps.length ? c.businessSteps.map((s, i) => ` ${i + 1}. ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2335
2480
|
].join("\n"));
|
|
2336
2481
|
if (missing.length) parts.push(`Not found: ${missing.join(", ")} \u2014 project_catalog lists the test cases.`);
|
|
2337
2482
|
return text(parts.join("\n\n") || "No test cases found.");
|
|
@@ -2390,7 +2535,7 @@ ${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.business
|
|
|
2390
2535
|
const done = await service.writeCases(projectId, groups, str(args["branch"]));
|
|
2391
2536
|
catalogs.delete(projectId);
|
|
2392
2537
|
const skipped = done.skipped.length ? ` Skipped ${done.skipped.length} the folder already had: ${done.skipped.map((s) => `"${s.name}"`).join(", ")}.` : "";
|
|
2393
|
-
return text(`Wrote ${done.created.length} test case${done.created.length === 1 ? "" : "s"}, not automated yet, on Claude's working branch ${done.branch}.${skipped}`);
|
|
2538
|
+
return text(`Wrote ${done.created.length} test case${done.created.length === 1 ? "" : "s"}, not automated yet, on Claude's working branch ${done.branch}${done.created.some((c) => c.key) ? `: ${done.created.map((c) => `${c.key ?? ""} ${c.name}`.trim()).join(", ")}` : ""}.${skipped}`);
|
|
2394
2539
|
}
|
|
2395
2540
|
},
|
|
2396
2541
|
{
|
|
@@ -2457,6 +2602,39 @@ Next: create_account for each person who signs in this way.`);
|
|
|
2457
2602
|
Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
|
|
2458
2603
|
}
|
|
2459
2604
|
},
|
|
2605
|
+
{
|
|
2606
|
+
name: "report_blocker",
|
|
2607
|
+
title: "Say a test case cannot be automated",
|
|
2608
|
+
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.",
|
|
2609
|
+
inputSchema: {
|
|
2610
|
+
type: "object",
|
|
2611
|
+
properties: {
|
|
2612
|
+
projectId: { type: "string" },
|
|
2613
|
+
case: { type: "string", description: "The case's id, TC-\u2026 (read_test_cases gives it)." },
|
|
2614
|
+
kind: { type: "string", enum: ["app-bug", "needs-person", "unclear-step", "not-found", "stuck"] },
|
|
2615
|
+
line: { type: "number", description: "The business step it stopped at, counted from 1." },
|
|
2616
|
+
detail: { type: "string", description: "What happened, in a sentence or two: what you did and what the app did." },
|
|
2617
|
+
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".' },
|
|
2618
|
+
platform: { type: "string", enum: ["web", "android", "ios"] },
|
|
2619
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches the case is on." }
|
|
2620
|
+
},
|
|
2621
|
+
required: ["case", "kind", "detail"],
|
|
2622
|
+
additionalProperties: false
|
|
2623
|
+
},
|
|
2624
|
+
run: async (args) => {
|
|
2625
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2626
|
+
const made = await service.reportBlocker(projectId, {
|
|
2627
|
+
case: str(args["case"]) ?? "",
|
|
2628
|
+
kind: str(args["kind"]) ?? "",
|
|
2629
|
+
detail: str(args["detail"]) ?? "",
|
|
2630
|
+
...typeof args["line"] === "number" ? { line: args["line"] } : {},
|
|
2631
|
+
...str(args["needs"]) ? { needs: str(args["needs"]) } : {},
|
|
2632
|
+
...str(args["platform"]) ? { platform: str(args["platform"]) } : {},
|
|
2633
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2634
|
+
});
|
|
2635
|
+
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.`);
|
|
2636
|
+
}
|
|
2637
|
+
},
|
|
2460
2638
|
{
|
|
2461
2639
|
name: "create_helper",
|
|
2462
2640
|
title: "Make a helper",
|
|
@@ -2490,7 +2668,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
|
|
|
2490
2668
|
{
|
|
2491
2669
|
name: "project_brief",
|
|
2492
2670
|
title: "Read the project's memory",
|
|
2493
|
-
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
|
|
2671
|
+
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.",
|
|
2494
2672
|
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 },
|
|
2495
2673
|
annotations: { readOnlyHint: true },
|
|
2496
2674
|
run: async (args) => {
|
|
@@ -2833,6 +3011,10 @@ async function runMcp(opts) {
|
|
|
2833
3011
|
const connection = connectionFor(service);
|
|
2834
3012
|
const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
|
|
2835
3013
|
let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
|
|
3014
|
+
const only = (process.env["FLOWRIKI_TOOLS"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
3015
|
+
if (only.length) tools = tools.filter((tool) => only.includes(tool.name));
|
|
3016
|
+
const except = (process.env["FLOWRIKI_TOOLS_EXCEPT"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
3017
|
+
if (except.length) tools = tools.filter((tool) => !except.includes(tool.name));
|
|
2836
3018
|
if (!connection) {
|
|
2837
3019
|
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.`;
|
|
2838
3020
|
tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
|
|
@@ -2953,6 +3135,8 @@ Run \`flowriki-agent --help\`.`);
|
|
|
2953
3135
|
console.log(`
|
|
2954
3136
|
${bold("Approve this computer in your browser")}`);
|
|
2955
3137
|
console.log(` ${url}
|
|
3138
|
+
`);
|
|
3139
|
+
if (note.detail?.["code"]) console.log(` ${bold("Your code:")} ${bold(String(note.detail["code"]))} ${dim("\u2014 the page asks for it")}
|
|
2956
3140
|
`);
|
|
2957
3141
|
if (note.detail?.["canOpen"] === false) {
|
|
2958
3142
|
console.log(dim(" Open the approval link in your browser.\n"));
|
|
@@ -3019,11 +3203,16 @@ Run \`flowriki-agent --help\`.`);
|
|
|
3019
3203
|
setInterval(() => void tellIfBehind(), 6 * 60 * 6e4).unref();
|
|
3020
3204
|
let stopping = false;
|
|
3021
3205
|
const shutdown = async (signal) => {
|
|
3022
|
-
if (stopping)
|
|
3206
|
+
if (stopping) {
|
|
3207
|
+
console.log(dim(" stopping now"));
|
|
3208
|
+
process.exit(130);
|
|
3209
|
+
}
|
|
3023
3210
|
stopping = true;
|
|
3024
3211
|
console.log(dim(`
|
|
3025
|
-
${signal} \u2014 stopping`));
|
|
3026
|
-
|
|
3212
|
+
${signal} \u2014 stopping (press Ctrl-C again to stop at once)`));
|
|
3213
|
+
const force = setTimeout(() => process.exit(1), 1e4);
|
|
3214
|
+
force.unref();
|
|
3215
|
+
await started.stop().catch(() => void 0);
|
|
3027
3216
|
process.exit(0);
|
|
3028
3217
|
};
|
|
3029
3218
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|