@flowriki/agent 0.1.0-beta.6 → 0.1.0-beta.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -7
- package/dist/accname.global.js +1 -1
- package/dist/{chunk-ZUHML4TR.js → chunk-BYFYSV5H.js} +7358 -3726
- package/dist/chunk-WQ22OUIQ.js +4259 -0
- package/dist/cli.js +2811 -3
- package/dist/dist-IBNVN2BD.js +97 -0
- package/dist/engine.js +2 -1
- package/package.json +4 -2
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,2755 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
AGENT_LATEST_URL,
|
|
3
4
|
AGENT_VERSION,
|
|
5
|
+
agentUpdateFor,
|
|
6
|
+
authenticationResolver,
|
|
7
|
+
checkAssertion,
|
|
8
|
+
closeRunSession,
|
|
4
9
|
connect,
|
|
10
|
+
connectionFor,
|
|
11
|
+
dataDir,
|
|
5
12
|
disconnect,
|
|
6
13
|
doctor,
|
|
14
|
+
executeStep,
|
|
15
|
+
fieldNames,
|
|
16
|
+
flowrikiHome,
|
|
17
|
+
helperRunner,
|
|
18
|
+
openRunSession,
|
|
7
19
|
prepareBrowsers,
|
|
20
|
+
replayCase,
|
|
21
|
+
replayFlow,
|
|
8
22
|
resolveCheckouts,
|
|
23
|
+
resolveService,
|
|
24
|
+
runtimeValue,
|
|
9
25
|
start,
|
|
10
26
|
status
|
|
11
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-BYFYSV5H.js";
|
|
28
|
+
import {
|
|
29
|
+
CLOUD_PROVIDERS,
|
|
30
|
+
CLOUD_PROVIDER_NAME,
|
|
31
|
+
CODEBASE_TAG_NAME,
|
|
32
|
+
MobileLane,
|
|
33
|
+
RecordedStepSchema,
|
|
34
|
+
checkMobileAssertion,
|
|
35
|
+
codebaseIndexOf,
|
|
36
|
+
exploreSummary,
|
|
37
|
+
isMobile,
|
|
38
|
+
listDevices,
|
|
39
|
+
locate,
|
|
40
|
+
mirrorCheckTargets,
|
|
41
|
+
mobileDoctor,
|
|
42
|
+
mobileOf,
|
|
43
|
+
mobileSetup,
|
|
44
|
+
nameKey,
|
|
45
|
+
openMobileSession,
|
|
46
|
+
parseGherkin,
|
|
47
|
+
performMobileStep,
|
|
48
|
+
platformsOf,
|
|
49
|
+
replayMobileCase,
|
|
50
|
+
runMobileSteps,
|
|
51
|
+
screenLines,
|
|
52
|
+
slugify,
|
|
53
|
+
stepRefusedOn,
|
|
54
|
+
targetFor
|
|
55
|
+
} from "./chunk-WQ22OUIQ.js";
|
|
12
56
|
|
|
13
57
|
// src/cli.ts
|
|
14
58
|
import { parseArgs } from "util";
|
|
59
|
+
|
|
60
|
+
// src/mcp/protocol.ts
|
|
61
|
+
import readline from "readline";
|
|
62
|
+
var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
63
|
+
async function handle(message, tools, info) {
|
|
64
|
+
const reply = (result) => ({ jsonrpc: "2.0", id: message.id ?? null, result });
|
|
65
|
+
const fail = (code, text2) => ({ jsonrpc: "2.0", id: message.id ?? null, error: { code, message: text2 } });
|
|
66
|
+
if (message.id === void 0 || message.id === null) return null;
|
|
67
|
+
switch (message.method) {
|
|
68
|
+
case "initialize": {
|
|
69
|
+
const asked = String(message.params?.["protocolVersion"] ?? "");
|
|
70
|
+
const version = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
|
|
71
|
+
return reply({
|
|
72
|
+
protocolVersion: version,
|
|
73
|
+
capabilities: { tools: { listChanged: true } },
|
|
74
|
+
serverInfo: { name: info.name, version: info.version },
|
|
75
|
+
instructions: info.instructions
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
case "ping":
|
|
79
|
+
return reply({});
|
|
80
|
+
case "tools/list":
|
|
81
|
+
return reply({
|
|
82
|
+
tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
|
83
|
+
name,
|
|
84
|
+
...title ? { title } : {},
|
|
85
|
+
description,
|
|
86
|
+
inputSchema,
|
|
87
|
+
...annotations ? { annotations } : {}
|
|
88
|
+
}))
|
|
89
|
+
});
|
|
90
|
+
case "tools/call": {
|
|
91
|
+
const name = String(message.params?.["name"] ?? "");
|
|
92
|
+
const tool = tools.find((t) => t.name === name);
|
|
93
|
+
if (!tool) return fail(-32602, `no tool named ${name}`);
|
|
94
|
+
const args = message.params?.["arguments"] ?? {};
|
|
95
|
+
try {
|
|
96
|
+
return reply(await tool.run(args));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return reply({ content: [{ type: "text", text: error.message }], isError: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return fail(-32601, `method not found: ${message.method}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function serve(tools, info, io = {}) {
|
|
106
|
+
const input = io.input ?? process.stdin;
|
|
107
|
+
const output = io.output ?? process.stdout;
|
|
108
|
+
if (!io.output) {
|
|
109
|
+
console.log = console.info = console.warn = console.debug = (...args) => {
|
|
110
|
+
process.stderr.write(`${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
111
|
+
`);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
115
|
+
const pending = /* @__PURE__ */ new Set();
|
|
116
|
+
lines.on("line", (line) => {
|
|
117
|
+
if (!line.trim()) return;
|
|
118
|
+
let message;
|
|
119
|
+
try {
|
|
120
|
+
message = JSON.parse(line);
|
|
121
|
+
} catch {
|
|
122
|
+
output.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })}
|
|
123
|
+
`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const task = handle(message, tools, info).then((answer) => {
|
|
127
|
+
if (answer) output.write(`${JSON.stringify(answer)}
|
|
128
|
+
`);
|
|
129
|
+
if (message.method === "notifications/initialized") {
|
|
130
|
+
output.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}
|
|
131
|
+
`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
pending.add(task);
|
|
135
|
+
void task.finally(() => pending.delete(task));
|
|
136
|
+
});
|
|
137
|
+
return new Promise((resolve) => lines.on("close", () => void Promise.allSettled([...pending]).then(() => resolve())));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/mcp/service-client.ts
|
|
141
|
+
var ASK_ID = (process.env["FLOWRIKI_ASK_ID"] ?? "").trim().slice(0, 200);
|
|
142
|
+
var ServiceClient = class {
|
|
143
|
+
constructor(connection, fetcher = fetch) {
|
|
144
|
+
this.connection = connection;
|
|
145
|
+
this.fetcher = fetcher;
|
|
146
|
+
}
|
|
147
|
+
connection;
|
|
148
|
+
fetcher;
|
|
149
|
+
get origin() {
|
|
150
|
+
return this.connection.service;
|
|
151
|
+
}
|
|
152
|
+
/** One request to the service, retried when the service — or the proxy in
|
|
153
|
+
* front of it — says it is too busy. A 429 or a 503 from the edge means
|
|
154
|
+
* the request was never handled, so trying again is safe for any method;
|
|
155
|
+
* a 502/504 is retried only when nothing could have happened (a GET).
|
|
156
|
+
* Backing off here, inside the tool, is what keeps the model from asking
|
|
157
|
+
* again every two seconds and making the limit worse. */
|
|
158
|
+
async call(path3, init = {}, attempt = 0) {
|
|
159
|
+
const res = await this.fetcher(`${this.connection.service}${path3}`, {
|
|
160
|
+
...init,
|
|
161
|
+
headers: {
|
|
162
|
+
"x-flowriki-device": this.connection.deviceId,
|
|
163
|
+
"x-flowriki-device-secret": this.connection.secret,
|
|
164
|
+
"content-type": "application/json",
|
|
165
|
+
// The dashboard's question these tools are answering, when they are:
|
|
166
|
+
// what they build is filed on it, for the person to watch.
|
|
167
|
+
...ASK_ID ? { "x-flowriki-ask": ASK_ID } : {}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const text2 = await res.text();
|
|
171
|
+
let body = {};
|
|
172
|
+
try {
|
|
173
|
+
body = text2 ? JSON.parse(text2) : {};
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const method = String(init.method ?? "GET").toUpperCase();
|
|
178
|
+
const busy = res.status === 429 || res.status === 503 || method === "GET" && (res.status === 502 || res.status === 504);
|
|
179
|
+
if (busy && attempt < 4) {
|
|
180
|
+
const asked = Number(res.headers.get("retry-after"));
|
|
181
|
+
const wait = Math.min(15e3, res.headers.get("retry-after") !== null && Number.isFinite(asked) && asked >= 0 ? asked * 1e3 : 1e3 * 2 ** attempt);
|
|
182
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
183
|
+
return this.call(path3, init, attempt + 1);
|
|
184
|
+
}
|
|
185
|
+
if (res.status === 404 && !("error" in body)) {
|
|
186
|
+
throw new Error("the Flowriki service does not offer this yet \u2014 it may need updating");
|
|
187
|
+
}
|
|
188
|
+
if (busy) throw new Error(`the Flowriki service is too busy right now (${res.status}) and stayed so after several tries \u2014 wait a minute before asking again, and do not retry in a loop`);
|
|
189
|
+
throw new Error(String(body["error"] ?? `the service answered ${res.status}`));
|
|
190
|
+
}
|
|
191
|
+
return body;
|
|
192
|
+
}
|
|
193
|
+
projects() {
|
|
194
|
+
return this.call("/api/agent/projects");
|
|
195
|
+
}
|
|
196
|
+
createProject(name, repository, baseUrl) {
|
|
197
|
+
return this.call("/api/agent/projects", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
body: JSON.stringify({ name, repository, ...baseUrl ? { baseUrl } : {} })
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catalog(projectId) {
|
|
203
|
+
return this.call(`/api/agent/catalog?projectId=${encodeURIComponent(projectId)}`);
|
|
204
|
+
}
|
|
205
|
+
/** `cloud` asks for that device farm's access key too, for a flow written
|
|
206
|
+
* on one of its phones. */
|
|
207
|
+
prepare(projectId, steps, signIn, extra = {}) {
|
|
208
|
+
return this.call(`/api/agent/prepare?projectId=${encodeURIComponent(projectId)}`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
body: JSON.stringify({ steps, ...signIn ? { signIn } : {}, ...extra.cloud ? { cloud: extra.cloud } : {} })
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** This person's open branches that Claude wrote on this project. */
|
|
214
|
+
agentChanges(projectId) {
|
|
215
|
+
return this.call(
|
|
216
|
+
`/api/agent/changes?projectId=${encodeURIComponent(projectId)}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
/** Delete one — refused by the service unless it is all Claude's own,
|
|
220
|
+
* unedited, unpublished work. */
|
|
221
|
+
discardChange(projectId, changeId) {
|
|
222
|
+
return this.call(
|
|
223
|
+
`/api/agent/changes/${encodeURIComponent(changeId)}?projectId=${encodeURIComponent(projectId)}`,
|
|
224
|
+
{ method: "DELETE" }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
saveFlow(projectId, title, flow, where = {}, secretValues = {}) {
|
|
228
|
+
return this.call(
|
|
229
|
+
`/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
|
|
230
|
+
{ method: "POST", body: JSON.stringify({ title, flow, ...where, secretValues }) }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
/** A sign-in made from a recorded login flow. */
|
|
234
|
+
createSignIn(projectId, input) {
|
|
235
|
+
return this.call(
|
|
236
|
+
`/api/agent/sign-ins?projectId=${encodeURIComponent(projectId)}`,
|
|
237
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
/** An account for a sign-in; secret values are sealed by the service and
|
|
241
|
+
* never come back. */
|
|
242
|
+
createAccount(projectId, input) {
|
|
243
|
+
return this.call(
|
|
244
|
+
`/api/agent/accounts?projectId=${encodeURIComponent(projectId)}`,
|
|
245
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
/** A helper, its secrets sealed by the service. */
|
|
249
|
+
createHelper(projectId, input) {
|
|
250
|
+
return this.call(
|
|
251
|
+
`/api/agent/helpers?projectId=${encodeURIComponent(projectId)}`,
|
|
252
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
/** What a new flow starts after: the flows, in run order, and their steps. */
|
|
256
|
+
flowsAfter(projectId, after) {
|
|
257
|
+
return this.call(
|
|
258
|
+
`/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
|
|
259
|
+
{ method: "POST", body: JSON.stringify({ after }) }
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
/** Build a test case from flows — as a dry run, or saved. */
|
|
263
|
+
/** The flow being recorded, as it stands, for the dashboard's job. Only
|
|
264
|
+
* when these tools answer a question asked in the dashboard. */
|
|
265
|
+
async reportDraft(projectId, draft) {
|
|
266
|
+
if (!ASK_ID) return;
|
|
267
|
+
await this.call(`/api/agent/jobs/draft?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ draft }) });
|
|
268
|
+
}
|
|
269
|
+
/** The test case being built, as it stands, for the dashboard's job. */
|
|
270
|
+
async reportCase(projectId, live) {
|
|
271
|
+
if (!ASK_ID) return;
|
|
272
|
+
await this.call(`/api/agent/jobs/case?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ case: live }) });
|
|
273
|
+
}
|
|
274
|
+
buildCase(projectId, spec, dryRun) {
|
|
275
|
+
return this.call(`/api/agent/cases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ ...spec, dryRun }) });
|
|
276
|
+
}
|
|
277
|
+
/** The test library, on Claude's working branch when it has one. */
|
|
278
|
+
library(projectId, opts = {}) {
|
|
279
|
+
const query = new URLSearchParams({ projectId, ...opts.q ? { q: opts.q } : {}, ...opts.featureId ? { featureId: opts.featureId } : {} });
|
|
280
|
+
return this.call(`/api/agent/library?${query}`);
|
|
281
|
+
}
|
|
282
|
+
/** Test cases written, edited or linked in one commit; duplicates skipped. */
|
|
283
|
+
importPart(projectId, draftId, part) {
|
|
284
|
+
return this.call(
|
|
285
|
+
`/api/agent/import-drafts/${encodeURIComponent(draftId)}?projectId=${encodeURIComponent(projectId)}&part=${part}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
saveImportCases(projectId, draftId, body) {
|
|
289
|
+
return this.call(
|
|
290
|
+
`/api/agent/import-drafts/${encodeURIComponent(draftId)}/cases?projectId=${encodeURIComponent(projectId)}`,
|
|
291
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
updateRequest(projectId, id) {
|
|
295
|
+
return this.call(
|
|
296
|
+
`/api/agent/update-requests/${encodeURIComponent(id)}?projectId=${encodeURIComponent(projectId)}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
proposeUpdates(projectId, id, body) {
|
|
300
|
+
return this.call(
|
|
301
|
+
`/api/agent/update-requests/${encodeURIComponent(id)}/proposals?projectId=${encodeURIComponent(projectId)}`,
|
|
302
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
readCases(projectId, refs) {
|
|
306
|
+
const ids = refs.filter((r) => /^tc_/.test(r.trim()));
|
|
307
|
+
const names = refs.filter((r) => !/^tc_/.test(r.trim()));
|
|
308
|
+
const q = new URLSearchParams({ projectId, ...ids.length ? { ids: ids.join(",") } : {}, ...names.length ? { names: names.join("|") } : {} });
|
|
309
|
+
return this.call(`/api/agent/cases/read?${q}`);
|
|
310
|
+
}
|
|
311
|
+
/** Test cases written down — business steps, no flows yet — on Claude's
|
|
312
|
+
* working branch. */
|
|
313
|
+
writeCases(projectId, groups, changeId) {
|
|
314
|
+
return this.call(
|
|
315
|
+
`/api/agent/cases/batch?projectId=${encodeURIComponent(projectId)}`,
|
|
316
|
+
{ method: "POST", body: JSON.stringify({ groups, ...changeId ? { changeId } : {} }) }
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
saveLibrary(projectId, input) {
|
|
320
|
+
return this.call(`/api/agent/library?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(input) });
|
|
321
|
+
}
|
|
322
|
+
// ── Manual cycles and bugs: the dashboard's own routes, behind this
|
|
323
|
+
// machine's credential, so the assistant can do what a person can. ──────
|
|
324
|
+
scoped(path3, projectId, extra = {}) {
|
|
325
|
+
const query = new URLSearchParams({ projectId, ...extra });
|
|
326
|
+
return `/api/agent${path3}?${query}`;
|
|
327
|
+
}
|
|
328
|
+
cycles(projectId) {
|
|
329
|
+
return this.call(this.scoped("/cycles", projectId));
|
|
330
|
+
}
|
|
331
|
+
cycle(projectId, id) {
|
|
332
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId));
|
|
333
|
+
}
|
|
334
|
+
createCycle(projectId, body, changeId) {
|
|
335
|
+
return this.call(this.scoped("/cycles", projectId, changeId ? { changeId } : {}), { method: "POST", body: JSON.stringify(body) });
|
|
336
|
+
}
|
|
337
|
+
updateCycle(projectId, id, body, changeId) {
|
|
338
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId, changeId ? { changeId } : {}), { method: "PATCH", body: JSON.stringify(body) });
|
|
339
|
+
}
|
|
340
|
+
deleteCycle(projectId, id) {
|
|
341
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
|
|
342
|
+
}
|
|
343
|
+
recordResult(projectId, id, caseId, body) {
|
|
344
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}/results/${encodeURIComponent(caseId)}`, projectId), { method: "PUT", body: JSON.stringify(body) });
|
|
345
|
+
}
|
|
346
|
+
addEvidenceLink(projectId, id, caseId, body) {
|
|
347
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}/results/${encodeURIComponent(caseId)}/evidence`, projectId), {
|
|
348
|
+
method: "POST",
|
|
349
|
+
body: JSON.stringify({ kind: "link", ...body })
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
bugs(projectId) {
|
|
353
|
+
return this.call(this.scoped("/bugs", projectId));
|
|
354
|
+
}
|
|
355
|
+
createBug(projectId, body) {
|
|
356
|
+
return this.call(this.scoped("/bugs", projectId), { method: "POST", body: JSON.stringify(body) });
|
|
357
|
+
}
|
|
358
|
+
updateBug(projectId, id, body) {
|
|
359
|
+
return this.call(this.scoped(`/bugs/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(body) });
|
|
360
|
+
}
|
|
361
|
+
// ── Knowledge, sources, requirements ──────────────────────
|
|
362
|
+
/** The project's brief for a job: its slice of the knowledge. */
|
|
363
|
+
brief(projectId, scope) {
|
|
364
|
+
return this.call(this.scoped("/brief", projectId, { ...scope.platform ? { platform: scope.platform } : {}, ...scope.features?.length ? { features: scope.features.join(",") } : {} }));
|
|
365
|
+
}
|
|
366
|
+
knowledgePages(projectId) {
|
|
367
|
+
return this.call(this.scoped("/knowledge", projectId));
|
|
368
|
+
}
|
|
369
|
+
knowledgePage(projectId, path3) {
|
|
370
|
+
return this.call(this.scoped("/knowledge/page", projectId, { path: path3 }));
|
|
371
|
+
}
|
|
372
|
+
writeKnowledge(projectId, body) {
|
|
373
|
+
return this.call(`/api/agent/knowledge?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(body) });
|
|
374
|
+
}
|
|
375
|
+
search(projectId, q) {
|
|
376
|
+
return this.call(this.scoped("/search", projectId, { q }));
|
|
377
|
+
}
|
|
378
|
+
sources(projectId) {
|
|
379
|
+
return this.call(this.scoped("/sources", projectId));
|
|
380
|
+
}
|
|
381
|
+
source(projectId, id) {
|
|
382
|
+
return this.call(this.scoped(`/sources/${encodeURIComponent(id)}`, projectId));
|
|
383
|
+
}
|
|
384
|
+
addSource(projectId, body, sourceId) {
|
|
385
|
+
return this.call(
|
|
386
|
+
this.scoped(sourceId ? `/sources/${encodeURIComponent(sourceId)}/versions` : "/sources", projectId),
|
|
387
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
requirements(projectId, opts = {}) {
|
|
391
|
+
return this.call(
|
|
392
|
+
this.scoped("/requirements", projectId, { ...opts.sourceId ? { sourceId: opts.sourceId } : {}, ...opts.state ? { state: opts.state } : {} })
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
deleteSource(projectId, id) {
|
|
396
|
+
return this.call(this.scoped(`/sources/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
|
|
397
|
+
}
|
|
398
|
+
updateRequirement(projectId, id, body) {
|
|
399
|
+
return this.call(this.scoped(`/requirements/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(body) });
|
|
400
|
+
}
|
|
401
|
+
/** Gather Claude's open branches here into one. */
|
|
402
|
+
gatherChanges(projectId, opts = {}) {
|
|
403
|
+
return this.call(
|
|
404
|
+
`/api/agent/changes/gather?projectId=${encodeURIComponent(projectId)}`,
|
|
405
|
+
{ method: "POST", body: JSON.stringify(opts) }
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/mcp/session.ts
|
|
411
|
+
import fs from "fs";
|
|
412
|
+
import path from "path";
|
|
413
|
+
var MAX_TREE = 14e3;
|
|
414
|
+
var MAX_STEP_VIEW = 6e3;
|
|
415
|
+
function topLevelBlocks(tree) {
|
|
416
|
+
const blocks = [];
|
|
417
|
+
for (const line of tree.split("\n")) {
|
|
418
|
+
if (line.startsWith("- ") || blocks.length === 0) blocks.push([line]);
|
|
419
|
+
else blocks[blocks.length - 1].push(line);
|
|
420
|
+
}
|
|
421
|
+
const named = (b, kinds) => kinds.some((k) => b[0].startsWith(`- ${k}`));
|
|
422
|
+
return {
|
|
423
|
+
dialogs: blocks.filter((b) => named(b, ["dialog", "alertdialog"])).map((b) => b.join("\n")),
|
|
424
|
+
navigation: blocks.find((b) => named(b, ["navigation", "banner"]))?.join("\n") ?? null
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
var STEP_TIMEOUT_MS = 1e4;
|
|
428
|
+
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;
|
|
429
|
+
var TYPED = "typed_";
|
|
430
|
+
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
431
|
+
var ACTIONS = [
|
|
432
|
+
"click",
|
|
433
|
+
"dblclick",
|
|
434
|
+
"rightclick",
|
|
435
|
+
"hover",
|
|
436
|
+
"fill",
|
|
437
|
+
"select",
|
|
438
|
+
"check",
|
|
439
|
+
"press",
|
|
440
|
+
"navigate",
|
|
441
|
+
"back",
|
|
442
|
+
"forward",
|
|
443
|
+
// On a phone: a tap is a click, and these have no web equivalent.
|
|
444
|
+
"tap",
|
|
445
|
+
"longPress",
|
|
446
|
+
"swipe",
|
|
447
|
+
"scroll",
|
|
448
|
+
"hideKeyboard",
|
|
449
|
+
"launchApp",
|
|
450
|
+
"terminateApp",
|
|
451
|
+
"resetApp",
|
|
452
|
+
"background",
|
|
453
|
+
"rotate",
|
|
454
|
+
"permission"
|
|
455
|
+
];
|
|
456
|
+
var isAction = (value) => ACTIONS.includes(String(value));
|
|
457
|
+
var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
|
|
458
|
+
function scopeOf(within) {
|
|
459
|
+
const said = within?.trim() ?? "";
|
|
460
|
+
if (!said) return { scope: "" };
|
|
461
|
+
return LANDMARKS.has(said.toLowerCase()) ? { scope: "", scopeRole: said.toLowerCase() } : { scope: said };
|
|
462
|
+
}
|
|
463
|
+
function asLabelled(target, fields) {
|
|
464
|
+
const field = fields.find((f) => f.role === target.role && (f.label === target.name || f.hint === target.name));
|
|
465
|
+
if (!field) return target;
|
|
466
|
+
return {
|
|
467
|
+
...target,
|
|
468
|
+
name: field.label,
|
|
469
|
+
nameFrom: "adjacent-label",
|
|
470
|
+
...field.hintFrom === "name" ? {} : { candidates: [{ strategy: field.hintFrom, value: field.hint }] }
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function labelTree(tree, fields) {
|
|
474
|
+
let out = tree;
|
|
475
|
+
for (const f of fields) out = out.split(`- ${f.role} ${JSON.stringify(f.hint)}`).join(`- ${f.role} ${JSON.stringify(f.label)}`);
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
function numberDuplicates(tree) {
|
|
479
|
+
const line = /^(\s*- )([\w-]+) "((?:[^"\\]|\\.)*)"(.*)$/;
|
|
480
|
+
const totals = /* @__PURE__ */ new Map();
|
|
481
|
+
const lines = tree.split("\n");
|
|
482
|
+
for (const l of lines) {
|
|
483
|
+
const m = line.exec(l);
|
|
484
|
+
if (m) totals.set(`${m[2]}|${m[3]}`, (totals.get(`${m[2]}|${m[3]}`) ?? 0) + 1);
|
|
485
|
+
}
|
|
486
|
+
const seen = /* @__PURE__ */ new Map();
|
|
487
|
+
return lines.map((l) => {
|
|
488
|
+
const m = line.exec(l);
|
|
489
|
+
if (!m) return l;
|
|
490
|
+
const key = `${m[2]}|${m[3]}`;
|
|
491
|
+
const total = totals.get(key) ?? 1;
|
|
492
|
+
if (total < 2) return l;
|
|
493
|
+
const at = seen.get(key) ?? 0;
|
|
494
|
+
seen.set(key, at + 1);
|
|
495
|
+
return `${l} [nth ${at} of ${total} with this name]`;
|
|
496
|
+
}).join("\n");
|
|
497
|
+
}
|
|
498
|
+
function deviceFrom(said, platform, devices, mobile) {
|
|
499
|
+
const want = said?.trim();
|
|
500
|
+
if (!want) return mobile?.devices[platform];
|
|
501
|
+
const lower = want.toLowerCase();
|
|
502
|
+
const mine = devices.filter((d) => d.platform === platform);
|
|
503
|
+
const local = mine.find((d) => d.id.toLowerCase() === lower || d.name.toLowerCase() === lower) ?? mine.find((d) => d.name.toLowerCase().includes(lower));
|
|
504
|
+
if (local) return { kind: "local", id: local.id, name: local.name };
|
|
505
|
+
const farm = /^([^:]+):\s*(.+?)(?:\s+(\d+(?:\.\d+)*))?$/.exec(want);
|
|
506
|
+
const provider = farm && CLOUD_PROVIDERS.find((p) => p === farm[1].trim().toLowerCase().replace(/\s+/g, "") || CLOUD_PROVIDER_NAME[p].toLowerCase() === farm[1].trim().toLowerCase());
|
|
507
|
+
if (farm && provider) {
|
|
508
|
+
if (!mobile?.cloud.some((c) => c.provider === provider)) throw new Error(`${CLOUD_PROVIDER_NAME[provider]} is not connected to this project \u2014 add it in Settings \u2192 Mobile`);
|
|
509
|
+
return { kind: "cloud", provider, device: farm[2].trim(), ...farm[3] ? { osVersion: farm[3] } : {} };
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`no ${platform === "ios" ? "iOS" : "Android"} device called "${want}" \u2014 connected: ${mine.map((d) => d.name).join(", ") || "none"}`);
|
|
512
|
+
}
|
|
513
|
+
async function devicesSummary() {
|
|
514
|
+
return listDevices().catch(() => []);
|
|
515
|
+
}
|
|
516
|
+
function describeRows(rows, testCase) {
|
|
517
|
+
return rows.map((r, i) => {
|
|
518
|
+
const steps = testCase?.occurrences[i]?.steps;
|
|
519
|
+
if (r.steps?.length && steps?.length === r.steps.length) {
|
|
520
|
+
const on = new Map(r.checks.map((c) => [c.n, c.on]));
|
|
521
|
+
return [
|
|
522
|
+
`Row ${r.row}: "${r.flow}" (${r.browser})`,
|
|
523
|
+
...r.steps.map((st, j) => ` step ${st.n}. ${describe(steps[j])}${st.check !== void 0 ? ` \u2014 check ${st.check} ${on.get(st.check) ? "[on]" : "[off]"}` : ""}`)
|
|
524
|
+
].join("\n");
|
|
525
|
+
}
|
|
526
|
+
return [
|
|
527
|
+
`Row ${r.row}: "${r.flow}" (${r.browser})`,
|
|
528
|
+
...r.checks.length ? r.checks.map((c) => ` ${c.on ? "[on] " : "[off]"} ${c.n}. ${c.check}`) : [" (no checks in this flow)"]
|
|
529
|
+
].join("\n");
|
|
530
|
+
}).join("\n");
|
|
531
|
+
}
|
|
532
|
+
function describe(step) {
|
|
533
|
+
const target = (step.name ? `${step.role || "element"} "${step.name}"` : step.role || "") + (step.nth !== void 0 ? ` (nth ${step.nth})` : "");
|
|
534
|
+
const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
|
|
535
|
+
switch (step.type) {
|
|
536
|
+
case "fill":
|
|
537
|
+
return step.secret ? `type a secret (\u2022\u2022\u2022\u2022\u2022\u2022) into ${target}${where}` : `type "${step.value}" into ${target}${where}`;
|
|
538
|
+
case "select":
|
|
539
|
+
return `choose "${step.value}" in ${target}${where}`;
|
|
540
|
+
case "check":
|
|
541
|
+
return `${step.value === "false" ? "untick" : "tick"} ${target}${where}`;
|
|
542
|
+
case "press":
|
|
543
|
+
return `press ${step.value}${target ? ` in ${target}` : ""}`;
|
|
544
|
+
case "navigate":
|
|
545
|
+
return `go to ${step.value}`;
|
|
546
|
+
case "back":
|
|
547
|
+
case "forward":
|
|
548
|
+
return `browser ${step.type}`;
|
|
549
|
+
case "assert":
|
|
550
|
+
return `check ${step.proposals.map((p) => `${p.kind}${p.op && p.op !== "equals" ? ` ${p.op}` : ""} "${String(p.value)}"${p.target?.name ? ` on ${p.target.role} "${p.target.name}"` : ""}`).join(", ")}`;
|
|
551
|
+
case "useHelper":
|
|
552
|
+
return `use helper ${step.name || step.helper?.helperId} \u2192 {{${step.helper?.saveAs}}}${step.helper?.then === "open" ? ", then open it" : ""}`;
|
|
553
|
+
case "swipe":
|
|
554
|
+
return `swipe ${step.value}${target ? ` on ${target}` : ""}`;
|
|
555
|
+
case "scroll":
|
|
556
|
+
return target ? `scroll to ${target}` : `scroll ${step.value}`;
|
|
557
|
+
case "longPress":
|
|
558
|
+
return `long-press ${target}${where}`;
|
|
559
|
+
case "hideKeyboard":
|
|
560
|
+
return "hide the keyboard";
|
|
561
|
+
case "launchApp":
|
|
562
|
+
return "open the app";
|
|
563
|
+
case "terminateApp":
|
|
564
|
+
return "close the app";
|
|
565
|
+
case "resetApp":
|
|
566
|
+
return "clear the app's data and open it";
|
|
567
|
+
case "background":
|
|
568
|
+
return `send the app to the background for ${step.value || 3}s`;
|
|
569
|
+
case "rotate":
|
|
570
|
+
return `rotate to ${step.value}`;
|
|
571
|
+
case "permission":
|
|
572
|
+
return `${step.value === "deny" ? "deny" : "allow"} the permission prompt`;
|
|
573
|
+
default:
|
|
574
|
+
return `${step.type} ${target}${where}`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
var AuthoringSession = class _AuthoringSession {
|
|
578
|
+
constructor(service, opts = {}) {
|
|
579
|
+
this.service = service;
|
|
580
|
+
this.opts = opts;
|
|
581
|
+
}
|
|
582
|
+
service;
|
|
583
|
+
opts;
|
|
584
|
+
run = null;
|
|
585
|
+
page = null;
|
|
586
|
+
/** The phone, when the flow being written runs on one. */
|
|
587
|
+
mobile = null;
|
|
588
|
+
values = /* @__PURE__ */ new Map();
|
|
589
|
+
resolvers = null;
|
|
590
|
+
lastTree = null;
|
|
591
|
+
/** Values typed into secret fields, by their `typed:` reference. */
|
|
592
|
+
typed = /* @__PURE__ */ new Map();
|
|
593
|
+
draft = null;
|
|
594
|
+
/** The steps as last replayed from the start and passed — a save of the
|
|
595
|
+
* same steps needs no second replay. Cleared by any change to them. */
|
|
596
|
+
verified = null;
|
|
597
|
+
catalog = null;
|
|
598
|
+
/** Text going back to Claude, with every secret typed here masked — a
|
|
599
|
+
* password box's value is in the page's tree, and a careless form puts it
|
|
600
|
+
* in the address. */
|
|
601
|
+
redact(text2) {
|
|
602
|
+
let out = text2;
|
|
603
|
+
for (const value of this.typed.values()) {
|
|
604
|
+
if (value.length < 3) continue;
|
|
605
|
+
out = out.split(value).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
606
|
+
const encoded = encodeURIComponent(value);
|
|
607
|
+
if (encoded !== value) out = out.split(encoded).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
get open() {
|
|
612
|
+
return this.run !== null || this.mobile !== null;
|
|
613
|
+
}
|
|
614
|
+
requireMobile() {
|
|
615
|
+
if (!this.mobile || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
616
|
+
return { mobile: this.mobile, draft: this.draft };
|
|
617
|
+
}
|
|
618
|
+
requireOpen() {
|
|
619
|
+
if (!this.run || !this.page || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
620
|
+
return { page: this.page, draft: this.draft, run: this.run };
|
|
621
|
+
}
|
|
622
|
+
/** What the steps so far, plus any about to run, need from the service:
|
|
623
|
+
* sign-ins, helpers, and their secret values. Asked again whenever a new
|
|
624
|
+
* helper or sign-in appears, never kept beyond this process. */
|
|
625
|
+
async prepare(extra = []) {
|
|
626
|
+
const draft = this.draft;
|
|
627
|
+
const prepared = await this.service.prepare(
|
|
628
|
+
draft.projectId,
|
|
629
|
+
withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]),
|
|
630
|
+
draft.signIn ?? null,
|
|
631
|
+
draft.device?.kind === "cloud" ? { cloud: draft.device.provider } : {}
|
|
632
|
+
);
|
|
633
|
+
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
634
|
+
this.resolvers = {
|
|
635
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
636
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
|
|
637
|
+
...runHelper ? { runHelper } : {}
|
|
638
|
+
};
|
|
639
|
+
return this.resolvers;
|
|
640
|
+
}
|
|
641
|
+
baseUrl() {
|
|
642
|
+
const base = this.catalog?.baseUrl ?? "";
|
|
643
|
+
if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
|
|
644
|
+
return base;
|
|
645
|
+
}
|
|
646
|
+
/** Open a browser for a new flow, signed in first if asked.
|
|
647
|
+
*
|
|
648
|
+
* A DRAFT LEFT BEHIND IS PICKED UP. Every kept step is written to disk as
|
|
649
|
+
* it is kept, so a flow interrupted — the program stopped, the limit hit,
|
|
650
|
+
* the agent restarted — is not lost: starting the same flow again replays
|
|
651
|
+
* what was kept and carries on from there, unless `fresh` says start over. */
|
|
652
|
+
async start(input, catalog, opts = {}) {
|
|
653
|
+
await this.close();
|
|
654
|
+
this.catalog = catalog;
|
|
655
|
+
this.draft = { ...input, steps: [] };
|
|
656
|
+
this.values = /* @__PURE__ */ new Map();
|
|
657
|
+
this.typed = /* @__PURE__ */ new Map();
|
|
658
|
+
this.lastTree = null;
|
|
659
|
+
this.verified = null;
|
|
660
|
+
const left = opts.fresh ? null : this.readDraft(input.projectId, input.name);
|
|
661
|
+
if (opts.fresh) this.dropDraft(input.projectId, input.name);
|
|
662
|
+
const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
|
|
663
|
+
this.report();
|
|
664
|
+
if (!left?.steps.length) return opened;
|
|
665
|
+
return this.resume(left.steps, opened);
|
|
666
|
+
}
|
|
667
|
+
/** The steps of an earlier attempt, performed again in the open session
|
|
668
|
+
* and kept as they pass; the first that fails is where the person — or
|
|
669
|
+
* the model — takes over. */
|
|
670
|
+
async resume(steps, opened) {
|
|
671
|
+
const draft = this.draft;
|
|
672
|
+
let failed = null;
|
|
673
|
+
for (const step of steps) {
|
|
674
|
+
try {
|
|
675
|
+
if (this.mobile) {
|
|
676
|
+
const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
|
|
677
|
+
if (done.status !== "passed") throw new Error(done.results[0]?.detail ?? done.error ?? "it did not work");
|
|
678
|
+
} else {
|
|
679
|
+
const { page, run } = this.requireOpen();
|
|
680
|
+
this.page = await executeStep(page, step, {
|
|
681
|
+
pages: run.pages,
|
|
682
|
+
values: this.values,
|
|
683
|
+
baseUrl: this.baseUrl(),
|
|
684
|
+
...this.resolvers ?? {},
|
|
685
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
|
|
686
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
687
|
+
navigationTimeoutMs: 45e3
|
|
688
|
+
});
|
|
689
|
+
await this.settle();
|
|
690
|
+
}
|
|
691
|
+
draft.steps.push(step);
|
|
692
|
+
this.checkpoint();
|
|
693
|
+
} catch (error) {
|
|
694
|
+
failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
this.checkpoint();
|
|
699
|
+
const kept = draft.steps.length;
|
|
700
|
+
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}). Carry on from here.` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
|
|
701
|
+
const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
|
|
702
|
+
return `${head}
|
|
703
|
+
|
|
704
|
+
${view}`;
|
|
705
|
+
}
|
|
706
|
+
// ── Drafts on disk ──────────────────────────────────────────────────────
|
|
707
|
+
static draftFile(projectId, name) {
|
|
708
|
+
return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
|
|
709
|
+
}
|
|
710
|
+
/** Written after every kept step: the draft as it stands, never a typed
|
|
711
|
+
* secret (those stay in memory until the vault has them). */
|
|
712
|
+
checkpoint() {
|
|
713
|
+
const draft = this.draft;
|
|
714
|
+
if (!draft) return;
|
|
715
|
+
this.verified = null;
|
|
716
|
+
this.report();
|
|
717
|
+
try {
|
|
718
|
+
const file = _AuthoringSession.draftFile(draft.projectId, draft.name);
|
|
719
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 448 });
|
|
720
|
+
const { preparation: _p, ...rest } = draft;
|
|
721
|
+
fs.writeFileSync(file, JSON.stringify({ ...rest, steps: withoutTyped(draft.steps), savedAt: (/* @__PURE__ */ new Date()).toISOString() }), { mode: 384 });
|
|
722
|
+
} catch {
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** The draft as it stands, to the dashboard question this answers — at
|
|
726
|
+
* most every half second, a recording keeps a step a second. */
|
|
727
|
+
reportTimer = null;
|
|
728
|
+
report() {
|
|
729
|
+
if (this.reportTimer) return;
|
|
730
|
+
this.reportTimer = setTimeout(() => void this.sendReport(), 500);
|
|
731
|
+
this.reportTimer.unref?.();
|
|
732
|
+
}
|
|
733
|
+
/** Whatever is waiting to be reported, now — before a save or a close, so
|
|
734
|
+
* the dashboard ends on the flow as it was saved. */
|
|
735
|
+
async sendReport() {
|
|
736
|
+
if (this.reportTimer) {
|
|
737
|
+
clearTimeout(this.reportTimer);
|
|
738
|
+
this.reportTimer = null;
|
|
739
|
+
}
|
|
740
|
+
const draft = this.draft;
|
|
741
|
+
if (draft) {
|
|
742
|
+
const featureName = draft.featureName ?? this.catalog?.features?.find((f) => f.id === draft.featureId)?.name ?? "";
|
|
743
|
+
if (typeof this.service.reportDraft === "function") await this.service.reportDraft(draft.projectId, {
|
|
744
|
+
name: draft.name,
|
|
745
|
+
feature: featureName,
|
|
746
|
+
platform: draft.platform ?? "web",
|
|
747
|
+
after: (draft.after ?? []).map((a) => a.name),
|
|
748
|
+
signsIn: Boolean(draft.signIn),
|
|
749
|
+
steps: withoutTyped(draft.steps).map((st) => st.secret ? { ...st, value: "" } : st)
|
|
750
|
+
}).catch(() => void 0);
|
|
751
|
+
}
|
|
752
|
+
if (this.liveCase) await this.sendCaseReport();
|
|
753
|
+
}
|
|
754
|
+
// ── The test case a recording is for ────────────────────────────────
|
|
755
|
+
//
|
|
756
|
+
// A PERSON THINKS IN TEST CASES. The flows are how the assistant makes one
|
|
757
|
+
// runnable; what the person watches is the case filling in — every Given /
|
|
758
|
+
// When / Then with the steps that carry it out, as each step is kept, not
|
|
759
|
+
// after the flows are saved and the case is built. start_case names the
|
|
760
|
+
// case and its lines; each step recorded after it says which line it is
|
|
761
|
+
// for; save_case ties them on the saved case without being told again.
|
|
762
|
+
liveCase = null;
|
|
763
|
+
startCase(projectId, name, folder, lines) {
|
|
764
|
+
const same = this.liveCase && this.liveCase.projectId === projectId && nameKey(this.liveCase.name) === nameKey(name);
|
|
765
|
+
this.liveCase = same ? { ...this.liveCase, folder, lines } : { projectId, name, folder, lines, rows: [], lineOf: /* @__PURE__ */ new Map() };
|
|
766
|
+
void this.sendCaseReport();
|
|
767
|
+
return `Building the test case "${name}" in ${folder}:
|
|
768
|
+
${lines.map((l, i) => ` ${i + 1}. ${l.keyword} ${l.text}`).join("\n")}
|
|
769
|
+
Record the flows it needs; on every act, add_check and use_helper pass line: the number of the line that step carries out. save_case ties them on the case.`;
|
|
770
|
+
}
|
|
771
|
+
/** How many steps the flow being written has kept — before a tool runs, so
|
|
772
|
+
* the steps it keeps can be told apart. */
|
|
773
|
+
keptCount() {
|
|
774
|
+
return this.draft?.steps.length ?? 0;
|
|
775
|
+
}
|
|
776
|
+
/** The steps kept since `before` carry out line `line` of the case. */
|
|
777
|
+
tagSince(before, line) {
|
|
778
|
+
const live = this.liveCase;
|
|
779
|
+
const draft = this.draft;
|
|
780
|
+
if (!live || !draft) return "";
|
|
781
|
+
if (!Number.isInteger(line) || line < 1 || line > live.lines.length) return ` (There is no line ${line} in "${live.name}" \u2014 it has ${live.lines.length}.)`;
|
|
782
|
+
for (const step of draft.steps.slice(before)) live.lineOf.set(step.id, line);
|
|
783
|
+
void this.sendCaseReport();
|
|
784
|
+
return "";
|
|
785
|
+
}
|
|
786
|
+
/** The case's lines, each with the flow steps that carry it out — what
|
|
787
|
+
* save_case sends when it is not told. Null for another case. */
|
|
788
|
+
caseLinesFor(name) {
|
|
789
|
+
const live = this.liveCase;
|
|
790
|
+
if (!live || nameKey(live.name) !== nameKey(name)) return null;
|
|
791
|
+
const steps = this.caseRows().flatMap((r) => r.steps);
|
|
792
|
+
return live.lines.map((l, i) => ({
|
|
793
|
+
step: `${l.keyword} ${l.text}`,
|
|
794
|
+
flowSteps: steps.filter((st) => live.lineOf.get(st.id) === i + 1).map((st) => st.id)
|
|
795
|
+
}));
|
|
796
|
+
}
|
|
797
|
+
caseRows() {
|
|
798
|
+
const live = this.liveCase;
|
|
799
|
+
const draft = this.draft && this.draft.projectId === live.projectId ? this.draft : null;
|
|
800
|
+
const rows = live.rows.filter((r) => !draft || nameKey(r.name) !== nameKey(draft.name));
|
|
801
|
+
const current = draft ? { name: draft.name, platform: draft.platform ?? "web", steps: draft.steps } : null;
|
|
802
|
+
const at = draft ? live.rows.findIndex((r) => nameKey(r.name) === nameKey(draft.name)) : -1;
|
|
803
|
+
if (current && at >= 0) rows.splice(at, 0, current);
|
|
804
|
+
else if (current) rows.push(current);
|
|
805
|
+
return rows;
|
|
806
|
+
}
|
|
807
|
+
/** The case as it stands, to the dashboard question this answers. */
|
|
808
|
+
async sendCaseReport() {
|
|
809
|
+
const live = this.liveCase;
|
|
810
|
+
if (!live || typeof this.service.reportCase !== "function") return;
|
|
811
|
+
const rows = this.caseRows();
|
|
812
|
+
const refOf = /* @__PURE__ */ new Map();
|
|
813
|
+
const occurrences = rows.map((r) => {
|
|
814
|
+
const occurrenceId = `live_${slugify(r.name) || "flow"}`;
|
|
815
|
+
for (const st of r.steps) refOf.set(st.id, `${occurrenceId}/${st.id}`);
|
|
816
|
+
return {
|
|
817
|
+
occurrenceId,
|
|
818
|
+
name: r.name,
|
|
819
|
+
...r.platform !== "web" ? { platform: r.platform } : {},
|
|
820
|
+
steps: withoutTyped(r.steps).map((st) => st.secret ? { ...st, value: "" } : st)
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
const businessSteps = live.lines.map((l, i) => {
|
|
824
|
+
const steps = rows.flatMap((r) => r.steps).filter((st) => live.lineOf.get(st.id) === i + 1).map((st) => refOf.get(st.id));
|
|
825
|
+
return { keyword: l.keyword, text: l.text, ...steps.length ? { steps } : {} };
|
|
826
|
+
});
|
|
827
|
+
await this.service.reportCase(live.projectId, { name: live.name, folder: live.folder, testCase: { name: live.name, occurrences, businessSteps } }).catch(() => void 0);
|
|
828
|
+
}
|
|
829
|
+
readDraft(projectId, name) {
|
|
830
|
+
try {
|
|
831
|
+
const raw = JSON.parse(fs.readFileSync(_AuthoringSession.draftFile(projectId, name), "utf8"));
|
|
832
|
+
const steps = (raw.steps ?? []).map((s) => RecordedStepSchema.parse(s));
|
|
833
|
+
return { steps };
|
|
834
|
+
} catch {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
dropDraft(projectId, name) {
|
|
839
|
+
try {
|
|
840
|
+
fs.rmSync(_AuthoringSession.draftFile(projectId, name), { force: true });
|
|
841
|
+
} catch {
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/** Close and forget the flow being written: close_browser means it. */
|
|
845
|
+
async closeAndDrop() {
|
|
846
|
+
const draft = this.draft;
|
|
847
|
+
await this.close();
|
|
848
|
+
if (draft) this.dropDraft(draft.projectId, draft.name);
|
|
849
|
+
this.draft = null;
|
|
850
|
+
}
|
|
851
|
+
async startWeb(input, catalog) {
|
|
852
|
+
void catalog;
|
|
853
|
+
const base = this.baseUrl();
|
|
854
|
+
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
855
|
+
this.page = this.run.page;
|
|
856
|
+
if (input.signIn) {
|
|
857
|
+
const resolvers = await this.prepare();
|
|
858
|
+
const signIn = {
|
|
859
|
+
id: "sign-in",
|
|
860
|
+
type: "signIn",
|
|
861
|
+
auth: { ...input.signIn, from: "loginPage" },
|
|
862
|
+
role: "",
|
|
863
|
+
name: "Sign in",
|
|
864
|
+
scope: "",
|
|
865
|
+
value: "",
|
|
866
|
+
secret: false,
|
|
867
|
+
legacySelector: "",
|
|
868
|
+
proposals: []
|
|
869
|
+
};
|
|
870
|
+
this.page = await executeStep(this.page, signIn, {
|
|
871
|
+
pages: this.run.pages,
|
|
872
|
+
...resolvers,
|
|
873
|
+
values: this.values,
|
|
874
|
+
baseUrl: base,
|
|
875
|
+
timeoutMs: 2e4,
|
|
876
|
+
navigationTimeoutMs: 45e3
|
|
877
|
+
});
|
|
878
|
+
} else {
|
|
879
|
+
await this.page.goto(new URL(input.startPath || "/", base).href, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
880
|
+
}
|
|
881
|
+
if (input.preparation?.length) {
|
|
882
|
+
const resolvers = await this.prepare(input.preparation);
|
|
883
|
+
for (const step of input.preparation) {
|
|
884
|
+
try {
|
|
885
|
+
this.page = await executeStep(this.page, step, {
|
|
886
|
+
pages: this.run.pages,
|
|
887
|
+
...resolvers,
|
|
888
|
+
values: this.values,
|
|
889
|
+
baseUrl: base,
|
|
890
|
+
timeoutMs: 2e4,
|
|
891
|
+
navigationTimeoutMs: 45e3
|
|
892
|
+
});
|
|
893
|
+
} catch (error) {
|
|
894
|
+
throw new Error(`The flows this one starts after did not finish, at "${step.name || step.type}": ${error.message.split("\n")[0]}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
await this.settle();
|
|
898
|
+
}
|
|
899
|
+
return this.snapshot();
|
|
900
|
+
}
|
|
901
|
+
/** The page as Claude reads it: where it is, and what is on it by role and name. */
|
|
902
|
+
async snapshot() {
|
|
903
|
+
return this.view(true);
|
|
904
|
+
}
|
|
905
|
+
/** WHAT CHANGED, after a step — not the whole page again.
|
|
906
|
+
*
|
|
907
|
+
* Every step used to answer with the full tree, which on a long storefront
|
|
908
|
+
* is twenty thousand characters repeated forty times to say "the box now
|
|
909
|
+
* has text in it". So a step answers with what it is about: an open dialog
|
|
910
|
+
* (with the navigation, which says who is signed in), or the lines that
|
|
911
|
+
* appeared and disappeared — and only after a real navigation, when most of
|
|
912
|
+
* the page is new, the page itself, capped. read_page is always the whole
|
|
913
|
+
* thing. */
|
|
914
|
+
async view(full) {
|
|
915
|
+
const { tree, head } = this.mobile ? await this.mobileTree() : await this.webTree();
|
|
916
|
+
const before = this.lastTree;
|
|
917
|
+
this.lastTree = tree;
|
|
918
|
+
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
919
|
+
\u2026 (cut \u2014 read_page shows the whole page)` : t;
|
|
920
|
+
if (full || before === null) return head + cap(tree, MAX_TREE);
|
|
921
|
+
const { dialogs, navigation } = topLevelBlocks(tree);
|
|
922
|
+
if (dialogs.length) {
|
|
923
|
+
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog and the navigation \u2014 read_page shows the whole page)";
|
|
924
|
+
}
|
|
925
|
+
const was = new Set(before.split("\n"));
|
|
926
|
+
const now = new Set(tree.split("\n"));
|
|
927
|
+
const added = tree.split("\n").filter((l) => !was.has(l));
|
|
928
|
+
const removed = before.split("\n").filter((l) => !now.has(l));
|
|
929
|
+
if (added.length === 0 && removed.length === 0) return head + "(the page did not change \u2014 read_page shows it)";
|
|
930
|
+
if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
|
|
931
|
+
return head + [
|
|
932
|
+
...navigation ? [navigation, ""] : [],
|
|
933
|
+
added.length ? `Appeared:
|
|
934
|
+
${added.join("\n")}` : "",
|
|
935
|
+
removed.length ? `Gone:
|
|
936
|
+
${removed.join("\n")}` : "",
|
|
937
|
+
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
938
|
+
].filter(Boolean).join("\n");
|
|
939
|
+
}
|
|
940
|
+
async webTree() {
|
|
941
|
+
const { page, draft } = this.requireOpen();
|
|
942
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
943
|
+
const tree = numberDuplicates(labelTree(
|
|
944
|
+
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
945
|
+
await fieldNames(page)
|
|
946
|
+
));
|
|
947
|
+
const title = await page.title().catch(() => "");
|
|
948
|
+
return { tree, head: `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
|
|
949
|
+
Steps so far: ${draft.steps.length}
|
|
950
|
+
|
|
951
|
+
` };
|
|
952
|
+
}
|
|
953
|
+
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
954
|
+
async act(input) {
|
|
955
|
+
if (this.mobile) return this.mobileAct(input);
|
|
956
|
+
const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
|
|
957
|
+
if (refused) throw new Error(refused);
|
|
958
|
+
const { page, draft, run } = this.requireOpen();
|
|
959
|
+
const navigating = input.action === "navigate" || input.action === "back" || input.action === "forward";
|
|
960
|
+
if (!navigating && input.action !== "press" && !(input.role && input.name)) {
|
|
961
|
+
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
962
|
+
}
|
|
963
|
+
if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
|
|
964
|
+
throw new Error(`${input.action} needs a value`);
|
|
965
|
+
}
|
|
966
|
+
const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
967
|
+
const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
|
|
968
|
+
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
969
|
+
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
970
|
+
const step = RecordedStepSchema.parse({
|
|
971
|
+
id,
|
|
972
|
+
type: input.action,
|
|
973
|
+
...target,
|
|
974
|
+
...scopeOf(input.within),
|
|
975
|
+
...input.nth !== void 0 ? { nth: input.nth } : {},
|
|
976
|
+
value: secret ? "" : input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
|
|
977
|
+
secret,
|
|
978
|
+
...secret ? { secretRef: `${TYPED}${id}` } : {},
|
|
979
|
+
legacySelector: "",
|
|
980
|
+
proposals: [],
|
|
981
|
+
// A path is the application's own, so the step replays on whichever
|
|
982
|
+
// environment the run is pointed at rather than the one written on.
|
|
983
|
+
...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
|
|
984
|
+
});
|
|
985
|
+
try {
|
|
986
|
+
this.page = await executeStep(page, step, {
|
|
987
|
+
pages: run.pages,
|
|
988
|
+
values: this.values,
|
|
989
|
+
baseUrl: this.baseUrl(),
|
|
990
|
+
...this.resolvers ?? {},
|
|
991
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
|
|
992
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
993
|
+
navigationTimeoutMs: 45e3
|
|
994
|
+
});
|
|
995
|
+
} catch (error) {
|
|
996
|
+
if (secret) this.typed.delete(`${TYPED}${id}`);
|
|
997
|
+
const said = error.message.split("\n")[0] ?? "";
|
|
998
|
+
const several = /strict mode violation|resolved to \d+ elements|\d+ elements match/i.test(said) ? "\nSeveral elements share this role and name. Pass nth (0 = the first, in the order read_page lists them \u2014 each shared name is marked [nth N of M]) to say which one." : "";
|
|
999
|
+
throw new Error(`That did not work, and was not kept: ${said}${several}
|
|
1000
|
+
|
|
1001
|
+
${await this.view(false)}`);
|
|
1002
|
+
}
|
|
1003
|
+
await this.settle();
|
|
1004
|
+
draft.steps.push(step);
|
|
1005
|
+
this.checkpoint();
|
|
1006
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
1007
|
+
|
|
1008
|
+
${await this.view(false)}`;
|
|
1009
|
+
}
|
|
1010
|
+
/** Check something about the page now, and keep the check if it holds. */
|
|
1011
|
+
async check(input) {
|
|
1012
|
+
if (this.mobile) return this.mobileCheck(input);
|
|
1013
|
+
const { page, draft } = this.requireOpen();
|
|
1014
|
+
if ((input.kind === "heading" || input.kind === "message" || input.kind === "screen") && input.op && input.op !== "equals" && !input.role) {
|
|
1015
|
+
throw new Error(`a ${input.kind} check without role and name matches the whole ${input.kind} \u2014 give its full text with op equals, or name the element (role + name) and use kind "text" with op ${input.op}`);
|
|
1016
|
+
}
|
|
1017
|
+
const assertion = RecordedStepSchema.shape.proposals.element.parse({
|
|
1018
|
+
kind: input.kind,
|
|
1019
|
+
value: input.value,
|
|
1020
|
+
enabled: true,
|
|
1021
|
+
...input.op && input.op !== "equals" ? { op: input.op } : {},
|
|
1022
|
+
...input.role ? { target: { ...asLabelled({ role: input.role, name: input.name ?? "" }, await fieldNames(page)), ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} } } : {}
|
|
1023
|
+
});
|
|
1024
|
+
const filled = (text2) => text2.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => runtimeValue(this.values, name));
|
|
1025
|
+
const expected = Array.isArray(assertion.value) ? assertion.value.map(filled) : filled(assertion.value);
|
|
1026
|
+
try {
|
|
1027
|
+
await checkAssertion(page, assertion.kind, expected, 5e3, { ...assertion, value: expected });
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
throw new Error(`That is not true of the page right now, so it was not kept: ${error.message.split("\n")[0]}`);
|
|
1030
|
+
}
|
|
1031
|
+
const step = RecordedStepSchema.parse(mirrorCheckTargets([{
|
|
1032
|
+
id: `c${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
1033
|
+
type: "assert",
|
|
1034
|
+
role: "",
|
|
1035
|
+
name: "",
|
|
1036
|
+
scope: "",
|
|
1037
|
+
value: "",
|
|
1038
|
+
secret: false,
|
|
1039
|
+
legacySelector: "",
|
|
1040
|
+
proposals: [assertion]
|
|
1041
|
+
}])[0]);
|
|
1042
|
+
draft.steps.push(step);
|
|
1043
|
+
this.checkpoint();
|
|
1044
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
1045
|
+
}
|
|
1046
|
+
/** Get a value from outside the page — an email code, a 2FA code, a text,
|
|
1047
|
+
* an API reply — and keep the step that gets it. */
|
|
1048
|
+
async useHelper(input) {
|
|
1049
|
+
if (this.mobile) return this.mobileHelper(input);
|
|
1050
|
+
const { page, draft, run } = this.requireOpen();
|
|
1051
|
+
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
1052
|
+
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
1053
|
+
const missing = helper.inputs.filter((name) => !(input.inputs ?? {})[name]);
|
|
1054
|
+
if (missing.length) throw new Error(`${helper.name} needs ${missing.map((n) => `{{${n}}}`).join(", ")} in inputs`);
|
|
1055
|
+
const step = RecordedStepSchema.parse({
|
|
1056
|
+
id: `h${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
1057
|
+
type: "useHelper",
|
|
1058
|
+
role: "",
|
|
1059
|
+
name: helper.name,
|
|
1060
|
+
scope: "",
|
|
1061
|
+
value: "",
|
|
1062
|
+
secret: false,
|
|
1063
|
+
legacySelector: "",
|
|
1064
|
+
proposals: [],
|
|
1065
|
+
helper: { helperId: helper.id, inputs: input.inputs ?? {}, saveAs: input.saveAs || (helper.kind === "mailbox" ? "code" : "value"), then: input.then ?? "remember" }
|
|
1066
|
+
});
|
|
1067
|
+
const resolvers = await this.prepare([step]);
|
|
1068
|
+
let got = { value: "", detail: "" };
|
|
1069
|
+
try {
|
|
1070
|
+
this.page = await executeStep(page, step, {
|
|
1071
|
+
pages: run.pages,
|
|
1072
|
+
values: this.values,
|
|
1073
|
+
baseUrl: this.baseUrl(),
|
|
1074
|
+
...resolvers,
|
|
1075
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
1076
|
+
navigationTimeoutMs: 45e3,
|
|
1077
|
+
onHelper: (answer) => {
|
|
1078
|
+
got = answer;
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
|
|
1083
|
+
}
|
|
1084
|
+
await this.settle();
|
|
1085
|
+
draft.steps.push(step);
|
|
1086
|
+
this.checkpoint();
|
|
1087
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got.value}" (${got.detail}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
|
|
1088
|
+
|
|
1089
|
+
${await this.view(false)}` : "");
|
|
1090
|
+
}
|
|
1091
|
+
list() {
|
|
1092
|
+
const draft = this.draft;
|
|
1093
|
+
if (!draft) return "No flow is being written.";
|
|
1094
|
+
const head = `"${draft.name}"${draft.signIn ? ", signed in first" : ""}, starting at ${draft.startPath}`;
|
|
1095
|
+
if (draft.steps.length === 0) return `${head}. No steps yet.`;
|
|
1096
|
+
return `${head}:
|
|
1097
|
+
${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
1098
|
+
}
|
|
1099
|
+
/** Drop a step from the draft. The browser is not wound back — replay to
|
|
1100
|
+
* see the flow as it now stands. */
|
|
1101
|
+
remove(number) {
|
|
1102
|
+
const draft = this.draft;
|
|
1103
|
+
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
1104
|
+
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
1105
|
+
const [gone] = draft.steps.splice(number - 1, 1);
|
|
1106
|
+
this.checkpoint();
|
|
1107
|
+
return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
|
|
1108
|
+
|
|
1109
|
+
${this.list()}`;
|
|
1110
|
+
}
|
|
1111
|
+
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
1112
|
+
* run will — the proof the steps work together, not just one at a time. */
|
|
1113
|
+
async replay() {
|
|
1114
|
+
if (this.mobile) return this.mobileReplay();
|
|
1115
|
+
const { draft } = this.requireOpen();
|
|
1116
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
1117
|
+
const resolvers = await this.prepare();
|
|
1118
|
+
const steps = [
|
|
1119
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: { ...draft.signIn, from: "loginPage" }, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
1120
|
+
...draft.preparation ?? [],
|
|
1121
|
+
...draft.steps
|
|
1122
|
+
];
|
|
1123
|
+
const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
|
|
1124
|
+
const lines = outcome.steps.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
|
|
1125
|
+
if (outcome.status === "passed") this.verified = signatureOf(draft.steps);
|
|
1126
|
+
return `${outcome.status === "passed" ? "The whole flow passed from the start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
1127
|
+
${lines.join("\n")}`;
|
|
1128
|
+
}
|
|
1129
|
+
/** Save the draft as a flow on a new branch, for a person to review.
|
|
1130
|
+
*
|
|
1131
|
+
* REPLAYED FIRST. A flow that was only ever performed step by step, with
|
|
1132
|
+
* pauses to look, has not been shown to run as a test runs it; so unless
|
|
1133
|
+
* the same steps already passed a replay, they are replayed from the
|
|
1134
|
+
* start now, and a flow that fails is not saved — the failing step is
|
|
1135
|
+
* named instead. `force` saves anyway, for a person who knows. */
|
|
1136
|
+
async save(title, where = {}, opts = {}) {
|
|
1137
|
+
const draft = this.draft;
|
|
1138
|
+
if (!draft || !this.open) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
1139
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
1140
|
+
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
1141
|
+
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
1142
|
+
}
|
|
1143
|
+
let replayed = "";
|
|
1144
|
+
if (!opts.force && this.verified !== signatureOf(draft.steps)) {
|
|
1145
|
+
const report = await this.replay();
|
|
1146
|
+
if (!/^The whole flow passed/.test(report)) {
|
|
1147
|
+
throw new Error(`Not saved: replayed from the start, the flow did not pass.
|
|
1148
|
+
${report}
|
|
1149
|
+
Fix the step that failed (remove_step, then act again) and save again \u2014 or save_flow with force: true to keep it as it is.`);
|
|
1150
|
+
}
|
|
1151
|
+
replayed = " It was replayed from the start first and passed.";
|
|
1152
|
+
}
|
|
1153
|
+
const checksOff = draft.steps.map((step) => step.type === "assert" ? { ...step, proposals: step.proposals.map((p) => ({ ...p, enabled: false })) } : step);
|
|
1154
|
+
if (this.reportTimer) await this.sendReport();
|
|
1155
|
+
const checkCount = checksOff.filter((step) => step.type === "assert").length;
|
|
1156
|
+
const secretValues = {};
|
|
1157
|
+
for (const step of checksOff) {
|
|
1158
|
+
const value = step.secretRef ? this.typed.get(step.secretRef) : void 0;
|
|
1159
|
+
if (value !== void 0) secretValues[step.id] = value;
|
|
1160
|
+
}
|
|
1161
|
+
const saved = await this.service.saveFlow(draft.projectId, title || "Tests written with Claude", {
|
|
1162
|
+
name: draft.name,
|
|
1163
|
+
...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
|
|
1164
|
+
startPath: draft.startPath,
|
|
1165
|
+
...isMobile(draft.platform) ? { platform: draft.platform } : {},
|
|
1166
|
+
steps: withoutTyped(checksOff),
|
|
1167
|
+
...draft.signIn ? { signIn: draft.signIn } : {},
|
|
1168
|
+
...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
|
|
1169
|
+
}, where, secretValues);
|
|
1170
|
+
const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
|
|
1171
|
+
const what = saved.replaced ? `Replaced the earlier "${draft.name}"` : `Saved "${draft.name}"`;
|
|
1172
|
+
const sealed = Object.keys(secretValues).length;
|
|
1173
|
+
const vault = sealed ? ` ${sealed} secret value${sealed === 1 ? " was" : "s were"} put in the project's vault, not the flow.` : "";
|
|
1174
|
+
const where_ = saved.newBranch ? `on a new branch, ${saved.branch}` : `on Claude's working branch, ${saved.branch}`;
|
|
1175
|
+
const others = await this.service.agentChanges(draft.projectId).then((r) => r.changes.filter((c) => c.id !== saved.changeId)).catch(() => []);
|
|
1176
|
+
const note = others.length ? `
|
|
1177
|
+
Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
|
|
1178
|
+
const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
|
|
1179
|
+
this.dropDraft(draft.projectId, draft.name);
|
|
1180
|
+
const live = this.liveCase;
|
|
1181
|
+
if (live && live.projectId === draft.projectId) {
|
|
1182
|
+
const row = { name: draft.name, platform: draft.platform ?? "web", steps: [...draft.steps] };
|
|
1183
|
+
const at = live.rows.findIndex((r) => nameKey(r.name) === nameKey(draft.name));
|
|
1184
|
+
if (at >= 0) live.rows[at] = row;
|
|
1185
|
+
else live.rows.push(row);
|
|
1186
|
+
}
|
|
1187
|
+
return `${what} ${where_}.${replayed}${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
|
|
1188
|
+
Review and publish it here: ${review}${note}`;
|
|
1189
|
+
}
|
|
1190
|
+
/** A test case built from flows, without saving: its rows and every check,
|
|
1191
|
+
* numbered, with which are on — what save_case's `checks` refer to. */
|
|
1192
|
+
async previewCase(projectId, spec) {
|
|
1193
|
+
const built = await this.service.buildCase(projectId, spec, true);
|
|
1194
|
+
return `"${spec.name}" in ${spec.folder} would be:
|
|
1195
|
+
${describeRows(built.rows, built.testCase)}`;
|
|
1196
|
+
}
|
|
1197
|
+
/** RUN THE CASE, THEN SAVE IT. Built as a dry run, replayed in a fresh
|
|
1198
|
+
* browser exactly as a test run does it, and saved onto Claude's working
|
|
1199
|
+
* branch only when every row passed. */
|
|
1200
|
+
async saveCase(projectId, spec, catalog) {
|
|
1201
|
+
const built = await this.service.buildCase(projectId, spec, true);
|
|
1202
|
+
const testCase = built.testCase;
|
|
1203
|
+
const steps = testCase.occurrences.flatMap((o) => o.steps);
|
|
1204
|
+
const prepared = await this.service.prepare(projectId, steps, null);
|
|
1205
|
+
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
1206
|
+
const base = catalog.baseUrl;
|
|
1207
|
+
const phoneRows = testCase.occurrences.filter((o) => isMobile(o.platform));
|
|
1208
|
+
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");
|
|
1209
|
+
const laneOptions = {
|
|
1210
|
+
mobile: catalog.mobile ?? mobileOf(null),
|
|
1211
|
+
resolveSecret: (ref) => prepared.secrets[ref],
|
|
1212
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
1213
|
+
...runHelper ? { runHelper } : {}
|
|
1214
|
+
};
|
|
1215
|
+
const lane = phoneRows.length ? new MobileLane(laneOptions) : null;
|
|
1216
|
+
const opened = /* @__PURE__ */ new Set();
|
|
1217
|
+
const outcome = phoneRows.length === testCase.occurrences.length ? await replayMobileCase({ ...laneOptions, occurrences: testCase.occurrences, ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {} }) : await replayCase({
|
|
1218
|
+
...lane ? {
|
|
1219
|
+
runElsewhere: (occurrence, ctx) => {
|
|
1220
|
+
if (!isMobile(occurrence.platform)) return null;
|
|
1221
|
+
const key = occurrence.actorId ?? testCase.defaultActorId ?? "default";
|
|
1222
|
+
const first = !opened.has(key);
|
|
1223
|
+
opened.add(key);
|
|
1224
|
+
return lane.run(occurrence, { deviceKey: key, first, ...ctx });
|
|
1225
|
+
}
|
|
1226
|
+
} : {},
|
|
1227
|
+
occurrences: testCase.occurrences,
|
|
1228
|
+
...testCase.actors ? { actors: testCase.actors } : {},
|
|
1229
|
+
...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
|
|
1230
|
+
baseUrl: base,
|
|
1231
|
+
headless: true,
|
|
1232
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
1233
|
+
resolveSecret: (ref) => prepared.secrets[ref],
|
|
1234
|
+
...runHelper ? { runHelper } : {}
|
|
1235
|
+
}).finally(() => lane?.close());
|
|
1236
|
+
const lines = outcome.occurrences.map((o, i) => {
|
|
1237
|
+
const failed = o.steps.find((st) => st.status === "failed");
|
|
1238
|
+
const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
|
|
1239
|
+
return `${o.status === "passed" ? "\u2713" : o.status === "failed" ? "\u2717" : "\xB7"} row ${i + 1} \u2014 ${o.name}${why}`;
|
|
1240
|
+
});
|
|
1241
|
+
if (outcome.status !== "passed") {
|
|
1242
|
+
return `The case did not pass, so it was NOT saved.
|
|
1243
|
+
${lines.join("\n")}
|
|
1244
|
+
|
|
1245
|
+
${describeRows(built.rows, testCase)}`;
|
|
1246
|
+
}
|
|
1247
|
+
const saved = await this.service.buildCase(projectId, spec, false);
|
|
1248
|
+
const review = `${this.service.origin}${saved.path}`;
|
|
1249
|
+
return `The case passed from the start${saved.replaced ? " and replaced the earlier version" : ""}, and is saved on ${saved.newBranch ? "a new branch" : "Claude's working branch"}. Nothing is published yet.
|
|
1250
|
+
${lines.join("\n")}
|
|
1251
|
+
Review and publish it here: ${review}`;
|
|
1252
|
+
}
|
|
1253
|
+
async close() {
|
|
1254
|
+
if (this.reportTimer) await this.sendReport();
|
|
1255
|
+
const run = this.run;
|
|
1256
|
+
const mobile = this.mobile;
|
|
1257
|
+
this.run = null;
|
|
1258
|
+
this.page = null;
|
|
1259
|
+
this.mobile = null;
|
|
1260
|
+
this.resolvers = null;
|
|
1261
|
+
if (mobile) await mobile.close().catch(() => void 0);
|
|
1262
|
+
if (run) await closeRunSession(run, { passed: true }).catch(() => void 0);
|
|
1263
|
+
}
|
|
1264
|
+
// ── On a phone ────────────────────────────────────────────────────────
|
|
1265
|
+
/** Open the project's app on the chosen phone, signed in and brought to
|
|
1266
|
+
* where the flow starts, as on the web. */
|
|
1267
|
+
async startMobile(platform, catalog) {
|
|
1268
|
+
const draft = this.draft;
|
|
1269
|
+
const mobile = catalog.mobile;
|
|
1270
|
+
if (!mobile) throw new Error("this project has no phone apps set up \u2014 turn on Android or iOS in Settings \u2192 Platforms");
|
|
1271
|
+
const resolvers = draft.signIn || draft.device?.kind === "cloud" ? await this.prepare() : null;
|
|
1272
|
+
this.mobile = await openMobileSession({
|
|
1273
|
+
platform,
|
|
1274
|
+
mobile,
|
|
1275
|
+
...catalog.runWhere ? { where: catalog.runWhere[platform] } : {},
|
|
1276
|
+
...draft.device ? { device: draft.device } : {},
|
|
1277
|
+
...resolvers ? { resolveSecret: resolvers.resolveSecret } : {}
|
|
1278
|
+
});
|
|
1279
|
+
const opening = [
|
|
1280
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
1281
|
+
...draft.preparation ?? []
|
|
1282
|
+
];
|
|
1283
|
+
if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await this.mobile.openLink(draft.startPath);
|
|
1284
|
+
if (opening.length) {
|
|
1285
|
+
const ready = await this.prepare(draft.preparation ?? []);
|
|
1286
|
+
const done = await runMobileSteps(this.mobile, opening, { values: this.values, ...ready });
|
|
1287
|
+
if (done.status !== "passed") throw new Error(`The app could not be brought to where this flow starts: ${done.error}`);
|
|
1288
|
+
}
|
|
1289
|
+
return this.snapshot();
|
|
1290
|
+
}
|
|
1291
|
+
async mobileTree() {
|
|
1292
|
+
const { mobile, draft } = this.requireMobile();
|
|
1293
|
+
mobile.changed();
|
|
1294
|
+
let screen = await mobile.screen();
|
|
1295
|
+
if (screen.nodes.length === 0) screen = await mobile.settle(8e3);
|
|
1296
|
+
const where = await mobile.currentScreenName();
|
|
1297
|
+
const tree = numberDuplicates(screenLines(screen)) || "(nothing on the screen can be read \u2014 the app may still be loading, or draws without accessibility labels)";
|
|
1298
|
+
return { tree, head: `Screen: ${screen.app || mobile.appId} on ${mobile.deviceName}${where ? ` \u2014 ${where}` : ""}
|
|
1299
|
+
Steps so far: ${draft.steps.length}
|
|
1300
|
+
|
|
1301
|
+
` };
|
|
1302
|
+
}
|
|
1303
|
+
mobileResolvers() {
|
|
1304
|
+
return {
|
|
1305
|
+
values: this.values,
|
|
1306
|
+
...this.resolvers ?? {},
|
|
1307
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref)
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
async mobileAct(input) {
|
|
1311
|
+
const { mobile, draft } = this.requireMobile();
|
|
1312
|
+
const type = input.action === "tap" ? "click" : input.action;
|
|
1313
|
+
const refused = stepRefusedOn(type, mobile.platform);
|
|
1314
|
+
if (refused) throw new Error(refused);
|
|
1315
|
+
const needsElement = ["click", "longPress", "fill", "select", "check"].includes(type);
|
|
1316
|
+
if (needsElement && !(input.role && (input.name || input.nth !== void 0))) {
|
|
1317
|
+
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
1318
|
+
}
|
|
1319
|
+
if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
|
|
1320
|
+
let target = { role: "", name: "" };
|
|
1321
|
+
let password = false;
|
|
1322
|
+
if (input.role) {
|
|
1323
|
+
const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
|
|
1324
|
+
try {
|
|
1325
|
+
const found = await locate(mobile, asked, STEP_TIMEOUT_MS, { scroll: type !== "swipe" });
|
|
1326
|
+
target = { ...targetFor(found.screen, found.node), ...asked };
|
|
1327
|
+
password = found.node.password;
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
const said = error.message.split("\n")[0] ?? "";
|
|
1330
|
+
const several = /elements match/.test(said) ? "\nSeveral elements share this role and name. Pass nth (0 = the first, in the order read_page lists them) to say which one." : "";
|
|
1331
|
+
throw new Error(`That did not work, and was not kept: ${said}${several}
|
|
1332
|
+
|
|
1333
|
+
${await this.view(false)}`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const secret = type === "fill" && (input.secret ?? (password || SECRET_WORDS.test(input.name ?? "")));
|
|
1337
|
+
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
1338
|
+
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
1339
|
+
const value = secret ? "" : type === "check" ? input.value === "false" ? "false" : "true" : type === "swipe" || type === "scroll" && !input.role ? (input.value || "up").toLowerCase() : input.value ?? "";
|
|
1340
|
+
const step = RecordedStepSchema.parse({
|
|
1341
|
+
id,
|
|
1342
|
+
type,
|
|
1343
|
+
...target,
|
|
1344
|
+
value,
|
|
1345
|
+
secret,
|
|
1346
|
+
...secret ? { secretRef: `${TYPED}${id}` } : {},
|
|
1347
|
+
legacySelector: "",
|
|
1348
|
+
proposals: []
|
|
1349
|
+
});
|
|
1350
|
+
try {
|
|
1351
|
+
await performMobileStep(mobile, step, this.mobileResolvers());
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
if (secret) this.typed.delete(`${TYPED}${id}`);
|
|
1354
|
+
throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
|
|
1355
|
+
|
|
1356
|
+
${await this.view(false)}`);
|
|
1357
|
+
}
|
|
1358
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1359
|
+
draft.steps.push(step);
|
|
1360
|
+
this.checkpoint();
|
|
1361
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
1362
|
+
|
|
1363
|
+
${await this.view(false)}`;
|
|
1364
|
+
}
|
|
1365
|
+
async mobileCheck(input) {
|
|
1366
|
+
const { mobile, draft } = this.requireMobile();
|
|
1367
|
+
if (input.kind === "url" || input.kind === "title") throw new Error(`a phone app has no ${input.kind === "url" ? "address" : "page title"} \u2014 check text on the screen (message), a heading's text, or an element`);
|
|
1368
|
+
const assertion = RecordedStepSchema.shape.proposals.element.parse({
|
|
1369
|
+
kind: input.kind,
|
|
1370
|
+
value: input.value,
|
|
1371
|
+
enabled: true,
|
|
1372
|
+
...input.op && input.op !== "equals" ? { op: input.op } : {},
|
|
1373
|
+
...input.role ? { target: { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} } } : {}
|
|
1374
|
+
});
|
|
1375
|
+
const filled = (text2) => text2.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => runtimeValue(this.values, name));
|
|
1376
|
+
const expected = Array.isArray(assertion.value) ? assertion.value.map(filled) : filled(assertion.value);
|
|
1377
|
+
try {
|
|
1378
|
+
await checkMobileAssertion(mobile, { ...assertion, value: expected }, 5e3);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
throw new Error(`That is not true of the screen right now, so it was not kept: ${error.message.split("\n")[0]}`);
|
|
1381
|
+
}
|
|
1382
|
+
const step = RecordedStepSchema.parse(mirrorCheckTargets([{
|
|
1383
|
+
id: `c${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
1384
|
+
type: "assert",
|
|
1385
|
+
role: "",
|
|
1386
|
+
name: "",
|
|
1387
|
+
scope: "",
|
|
1388
|
+
value: "",
|
|
1389
|
+
secret: false,
|
|
1390
|
+
legacySelector: "",
|
|
1391
|
+
proposals: [assertion]
|
|
1392
|
+
}])[0]);
|
|
1393
|
+
draft.steps.push(step);
|
|
1394
|
+
this.checkpoint();
|
|
1395
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
1396
|
+
}
|
|
1397
|
+
async mobileHelper(input) {
|
|
1398
|
+
const { mobile, draft } = this.requireMobile();
|
|
1399
|
+
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
1400
|
+
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
1401
|
+
const missing = helper.inputs.filter((name) => !(input.inputs ?? {})[name]);
|
|
1402
|
+
if (missing.length) throw new Error(`${helper.name} needs ${missing.map((n) => `{{${n}}}`).join(", ")} in inputs`);
|
|
1403
|
+
const step = RecordedStepSchema.parse({
|
|
1404
|
+
id: `h${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
1405
|
+
type: "useHelper",
|
|
1406
|
+
role: "",
|
|
1407
|
+
name: helper.name,
|
|
1408
|
+
scope: "",
|
|
1409
|
+
value: "",
|
|
1410
|
+
secret: false,
|
|
1411
|
+
legacySelector: "",
|
|
1412
|
+
proposals: [],
|
|
1413
|
+
helper: { helperId: helper.id, inputs: input.inputs ?? {}, saveAs: input.saveAs || (helper.kind === "mailbox" ? "code" : "value"), then: input.then ?? "remember" }
|
|
1414
|
+
});
|
|
1415
|
+
await this.prepare([step]);
|
|
1416
|
+
let got;
|
|
1417
|
+
try {
|
|
1418
|
+
got = await performMobileStep(mobile, step, this.mobileResolvers());
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
|
|
1421
|
+
}
|
|
1422
|
+
draft.steps.push(step);
|
|
1423
|
+
this.checkpoint();
|
|
1424
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got?.value ?? ""}" (${got?.detail ?? ""}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
|
|
1425
|
+
|
|
1426
|
+
${await this.view(false)}` : "");
|
|
1427
|
+
}
|
|
1428
|
+
/** The whole flow again from the app's start, on the same phone — a phone
|
|
1429
|
+
* holds one session at a time, so the app is closed and opened rather than
|
|
1430
|
+
* a second device started. */
|
|
1431
|
+
async mobileReplay() {
|
|
1432
|
+
const { mobile, draft } = this.requireMobile();
|
|
1433
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
1434
|
+
const resolvers = await this.prepare();
|
|
1435
|
+
const steps = [
|
|
1436
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
1437
|
+
...draft.preparation ?? [],
|
|
1438
|
+
...draft.steps
|
|
1439
|
+
];
|
|
1440
|
+
await mobile.terminateApp().catch(() => void 0);
|
|
1441
|
+
await mobile.launchApp();
|
|
1442
|
+
await mobile.settle();
|
|
1443
|
+
if (/^[a-z][\w+.-]*:\/\//i.test(draft.startPath)) await mobile.openLink(draft.startPath);
|
|
1444
|
+
const outcome = await runMobileSteps(mobile, steps, { values: /* @__PURE__ */ new Map(), ...resolvers, resolveSecret: (ref) => this.typed.get(ref) ?? resolvers.resolveSecret(ref) });
|
|
1445
|
+
this.lastTree = null;
|
|
1446
|
+
if (outcome.status === "passed") this.verified = signatureOf(draft.steps);
|
|
1447
|
+
const lines = outcome.results.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
|
|
1448
|
+
return `${outcome.status === "passed" ? "The whole flow passed from the app's start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
1449
|
+
${lines.join("\n")}`;
|
|
1450
|
+
}
|
|
1451
|
+
/** Let a click's consequences land before the page is read again. */
|
|
1452
|
+
async settle() {
|
|
1453
|
+
const page = this.page;
|
|
1454
|
+
if (!page) return;
|
|
1455
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
1456
|
+
await page.waitForTimeout(300);
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
function signatureOf(steps) {
|
|
1460
|
+
return JSON.stringify(steps.map((s) => [s.id, s.type, s.role, s.name, s.value, s.nth ?? null, s.proposals.map((p) => [p.kind, p.value, p.enabled])]));
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// src/mcp/tools.ts
|
|
1464
|
+
var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
|
|
1465
|
+
Every step you take is performed first and kept only if it worked, so what you
|
|
1466
|
+
save already ran once. Everything goes to ONE working branch per project that a
|
|
1467
|
+
person reviews and publishes in the Flowriki dashboard \u2014 nothing you do is
|
|
1468
|
+
published.
|
|
1469
|
+
|
|
1470
|
+
A new project \u2014 "set up Flowriki for this site, in this repository" \u2014 is made
|
|
1471
|
+
with create_project; it then becomes the project the other tools work on.
|
|
1472
|
+
|
|
1473
|
+
THE MODEL \u2014 get this right before recording anything:
|
|
1474
|
+
- A FLOW is a reusable building block: one piece of a journey ("Open the account
|
|
1475
|
+
dialog", "Fill the sign-up form", "Sign in with a wrong password"). A flow can
|
|
1476
|
+
START AFTER other flows (start_flow's "after"), so it holds only its own new
|
|
1477
|
+
steps and links to the ones before it. A path that behaves differently \u2014 a
|
|
1478
|
+
wrong password, a duplicate email \u2014 is its own flow, because the page does
|
|
1479
|
+
something different.
|
|
1480
|
+
- A flow records checks (add_check) that prove each thing happened, but they
|
|
1481
|
+
are SAVED SWITCHED OFF: which ones matter depends on the test.
|
|
1482
|
+
- Every kept step is written down as it is kept: if you are stopped, start_flow
|
|
1483
|
+
with the same name picks the flow up where it was (fresh: true starts over).
|
|
1484
|
+
save_flow replays the flow from the start first and refuses one that fails.
|
|
1485
|
+
- A TEST CASE is the test. It strings flows together as rows (in the same
|
|
1486
|
+
browser, or a new one for a second person) and switches on the checks it is
|
|
1487
|
+
about. Build it with preview_case, then save_case \u2014 which runs it first.
|
|
1488
|
+
- A test case can be WRITTEN BEFORE IT IS AUTOMATED: business steps (Given /
|
|
1489
|
+
When / Then) and no flows \u2014 imported from a spreadsheet, or written with
|
|
1490
|
+
write_test_cases. To automate one, record the flows its steps need, then
|
|
1491
|
+
save_case with the SAME name in the SAME folder: that fills it, and its
|
|
1492
|
+
business steps stay. Its Then lines are the checks to switch on.
|
|
1493
|
+
- SIGN-IN is shared setup: a flow or case that needs somebody signed in uses a
|
|
1494
|
+
sign-in profile from the catalog (start_flow's signIn), never typed login steps.
|
|
1495
|
+
None yet? Make it: record the login as a flow that ends with a check proving
|
|
1496
|
+
it worked (the account menu, a heading, the URL), then create_sign_in from it
|
|
1497
|
+
and create_account for each person who signs in (a customer, an admin). The
|
|
1498
|
+
login flow types into its fields; the account supplies the values.
|
|
1499
|
+
- HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
|
|
1500
|
+
with create_helper when the catalog has none that fits.
|
|
1501
|
+
- SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
|
|
1502
|
+
"Select & Customize") are told apart by POSITION, like an XPath index: read_page
|
|
1503
|
+
marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
|
|
1504
|
+
as soon as a name is shared \u2014 there is no need to look for another way round.
|
|
1505
|
+
- PASSWORDS AND KEYS never go into a flow or a reply. Typed into a password
|
|
1506
|
+
field, a value is kept as a secret and saved to the project's vault; given to
|
|
1507
|
+
create_account or create_helper, it is sealed there too and never shown back.
|
|
1508
|
+
Only use credentials the person gave you or that the app itself shows for
|
|
1509
|
+
testing (a demo login) \u2014 never guess or reuse anyone's real password.
|
|
1510
|
+
- FOLDERS do not nest. Name each one after the product area and what is in it,
|
|
1511
|
+
the way a tester would look for it \u2014 "Account \u2013 Sign up", "Account \u2013 Sign in",
|
|
1512
|
+
"Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
|
|
1513
|
+
cases built from them use the same names. Keep one area per folder.
|
|
1514
|
+
|
|
1515
|
+
KNOWLEDGE: project_brief is the project's memory \u2014 read it first. Search the
|
|
1516
|
+
knowledge pages with knowledge_search; read one with knowledge_read.
|
|
1517
|
+
product.md is "About the product": the person's own description of
|
|
1518
|
+
the product, in their words, as long as they like \u2014 read it first. Beside it,
|
|
1519
|
+
product/web.md, product/android.md and product/ios.md hold what is particular
|
|
1520
|
+
to that platform's app, when the project tests more than one. They
|
|
1521
|
+
write them, often with you in a conversation beside it; propose text there and
|
|
1522
|
+
let them decide, rather than writing product pages unless they ask you to.
|
|
1523
|
+
A linked codebase folder is learned into
|
|
1524
|
+
codebase/<folder>.md, which must start "# <folder>", then "Commit: <full sha>"
|
|
1525
|
+
and "Learned: <yyyy-mm-dd>" lines: that is how the Knowledge screen shows it
|
|
1526
|
+
as learned. codebase.json says which platform's code each folder holds (Web,
|
|
1527
|
+
Android, iOS, Mobile, All combined, Other): read the web folder's page for a
|
|
1528
|
+
website test and the phone folder's for an app test. explore/<platform>.md is
|
|
1529
|
+
what the assistant saw WALKING that platform \u2014 screens, the words on them,
|
|
1530
|
+
journeys, rules it saw refused, vocabulary: read it before writing a test for
|
|
1531
|
+
that platform, because it says what a person sees; the code does not. Keep what you learn with write_knowledge (features/<name>.md,
|
|
1532
|
+
memory/decisions.md, a session summary), on Claude's branch for review \u2014
|
|
1533
|
+
never a password, key or token.
|
|
1534
|
+
|
|
1535
|
+
UPDATING TEST CASES: when asked to update test cases (upd_\u2026), read the
|
|
1536
|
+
request with read_update_request, then propose_case_updates with each changed
|
|
1537
|
+
case's complete new steps \u2014 keep what the change does not touch word for
|
|
1538
|
+
word, so the person sees exactly what moved. Leave out cases that need no
|
|
1539
|
+
change. You never change a test case yourself; the person accepts.
|
|
1540
|
+
|
|
1541
|
+
CONVERTING A SPREADSHEET: when asked to convert an import (imp_\u2026), read
|
|
1542
|
+
every part with read_import_file, then save_import_cases in batches. Group
|
|
1543
|
+
cases into features by product area (a sheet or section usually is one). Each
|
|
1544
|
+
case is one scenario: name it after what it proves. Steps: preconditions and
|
|
1545
|
+
setup as Given; ONE action per When / And \u2014 split steps that do several
|
|
1546
|
+
things; each expected result as Then / And, after the action it follows.
|
|
1547
|
+
Keep every case and every step's meaning, and its id from the file in row \u2014
|
|
1548
|
+
that id is how a later import of the same file knows the case is already
|
|
1549
|
+
there; never invent a step or drop one. A case found in several sheets is
|
|
1550
|
+
converted once. Use the project's existing feature names (project_catalog)
|
|
1551
|
+
when a case belongs in one, rather than a new look-alike. Rows that are not test cases (titles,
|
|
1552
|
+
notes, empty) are left out. Say in note what you could not convert.
|
|
1553
|
+
|
|
1554
|
+
PHONE APPS (Android, iOS): a project can test its Android and iOS apps as well
|
|
1555
|
+
as, or instead of, a website \u2014 project_catalog says which, names the app, and
|
|
1556
|
+
lists the phones connected to this computer. start_flow with platform
|
|
1557
|
+
"android" or "ios" opens the app on a phone (device: a name from the catalog,
|
|
1558
|
+
"BrowserStack: Samsung Galaxy S23", or nothing for whatever is connected).
|
|
1559
|
+
read_page then reads the SCREEN as roles and names, just like a page, and act
|
|
1560
|
+
and add_check work the same way. On a phone: tap (= click) a button, fill a
|
|
1561
|
+
textbox, swipe up/down/left/right (value; on an element, or the screen), scroll
|
|
1562
|
+
to an element (role + name) or by direction, longPress, back (Android only),
|
|
1563
|
+
hideKeyboard, permission (value "allow" or "deny") for a system prompt, and
|
|
1564
|
+
launchApp / terminateApp / resetApp / background (value: seconds) / rotate
|
|
1565
|
+
(value: portrait or landscape). navigate opens a deep link. Never tap by
|
|
1566
|
+
position \u2014 use the role and name; [id=\u2026] on a line is the app's own id for it,
|
|
1567
|
+
and is used automatically. A phone app has no URL or page title: check a
|
|
1568
|
+
message (text on the screen), an element's text, or that something is visible.
|
|
1569
|
+
The app keeps its data between runs unless the project says otherwise, so a
|
|
1570
|
+
sign-in step is skipped when the app is already signed in. A test case can
|
|
1571
|
+
mix rows on a phone with rows in a browser (a customer orders in the app, an
|
|
1572
|
+
admin approves on the website).
|
|
1573
|
+
|
|
1574
|
+
HOW TO WORK:
|
|
1575
|
+
1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
|
|
1576
|
+
branch \u2014 rather than writing it again.
|
|
1577
|
+
2. Before recording, say the plan to the person: the folders, the building-block
|
|
1578
|
+
flows (and what each starts after), and the test cases (rows + what each
|
|
1579
|
+
checks). Then record the blocks, earliest first.
|
|
1580
|
+
3. Record with start_flow (pass "after" for the flows it continues from), read
|
|
1581
|
+
the page with read_page, and act using the role and name EXACTLY as the page
|
|
1582
|
+
shows them. If a name appears more than once, pass "within" with the name of
|
|
1583
|
+
the region, dialog or form \u2014 or "dialog" / "navigation".
|
|
1584
|
+
4. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
1585
|
+
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
1586
|
+
catalog, then type {{its saveAs name}} into the field that needs it.
|
|
1587
|
+
5. Add checks for what each step proves (a heading, a message, a value, the
|
|
1588
|
+
URL). replay_flow, then save_flow. A flow must record at least one check.
|
|
1589
|
+
6. THE PERSON THINKS IN TEST CASES; flows are how you make one runnable.
|
|
1590
|
+
Before recording, start_case with the case's name, folder and Given /
|
|
1591
|
+
When / Then lines (its own, from read_test_cases, when it has them). Then
|
|
1592
|
+
on EVERY act, add_check and use_helper pass line: the number of the line
|
|
1593
|
+
that step carries out \u2014 the person watches each line fill with its steps.
|
|
1594
|
+
preview_case lists the case's rows and every step, numbered; save_case
|
|
1595
|
+
with the checks to switch on runs the whole case, saves it only if it
|
|
1596
|
+
passed, and ties each line to its steps. Give the person the review link.
|
|
1597
|
+
7. Saving the same name again replaces it on the working branch. ONE VERSION
|
|
1598
|
+
of each scenario per platform: redo a flow or case under its existing name
|
|
1599
|
+
(project_catalog lists them) rather than saving another beside it. A test's
|
|
1600
|
+
PLATFORM IS ITS LABEL, not words in its name: name flows and cases for what
|
|
1601
|
+
they do ("Sign up with valid credentials"), give start_flow its platform
|
|
1602
|
+
and preview_case / save_case theirs \u2014 the same name on two platforms is
|
|
1603
|
+
two tests, told apart by the label. Old branches
|
|
1604
|
+
of yours? gather_branches puts them into one; discard_branch deletes one \u2014
|
|
1605
|
+
only after the person asks or agrees.
|
|
1606
|
+
Test data you invent (a new user's email or password for a sign-up) is fine to
|
|
1607
|
+
type. For anything that must be NEW on every run \u2014 a sign-up's email or
|
|
1608
|
+
username \u2014 use {{unique}}: it is different on every run and the same within
|
|
1609
|
+
one, e.g. qa+{{unique}}@ the domain of the project's mailbox helper, so the
|
|
1610
|
+
helper can read that address's verification email with {"email": "qa+{{unique}}@\u2026"}.
|
|
1611
|
+
A test that types a fixed new email passes once and fails every run after.`;
|
|
1612
|
+
var CASE_INPUT = {
|
|
1613
|
+
type: "object",
|
|
1614
|
+
properties: {
|
|
1615
|
+
name: { type: "string", description: 'The test case: the scenario it proves, e.g. "Wrong password is refused".' },
|
|
1616
|
+
folder: { type: "string", description: 'The case folder \u2014 area and topic, e.g. "Account \u2013 Sign in". Made if new.' },
|
|
1617
|
+
description: { type: "string" },
|
|
1618
|
+
rows: {
|
|
1619
|
+
type: "array",
|
|
1620
|
+
description: "The flows to run, in order. Each row runs in the same browser as the row above unless newBrowser is true (a second person).",
|
|
1621
|
+
items: {
|
|
1622
|
+
type: "object",
|
|
1623
|
+
properties: {
|
|
1624
|
+
flow: { type: "string", description: "A flow's name or id from project_catalog." },
|
|
1625
|
+
newBrowser: { type: "boolean" },
|
|
1626
|
+
checks: {
|
|
1627
|
+
description: `Which of this row's checks to switch on: their numbers from preview_case, or "all". The rest stay off.`,
|
|
1628
|
+
anyOf: [{ type: "array", items: { type: "number" } }, { type: "string", enum: ["all"] }]
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
required: ["flow"],
|
|
1632
|
+
additionalProperties: false
|
|
1633
|
+
}
|
|
1634
|
+
},
|
|
1635
|
+
businessSteps: {
|
|
1636
|
+
type: "array",
|
|
1637
|
+
description: 'The case in Given / When / Then lines, in order, each with the step numbers from preview_case that carry it out \u2014 e.g. {"step": "Given the user is on the sign-in page", "steps": [1, 2]}. A case that already has business steps keeps its own words: give the same lines.',
|
|
1638
|
+
items: {
|
|
1639
|
+
type: "object",
|
|
1640
|
+
properties: {
|
|
1641
|
+
step: { type: "string", description: "One line, starting Given, When, Then, And or But." },
|
|
1642
|
+
steps: { type: "array", items: { type: "number" }, description: "The step numbers (across the case) that carry it out." }
|
|
1643
|
+
},
|
|
1644
|
+
required: ["step"],
|
|
1645
|
+
additionalProperties: false
|
|
1646
|
+
}
|
|
1647
|
+
},
|
|
1648
|
+
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." },
|
|
1649
|
+
branch: { type: "string", description: "Optional: save onto this branch (chg_\u2026) instead of the working one." },
|
|
1650
|
+
projectId: { type: "string" }
|
|
1651
|
+
},
|
|
1652
|
+
required: ["name", "folder", "rows"],
|
|
1653
|
+
additionalProperties: false
|
|
1654
|
+
};
|
|
1655
|
+
var caseSpec = (args) => ({
|
|
1656
|
+
name: String(args["name"] ?? ""),
|
|
1657
|
+
folder: String(args["folder"] ?? ""),
|
|
1658
|
+
...args["description"] ? { description: String(args["description"]) } : {},
|
|
1659
|
+
rows: Array.isArray(args["rows"]) ? args["rows"] : [],
|
|
1660
|
+
...Array.isArray(args["businessSteps"]) ? { businessSteps: args["businessSteps"] } : {},
|
|
1661
|
+
...args["branch"] ? { changeId: String(args["branch"]) } : {},
|
|
1662
|
+
...args["platform"] === "web" || args["platform"] === "android" || args["platform"] === "ios" ? { platform: args["platform"] } : {}
|
|
1663
|
+
});
|
|
1664
|
+
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
1665
|
+
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
1666
|
+
function authoringTools(service, session) {
|
|
1667
|
+
let defaultProject = null;
|
|
1668
|
+
const catalogs = /* @__PURE__ */ new Map();
|
|
1669
|
+
const projectFor = async (asked) => {
|
|
1670
|
+
const named = str(asked);
|
|
1671
|
+
if (named) return defaultProject = named;
|
|
1672
|
+
if (session.draft) return session.draft.projectId;
|
|
1673
|
+
if (defaultProject) return defaultProject;
|
|
1674
|
+
const { projects } = await service.projects();
|
|
1675
|
+
if (projects.length === 1) return defaultProject = projects[0].id;
|
|
1676
|
+
if (projects.length === 0) throw new Error("this account is not on any Flowriki project yet \u2014 create one in the dashboard");
|
|
1677
|
+
throw new Error(`which project? Pass projectId \u2014 one of: ${projects.map((p) => `${p.name} (${p.id})`).join(", ")}`);
|
|
1678
|
+
};
|
|
1679
|
+
const catalogOf = async (projectId, fresh = false) => {
|
|
1680
|
+
if (!fresh && catalogs.has(projectId)) return catalogs.get(projectId);
|
|
1681
|
+
const catalog = await service.catalog(projectId);
|
|
1682
|
+
catalogs.set(projectId, catalog);
|
|
1683
|
+
return catalog;
|
|
1684
|
+
};
|
|
1685
|
+
const tagged = async (args, run) => {
|
|
1686
|
+
const before = session.keptCount();
|
|
1687
|
+
const out = await run();
|
|
1688
|
+
const line = Number(args["line"]);
|
|
1689
|
+
return text(Number.isInteger(line) && line > 0 ? out + session.tagSince(before, line) : out);
|
|
1690
|
+
};
|
|
1691
|
+
const withLines = (spec) => {
|
|
1692
|
+
if (spec.businessSteps?.length) return spec;
|
|
1693
|
+
const lines = session.caseLinesFor(spec.name);
|
|
1694
|
+
return lines ? { ...spec, businessSteps: lines } : spec;
|
|
1695
|
+
};
|
|
1696
|
+
const tools = [
|
|
1697
|
+
{
|
|
1698
|
+
name: "list_projects",
|
|
1699
|
+
title: "List Flowriki projects",
|
|
1700
|
+
description: "The Flowriki projects you can write tests for, with your role on each. Only needed when you are on more than one.",
|
|
1701
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1702
|
+
annotations: { readOnlyHint: true },
|
|
1703
|
+
run: async () => {
|
|
1704
|
+
const { projects } = await service.projects();
|
|
1705
|
+
return text(projects.length ? projects.map((p) => `${p.name} \u2014 id ${p.id}, you are ${p.role}`).join("\n") : "You are not on any project yet.");
|
|
1706
|
+
}
|
|
1707
|
+
},
|
|
1708
|
+
{
|
|
1709
|
+
name: "create_project",
|
|
1710
|
+
title: "Create a Flowriki project",
|
|
1711
|
+
description: "Create a new Flowriki project, connected to a git repository where its tests will be stored, and pointed at the app to test. The repository must already exist and must not hold another Flowriki project. You become its owner.",
|
|
1712
|
+
inputSchema: {
|
|
1713
|
+
type: "object",
|
|
1714
|
+
properties: {
|
|
1715
|
+
name: { type: "string", description: `The project's name, e.g. "Pizza Romano".` },
|
|
1716
|
+
repository: { type: "string", description: "owner/name, or the repository's address, e.g. https://github.com/acme/shop-tests.git" },
|
|
1717
|
+
baseUrl: { type: "string", description: "The app's address, e.g. https://shop.example.com/" }
|
|
1718
|
+
},
|
|
1719
|
+
required: ["name", "repository"],
|
|
1720
|
+
additionalProperties: false
|
|
1721
|
+
},
|
|
1722
|
+
run: async (args) => {
|
|
1723
|
+
const made = await service.createProject(str(args["name"]) ?? "", str(args["repository"]) ?? "", str(args["baseUrl"]));
|
|
1724
|
+
defaultProject = made.id;
|
|
1725
|
+
catalogs.delete(made.id);
|
|
1726
|
+
return text(`Created "${made.name}" (id ${made.id}), stored in ${made.repository}${made.baseUrl ? `, testing ${made.baseUrl}` : " \u2014 no base URL yet; set one in its settings"}.${made.note ? `
|
|
1727
|
+
Note: ${made.note}` : ""}
|
|
1728
|
+
It is now the project these tools work on. Open it: ${service.origin}/projects/${made.id}`);
|
|
1729
|
+
}
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
name: "project_catalog",
|
|
1733
|
+
title: "What a test can be built from",
|
|
1734
|
+
description: "The project's base URL, features (folders for flows), existing flows, sign-in profiles with their accounts, and helpers (email codes, 2FA, SMS, API calls). Read this before starting a flow.",
|
|
1735
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Optional when you are on one project." } }, additionalProperties: false },
|
|
1736
|
+
annotations: { readOnlyHint: true },
|
|
1737
|
+
run: async (args) => {
|
|
1738
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1739
|
+
const c = await catalogOf(projectId, true);
|
|
1740
|
+
const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
|
|
1741
|
+
const platforms = platformsOf(c);
|
|
1742
|
+
const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
|
|
1743
|
+
const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
|
|
1744
|
+
const app = p === "android" ? c.mobile?.android?.appPackage : c.mobile?.ios?.bundleId;
|
|
1745
|
+
const here = phones.filter((d) => d.platform === p);
|
|
1746
|
+
const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
|
|
1747
|
+
const chosen = c.mobile?.devices[p];
|
|
1748
|
+
return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not named \u2014 Settings \u2192 Environments)"}
|
|
1749
|
+
Phones on this computer: ${here.length ? here.map((d) => `${d.name}${d.state === "ready" ? "" : ` (${d.state})`}`).join(", ") : "none"}` + (farms.length ? `
|
|
1750
|
+
Cloud device farms: ${farms.join(", ")}` : "") + `
|
|
1751
|
+
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}`}` : "");
|
|
1752
|
+
});
|
|
1753
|
+
return text([
|
|
1754
|
+
`Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
|
|
1755
|
+
platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}${c.runWhere?.web === "cloud" ? " \u2014 test runs use a cloud browser; recording is on this computer" : ""}` : "",
|
|
1756
|
+
...phoneLines,
|
|
1757
|
+
c.environments.length ? `Environments: ${c.environments.map((e) => `${e.name} ${e.baseUrl}`).join(", ")}` : "",
|
|
1758
|
+
`
|
|
1759
|
+
Features:${c.features.length ? "" : " none yet"}`,
|
|
1760
|
+
...c.features.map((f) => ` - ${f.name} (id ${f.id})${f.description ? ` \u2014 ${f.description}` : ""}
|
|
1761
|
+
${flowsBy(f.id)}`),
|
|
1762
|
+
...c.draft ? [
|
|
1763
|
+
`
|
|
1764
|
+
On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet published:`,
|
|
1765
|
+
...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
|
|
1766
|
+
...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)"],
|
|
1767
|
+
...c.draft.caseFolders?.length ? [` New case folders: ${c.draft.caseFolders.map((f) => f.name).join(", ")}`] : [],
|
|
1768
|
+
...(c.draft.cases ?? []).map((t) => ` - case: ${t.name} (folder ${t.folderId}) \u2014 rows: ${t.rows.join(" \u2192 ")}`)
|
|
1769
|
+
] : [],
|
|
1770
|
+
`
|
|
1771
|
+
Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
|
|
1772
|
+
...(c.caseFolders ?? []).map((f) => ` - ${f.name} (id ${f.id})
|
|
1773
|
+
${(c.cases ?? []).filter((t) => t.folderId === f.id).map((t) => ` - ${t.name} \u2014 rows: ${t.rows.join(" \u2192 ")}`).join("\n")}`),
|
|
1774
|
+
`
|
|
1775
|
+
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
1776
|
+
...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"}`),
|
|
1777
|
+
`
|
|
1778
|
+
Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
1779
|
+
...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}` : ""}`)
|
|
1780
|
+
].filter(Boolean).join("\n"));
|
|
1781
|
+
}
|
|
1782
|
+
},
|
|
1783
|
+
{
|
|
1784
|
+
name: "start_case",
|
|
1785
|
+
title: "Start building a test case",
|
|
1786
|
+
description: "Say which test case you are building, before recording what it needs: its name, its folder and its Given / When / Then lines (the case's own, from read_test_cases, when it already has them). The person watches it fill in: every step you then keep with act, add_check or use_helper \u2014 passing line, the number of the line it carries out \u2014 shows under that line at once. save_case then ties them on the saved case.",
|
|
1787
|
+
inputSchema: {
|
|
1788
|
+
type: "object",
|
|
1789
|
+
properties: {
|
|
1790
|
+
name: { type: "string", description: "The test case: the scenario it proves." },
|
|
1791
|
+
folder: { type: "string", description: 'Its folder \u2014 area and topic, e.g. "Account \u2013 Sign up".' },
|
|
1792
|
+
steps: { type: "array", items: { type: "string" }, description: "Its Given / When / Then lines, in order; And / But continue the line above." },
|
|
1793
|
+
projectId: { type: "string" }
|
|
1794
|
+
},
|
|
1795
|
+
required: ["name", "folder", "steps"],
|
|
1796
|
+
additionalProperties: false
|
|
1797
|
+
},
|
|
1798
|
+
run: async (args) => {
|
|
1799
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1800
|
+
const lines = parseGherkin((Array.isArray(args["steps"]) ? args["steps"].map(String) : []).join("\n"));
|
|
1801
|
+
if (!lines?.length) throw new Error("the steps must each start with Given, When, Then, And or But");
|
|
1802
|
+
return text(session.startCase(projectId, str(args["name"]) ?? "", str(args["folder"]) ?? "", lines));
|
|
1803
|
+
}
|
|
1804
|
+
},
|
|
1805
|
+
{
|
|
1806
|
+
name: "start_flow",
|
|
1807
|
+
title: "Start writing a flow",
|
|
1808
|
+
description: "Open a browser on this computer at the project's app \u2014 or, with platform android or ios, the project's phone app on a phone \u2014 and start a new flow. Returns the page or screen. Closes any flow that was being written.",
|
|
1809
|
+
inputSchema: {
|
|
1810
|
+
type: "object",
|
|
1811
|
+
properties: {
|
|
1812
|
+
name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
|
|
1813
|
+
feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
|
|
1814
|
+
startPath: { type: "string", description: `Where it starts, relative to the base URL. Default "/". On a phone: a deep link to open, or leave out for the app's first screen.` },
|
|
1815
|
+
platform: { type: "string", enum: ["web", "android", "ios"], description: "Where the flow runs. Default: web, or the project's only platform." },
|
|
1816
|
+
device: { type: "string", description: `Phones only: a phone from project_catalog by name, or a cloud farm's as "BrowserStack: Samsung Galaxy S23". Default: the project's choice, then whatever is connected.` },
|
|
1817
|
+
after: {
|
|
1818
|
+
type: "array",
|
|
1819
|
+
items: { type: "string" },
|
|
1820
|
+
description: "Flows this one starts after (names or ids from project_catalog). They are replayed first, so recording begins where they end; what THEY start after is added too. The saved flow links to them."
|
|
1821
|
+
},
|
|
1822
|
+
signIn: {
|
|
1823
|
+
type: "object",
|
|
1824
|
+
description: "Start signed in, with a profile (and optionally an account) from project_catalog.",
|
|
1825
|
+
properties: { profileId: { type: "string" }, accountId: { type: "string" } },
|
|
1826
|
+
required: ["profileId"],
|
|
1827
|
+
additionalProperties: false
|
|
1828
|
+
},
|
|
1829
|
+
fresh: { type: "boolean", description: "Start over, ignoring a draft of this flow left by an earlier attempt. By default the earlier steps are replayed and kept, and recording carries on from there." },
|
|
1830
|
+
projectId: { type: "string" }
|
|
1831
|
+
},
|
|
1832
|
+
required: ["name", "feature"],
|
|
1833
|
+
additionalProperties: false
|
|
1834
|
+
},
|
|
1835
|
+
annotations: { openWorldHint: true },
|
|
1836
|
+
run: async (args) => {
|
|
1837
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1838
|
+
const catalog = await catalogOf(projectId, true);
|
|
1839
|
+
const featureAsked = str(args["feature"]) ?? "";
|
|
1840
|
+
const feature = catalog.features.find((f) => f.id === featureAsked || f.name.toLowerCase() === featureAsked.toLowerCase());
|
|
1841
|
+
const signIn = args["signIn"];
|
|
1842
|
+
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
1843
|
+
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
1844
|
+
}
|
|
1845
|
+
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1846
|
+
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1847
|
+
const platforms = platformsOf(catalog);
|
|
1848
|
+
const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
|
|
1849
|
+
const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
|
|
1850
|
+
if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
|
|
1851
|
+
if (!platforms[platform]) throw new Error(`this project does not test ${platform === "web" ? "a website" : `an ${platform === "ios" ? "iOS" : "Android"} app`} \u2014 turn it on in Settings first`);
|
|
1852
|
+
const device = isMobile(platform) ? deviceFrom(str(args["device"]), platform, await devicesSummary(), catalog.mobile) : void 0;
|
|
1853
|
+
const page = await session.start({
|
|
1854
|
+
projectId,
|
|
1855
|
+
...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
|
|
1856
|
+
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1857
|
+
name: str(args["name"]) ?? "Untitled flow",
|
|
1858
|
+
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1859
|
+
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1860
|
+
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1861
|
+
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
|
|
1862
|
+
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1863
|
+
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."}
|
|
1864
|
+
|
|
1865
|
+
${page}`);
|
|
1866
|
+
}
|
|
1867
|
+
},
|
|
1868
|
+
{
|
|
1869
|
+
name: "read_page",
|
|
1870
|
+
title: "Read the page",
|
|
1871
|
+
description: "The current page \u2014 or phone screen \u2014 as its accessibility tree: each element's role and name, which is exactly what act and add_check refer to.",
|
|
1872
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1873
|
+
annotations: { readOnlyHint: true },
|
|
1874
|
+
run: async () => text(await session.snapshot())
|
|
1875
|
+
},
|
|
1876
|
+
{
|
|
1877
|
+
name: "act",
|
|
1878
|
+
title: "Do one thing on the page",
|
|
1879
|
+
description: `Perform one step and keep it in the flow if it worked. Returns the page afterwards. click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value (on a phone, a deep link). back/forward: nothing. PHONE ONLY: tap (role + name), longPress (role + name), swipe (value up/down/left/right, optionally on role + name), scroll (to role + name, or value up/down), hideKeyboard, permission (value allow/deny), launchApp, terminateApp, resetApp, background (value: seconds), rotate (value portrait/landscape).`,
|
|
1880
|
+
inputSchema: {
|
|
1881
|
+
type: "object",
|
|
1882
|
+
properties: {
|
|
1883
|
+
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"] },
|
|
1884
|
+
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
|
|
1885
|
+
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1886
|
+
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
1887
|
+
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." },
|
|
1888
|
+
value: { type: "string" },
|
|
1889
|
+
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." },
|
|
1890
|
+
line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
|
|
1891
|
+
},
|
|
1892
|
+
required: ["action"],
|
|
1893
|
+
additionalProperties: false
|
|
1894
|
+
},
|
|
1895
|
+
run: async (args) => {
|
|
1896
|
+
if (!isAction(args["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward \u2014 or on a phone tap, longPress, swipe, scroll, hideKeyboard, permission, launchApp, terminateApp, resetApp, background, rotate");
|
|
1897
|
+
const action = args["action"];
|
|
1898
|
+
return tagged(args, async () => session.act({
|
|
1899
|
+
action,
|
|
1900
|
+
...str(args["role"]) ? { role: str(args["role"]) } : {},
|
|
1901
|
+
...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
|
|
1902
|
+
...str(args["within"]) ? { within: str(args["within"]) } : {},
|
|
1903
|
+
...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {},
|
|
1904
|
+
...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {},
|
|
1905
|
+
...typeof args["secret"] === "boolean" ? { secret: args["secret"] } : {}
|
|
1906
|
+
}));
|
|
1907
|
+
}
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
name: "add_check",
|
|
1911
|
+
title: "Add a check",
|
|
1912
|
+
description: "Assert something about the page (or phone screen) as it is now, and keep it in the flow if it holds. Page checks need only kind + value: heading, url (a path), title, message (text shown anywhere \u2014 on a phone, the way to check a screen). Element checks also need role + name: text, value, visible, hidden, checked, count. op: equals (default), contains or matches.",
|
|
1913
|
+
inputSchema: {
|
|
1914
|
+
type: "object",
|
|
1915
|
+
properties: {
|
|
1916
|
+
kind: { type: "string", enum: ["heading", "url", "title", "message", "screen", "text", "value", "visible", "hidden", "checked", "enabled", "count"] },
|
|
1917
|
+
value: { type: "string", description: `What it should be: the heading's words, the path, "true" for a state, a number for count.` },
|
|
1918
|
+
op: { type: "string", enum: ["equals", "contains", "matches"] },
|
|
1919
|
+
role: { type: "string" },
|
|
1920
|
+
name: { type: "string" },
|
|
1921
|
+
within: { type: "string" },
|
|
1922
|
+
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])." },
|
|
1923
|
+
line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
|
|
1924
|
+
},
|
|
1925
|
+
required: ["kind", "value"],
|
|
1926
|
+
additionalProperties: false
|
|
1927
|
+
},
|
|
1928
|
+
run: async (args) => tagged(args, async () => session.check({
|
|
1929
|
+
kind: str(args["kind"]),
|
|
1930
|
+
value: str(args["value"]) ?? "",
|
|
1931
|
+
...str(args["op"]) ? { op: str(args["op"]) } : {},
|
|
1932
|
+
...str(args["role"]) ? { role: str(args["role"]), name: str(args["name"]) ?? "" } : {},
|
|
1933
|
+
...str(args["within"]) ? { within: str(args["within"]) } : {},
|
|
1934
|
+
...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {}
|
|
1935
|
+
}))
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
name: "use_helper",
|
|
1939
|
+
title: "Use a helper",
|
|
1940
|
+
description: `Get a value from outside the page with one of the project's helpers \u2014 the code or link from a verification email, an authenticator code, an SMS code, a field of an API reply \u2014 and keep the step. Later steps use it as {{saveAs}}. then: "open" goes to the value as a link (for verification links).`,
|
|
1941
|
+
inputSchema: {
|
|
1942
|
+
type: "object",
|
|
1943
|
+
properties: {
|
|
1944
|
+
helperId: { type: "string", description: "The helper's id or name from project_catalog." },
|
|
1945
|
+
inputs: { type: "object", additionalProperties: { type: "string" }, description: `Values for the helper's {{names}}, e.g. {"email": "qa+signup1@example.com"}.` },
|
|
1946
|
+
saveAs: { type: "string", description: "What to call the value, e.g. code. Default: code for a mailbox, value otherwise." },
|
|
1947
|
+
then: { type: "string", enum: ["remember", "open"] },
|
|
1948
|
+
line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
|
|
1949
|
+
},
|
|
1950
|
+
required: ["helperId"],
|
|
1951
|
+
additionalProperties: false
|
|
1952
|
+
},
|
|
1953
|
+
run: async (args) => tagged(args, async () => session.useHelper({
|
|
1954
|
+
helperId: str(args["helperId"]),
|
|
1955
|
+
...args["inputs"] && typeof args["inputs"] === "object" ? { inputs: Object.fromEntries(Object.entries(args["inputs"]).map(([k, v]) => [k, String(v)])) } : {},
|
|
1956
|
+
...str(args["saveAs"]) ? { saveAs: str(args["saveAs"]) } : {},
|
|
1957
|
+
...args["then"] === "open" ? { then: "open" } : {}
|
|
1958
|
+
}))
|
|
1959
|
+
},
|
|
1960
|
+
{
|
|
1961
|
+
name: "list_steps",
|
|
1962
|
+
title: "List the flow's steps",
|
|
1963
|
+
description: "The flow being written, step by step.",
|
|
1964
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1965
|
+
annotations: { readOnlyHint: true },
|
|
1966
|
+
run: async () => text(session.list())
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
name: "remove_step",
|
|
1970
|
+
title: "Remove a step",
|
|
1971
|
+
description: "Remove one step by its number in list_steps. The browser is not wound back; use replay_flow to see the flow as it now stands.",
|
|
1972
|
+
inputSchema: { type: "object", properties: { number: { type: "integer", minimum: 1 } }, required: ["number"], additionalProperties: false },
|
|
1973
|
+
annotations: { destructiveHint: true },
|
|
1974
|
+
run: async (args) => text(session.remove(Number(args["number"])))
|
|
1975
|
+
},
|
|
1976
|
+
{
|
|
1977
|
+
name: "replay_flow",
|
|
1978
|
+
title: "Replay the flow from the start",
|
|
1979
|
+
description: "Run the whole flow from the beginning in a fresh browser, exactly as a test run will, and report each step. Do this before saving.",
|
|
1980
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1981
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1982
|
+
run: async () => text(await session.replay())
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
name: "save_flow",
|
|
1986
|
+
title: "Save the flow to a branch",
|
|
1987
|
+
description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. The flow is replayed from the start first, as a test run will run it, unless the same steps already passed replay_flow; one that fails is not saved and the failing step is named. Returns the review link. Refused if the flow checks nothing.",
|
|
1988
|
+
inputSchema: {
|
|
1989
|
+
type: "object",
|
|
1990
|
+
properties: {
|
|
1991
|
+
title: { type: "string", description: "The branch's title, used only when a new branch is started." },
|
|
1992
|
+
branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
|
|
1993
|
+
newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." },
|
|
1994
|
+
force: { type: "boolean", description: "Save even though the replay from the start failed \u2014 only when the person asked for it as it is." }
|
|
1995
|
+
},
|
|
1996
|
+
additionalProperties: false
|
|
1997
|
+
},
|
|
1998
|
+
run: async (args) => text(await session.save(str(args["title"]), {
|
|
1999
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
|
|
2000
|
+
...args["newBranch"] === true ? { newBranch: true } : {}
|
|
2001
|
+
}, { ...args["force"] === true ? { force: true } : {} }))
|
|
2002
|
+
},
|
|
2003
|
+
{
|
|
2004
|
+
name: "list_my_branches",
|
|
2005
|
+
title: "List Claude's open branches",
|
|
2006
|
+
description: "The project's open, unpublished branches that Claude wrote for this person, newest first, with review links. Use it to find a superseded branch to discard.",
|
|
2007
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Optional when you are on one project." } }, additionalProperties: false },
|
|
2008
|
+
annotations: { readOnlyHint: true },
|
|
2009
|
+
run: async (args) => {
|
|
2010
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2011
|
+
const { changes } = await service.agentChanges(projectId);
|
|
2012
|
+
if (!changes.length) return text("Claude has no open branches on this project.");
|
|
2013
|
+
return text(changes.map((c) => `- ${c.id} \u2014 "${c.title}" (${new Date(c.createdAt).toISOString().slice(0, 16).replace("T", " ")}) ${service.origin}${c.path}`).join("\n"));
|
|
2014
|
+
}
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
name: "gather_branches",
|
|
2018
|
+
title: "Gather Claude's branches into one",
|
|
2019
|
+
description: "Move the flows from Claude's open branches on this project into one branch, and delete the emptied ones \u2014 so the person reviews and publishes once. Only Claude's own, unedited branches; anything a person touched stops it.",
|
|
2020
|
+
inputSchema: {
|
|
2021
|
+
type: "object",
|
|
2022
|
+
properties: {
|
|
2023
|
+
into: { type: "string", description: "The branch to gather into (chg_\u2026). Default: Claude's oldest open branch." },
|
|
2024
|
+
title: { type: "string", description: 'A title for the gathered branch, e.g. "Sign in and sign up tests".' },
|
|
2025
|
+
projectId: { type: "string" }
|
|
2026
|
+
},
|
|
2027
|
+
additionalProperties: false
|
|
2028
|
+
},
|
|
2029
|
+
annotations: { destructiveHint: true },
|
|
2030
|
+
run: async (args) => {
|
|
2031
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2032
|
+
const done = await service.gatherChanges(projectId, {
|
|
2033
|
+
...str(args["into"]) ? { into: str(args["into"]) } : {},
|
|
2034
|
+
...str(args["title"]) ? { title: str(args["title"]) } : {}
|
|
2035
|
+
});
|
|
2036
|
+
catalogs.delete(projectId);
|
|
2037
|
+
return text(`Gathered ${done.gathered.length} branch${done.gathered.length === 1 ? "" : "es"} into "${done.title}" (${done.changeId}). Review and publish it here: ${service.origin}${done.path}`);
|
|
2038
|
+
}
|
|
2039
|
+
},
|
|
2040
|
+
{
|
|
2041
|
+
name: "discard_branch",
|
|
2042
|
+
title: "Delete one of Claude's branches",
|
|
2043
|
+
description: "Delete an open branch Claude wrote, with the flows on it \u2014 e.g. one a redo replaced. Only after the person asked for it or agreed. Refused for a branch a person started, edited, or published; those are deleted in the dashboard.",
|
|
2044
|
+
inputSchema: {
|
|
2045
|
+
type: "object",
|
|
2046
|
+
properties: { changeId: { type: "string", description: "The branch's id from list_my_branches (chg_\u2026)." }, projectId: { type: "string" } },
|
|
2047
|
+
required: ["changeId"],
|
|
2048
|
+
additionalProperties: false
|
|
2049
|
+
},
|
|
2050
|
+
annotations: { destructiveHint: true },
|
|
2051
|
+
run: async (args) => {
|
|
2052
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2053
|
+
const changeId = str(args["changeId"]) ?? "";
|
|
2054
|
+
const { deleted } = await service.discardChange(projectId, changeId);
|
|
2055
|
+
return text(`Deleted ${changeId} and its branch ${deleted}.`);
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
{
|
|
2059
|
+
name: "preview_case",
|
|
2060
|
+
title: "Preview a test case",
|
|
2061
|
+
description: "Build a test case from flows without saving or running it: its rows (each flow with what it starts after folded in, and which browser it runs in) and every check, numbered, with which are on. Use the numbers in save_case.",
|
|
2062
|
+
inputSchema: CASE_INPUT,
|
|
2063
|
+
annotations: { readOnlyHint: true },
|
|
2064
|
+
run: async (args) => {
|
|
2065
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2066
|
+
return text(await session.previewCase(projectId, withLines(caseSpec(args))));
|
|
2067
|
+
}
|
|
2068
|
+
},
|
|
2069
|
+
{
|
|
2070
|
+
name: "save_case",
|
|
2071
|
+
title: "Run and save a test case",
|
|
2072
|
+
description: "Build the test case, run it from the start in a fresh browser exactly as a test run will, and \u2014 only if every row passed \u2014 save it onto Claude's working branch. Saving the same name in the same folder again replaces it. Refused if it switches on no checks.",
|
|
2073
|
+
inputSchema: CASE_INPUT,
|
|
2074
|
+
annotations: { openWorldHint: true },
|
|
2075
|
+
run: async (args) => {
|
|
2076
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2077
|
+
const out = await session.saveCase(projectId, withLines(caseSpec(args)), await catalogOf(projectId, true));
|
|
2078
|
+
catalogs.delete(projectId);
|
|
2079
|
+
return text(out);
|
|
2080
|
+
}
|
|
2081
|
+
},
|
|
2082
|
+
{
|
|
2083
|
+
name: "read_import_file",
|
|
2084
|
+
title: "Read a spreadsheet waiting to be converted",
|
|
2085
|
+
description: "Read one part of a spreadsheet the person is importing (its id is imp_\u2026): every sheet's rows, each as a JSON array of its cells, numbered as the spreadsheet numbers them. Part 1 says how many parts there are; read every one.",
|
|
2086
|
+
inputSchema: {
|
|
2087
|
+
type: "object",
|
|
2088
|
+
properties: { projectId: { type: "string" }, file: { type: "string", description: "The import's id, imp_\u2026" }, part: { type: "number", description: "Which part, from 1." } },
|
|
2089
|
+
required: ["file", "part"],
|
|
2090
|
+
additionalProperties: false
|
|
2091
|
+
},
|
|
2092
|
+
annotations: { readOnlyHint: true },
|
|
2093
|
+
run: async (args) => {
|
|
2094
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2095
|
+
const got = await service.importPart(projectId, str(args["file"]) ?? "", Number(args["part"] ?? 1));
|
|
2096
|
+
return text(`${got.file} \u2014 part ${got.part} of ${got.of}${got.part < got.of ? " (read the rest too)" : " (the last)"}
|
|
2097
|
+
|
|
2098
|
+
${got.text}`);
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
name: "save_import_cases",
|
|
2103
|
+
title: "Save the test cases a spreadsheet becomes",
|
|
2104
|
+
description: "Save test cases converted from a spreadsheet being imported (its id is imp_\u2026), in batches as you go: features, each with cases \u2014 name (the scenario it proves), steps as Given / When / Then / And lines, row (its id or row in the file), description (optional). They are shown to the person to import; nothing is written to the project until they do. done: true with the last batch; note: anything you could not convert, and why. restart: true replaces what was saved before.",
|
|
2105
|
+
inputSchema: {
|
|
2106
|
+
type: "object",
|
|
2107
|
+
properties: {
|
|
2108
|
+
projectId: { type: "string" },
|
|
2109
|
+
file: { type: "string", description: "The import's id, imp_\u2026" },
|
|
2110
|
+
features: {
|
|
2111
|
+
type: "array",
|
|
2112
|
+
items: {
|
|
2113
|
+
type: "object",
|
|
2114
|
+
properties: {
|
|
2115
|
+
feature: { type: "string" },
|
|
2116
|
+
cases: {
|
|
2117
|
+
type: "array",
|
|
2118
|
+
items: {
|
|
2119
|
+
type: "object",
|
|
2120
|
+
properties: { name: { type: "string" }, description: { type: "string" }, steps: { type: "array", items: { type: "string" } }, row: { type: "string" } },
|
|
2121
|
+
required: ["name", "steps"],
|
|
2122
|
+
additionalProperties: false
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
},
|
|
2126
|
+
required: ["feature", "cases"],
|
|
2127
|
+
additionalProperties: false
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
done: { type: "boolean" },
|
|
2131
|
+
note: { type: "string" },
|
|
2132
|
+
restart: { type: "boolean" }
|
|
2133
|
+
},
|
|
2134
|
+
required: ["file", "features"],
|
|
2135
|
+
additionalProperties: false
|
|
2136
|
+
},
|
|
2137
|
+
run: async (args) => {
|
|
2138
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2139
|
+
const features = Array.isArray(args["features"]) ? args["features"] : [];
|
|
2140
|
+
const saved = await service.saveImportCases(projectId, str(args["file"]) ?? "", {
|
|
2141
|
+
features,
|
|
2142
|
+
...args["done"] === true ? { done: true } : {},
|
|
2143
|
+
...typeof args["note"] === "string" ? { note: args["note"] } : {},
|
|
2144
|
+
...args["restart"] === true ? { restart: true } : {}
|
|
2145
|
+
});
|
|
2146
|
+
return text(`Saved ${saved.added} more \u2014 ${saved.total} test cases so far.${saved.done ? " Done: the person can now look them over and import them." : " Keep going, and pass done: true with the last batch."}`);
|
|
2147
|
+
}
|
|
2148
|
+
},
|
|
2149
|
+
{
|
|
2150
|
+
name: "read_update_request",
|
|
2151
|
+
title: "Read what should change in some test cases",
|
|
2152
|
+
description: "Read a request to update test cases (its id is upd_\u2026): what the person wants changed, the change document they gave with it (a spec, release notes \u2014 read every part), and each test case as it is now \u2014 its id, feature, name and business steps.",
|
|
2153
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, request: { type: "string", description: "upd_\u2026" }, part: { type: "number", description: "Which part of a long change document, from 1." } }, required: ["request"], additionalProperties: false },
|
|
2154
|
+
annotations: { readOnlyHint: true },
|
|
2155
|
+
run: async (args) => {
|
|
2156
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2157
|
+
const r = await service.updateRequest(projectId, str(args["request"]) ?? "");
|
|
2158
|
+
const PART = 4e4;
|
|
2159
|
+
const doc = r.document;
|
|
2160
|
+
const parts = doc ? Math.max(1, Math.ceil(doc.text.length / PART)) : 0;
|
|
2161
|
+
const part = Math.min(Math.max(1, Math.floor(Number(args["part"] ?? 1)) || 1), Math.max(parts, 1));
|
|
2162
|
+
return text([
|
|
2163
|
+
`What should change: ${r.instruction}`,
|
|
2164
|
+
...doc ? [`
|
|
2165
|
+
The change document "${doc.name}" \u2014 part ${part} of ${parts}${part < parts ? " (read the rest with part)" : ""}:
|
|
2166
|
+
${doc.text.slice((part - 1) * PART, part * PART)}
|
|
2167
|
+
\u2014 end of part ${part} \u2014`] : [],
|
|
2168
|
+
...r.cases.map((c) => [`
|
|
2169
|
+
${c.feature} \u203A ${c.name} (${c.id})`, ...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no steps written)"]].join("\n"))
|
|
2170
|
+
].join("\n"));
|
|
2171
|
+
}
|
|
2172
|
+
},
|
|
2173
|
+
{
|
|
2174
|
+
name: "propose_case_updates",
|
|
2175
|
+
title: "Propose new steps for test cases",
|
|
2176
|
+
description: "Propose the new business steps for test cases in an update request (upd_\u2026): for each case changed, its id and ALL its steps as Given / When / Then / And lines (the whole case, not only what changed). Cases that need no change are left out. The person sees each before and after and accepts the ones they want; nothing changes until they do. done: true with the last batch; note: anything you could not do.",
|
|
2177
|
+
inputSchema: {
|
|
2178
|
+
type: "object",
|
|
2179
|
+
properties: {
|
|
2180
|
+
projectId: { type: "string" },
|
|
2181
|
+
request: { type: "string", description: "upd_\u2026" },
|
|
2182
|
+
updates: {
|
|
2183
|
+
type: "array",
|
|
2184
|
+
items: {
|
|
2185
|
+
type: "object",
|
|
2186
|
+
properties: { case: { type: "string", description: "The test case's id, tc_\u2026" }, steps: { type: "array", items: { type: "string" } }, note: { type: "string", description: "Why, in a few words." } },
|
|
2187
|
+
required: ["case", "steps"],
|
|
2188
|
+
additionalProperties: false
|
|
2189
|
+
}
|
|
2190
|
+
},
|
|
2191
|
+
done: { type: "boolean" },
|
|
2192
|
+
note: { type: "string" }
|
|
2193
|
+
},
|
|
2194
|
+
required: ["request", "updates"],
|
|
2195
|
+
additionalProperties: false
|
|
2196
|
+
},
|
|
2197
|
+
run: async (args) => {
|
|
2198
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2199
|
+
const updates = Array.isArray(args["updates"]) ? args["updates"] : [];
|
|
2200
|
+
const r = await service.proposeUpdates(projectId, str(args["request"]) ?? "", { updates, ...args["done"] === true ? { done: true } : {}, ...typeof args["note"] === "string" ? { note: args["note"] } : {} });
|
|
2201
|
+
return text(`Proposed new steps for ${r.proposed} of ${r.of} test cases.${r.done ? " Done: the person can now accept them." : " Pass done: true with the last batch."}`);
|
|
2202
|
+
}
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
name: "read_test_cases",
|
|
2206
|
+
title: "Read test cases",
|
|
2207
|
+
description: "Read test cases by id (tc_\u2026) 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.",
|
|
2208
|
+
inputSchema: {
|
|
2209
|
+
type: "object",
|
|
2210
|
+
properties: { projectId: { type: "string" }, cases: { type: "array", items: { type: "string" }, description: "Ids or names." } },
|
|
2211
|
+
required: ["cases"],
|
|
2212
|
+
additionalProperties: false
|
|
2213
|
+
},
|
|
2214
|
+
annotations: { readOnlyHint: true },
|
|
2215
|
+
run: async (args) => {
|
|
2216
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2217
|
+
const refs = Array.isArray(args["cases"]) ? args["cases"].map(String) : [];
|
|
2218
|
+
const { cases, missing } = await service.readCases(projectId, refs);
|
|
2219
|
+
const parts = cases.map((c) => [
|
|
2220
|
+
`${c.folder} \u203A ${c.name} (${c.id}) \u2014 ${c.automated ? `automated, rows: ${c.rows.join(" \u2192 ")}` : "not automated yet"}`,
|
|
2221
|
+
...c.description ? [` ${c.description}`] : [],
|
|
2222
|
+
...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2223
|
+
].join("\n"));
|
|
2224
|
+
if (missing.length) parts.push(`Not found: ${missing.join(", ")} \u2014 project_catalog lists the test cases.`);
|
|
2225
|
+
return text(parts.join("\n\n") || "No test cases found.");
|
|
2226
|
+
}
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
name: "write_test_cases",
|
|
2230
|
+
title: "Write test cases down, before automating them",
|
|
2231
|
+
description: "Write test cases as business steps \u2014 Given / When / Then \u2014 with no flows yet, grouped by feature (the case folder), on Claude's working branch for review. They show as not automated until a test case with the same name is saved into the same folder with save_case, which keeps these steps. A case its folder already has is skipped. Use it when asked to write, plan or list test cases; record and save_case only when asked to automate.",
|
|
2232
|
+
inputSchema: {
|
|
2233
|
+
type: "object",
|
|
2234
|
+
properties: {
|
|
2235
|
+
projectId: { type: "string" },
|
|
2236
|
+
features: {
|
|
2237
|
+
type: "array",
|
|
2238
|
+
description: 'One entry per feature (case folder), e.g. "Account \u2013 Sign up".',
|
|
2239
|
+
items: {
|
|
2240
|
+
type: "object",
|
|
2241
|
+
properties: {
|
|
2242
|
+
feature: { type: "string" },
|
|
2243
|
+
cases: {
|
|
2244
|
+
type: "array",
|
|
2245
|
+
items: {
|
|
2246
|
+
type: "object",
|
|
2247
|
+
properties: {
|
|
2248
|
+
name: { type: "string", description: 'The scenario it proves, e.g. "A taken email is refused".' },
|
|
2249
|
+
description: { type: "string" },
|
|
2250
|
+
steps: { type: "array", description: `Given / When / Then lines, e.g. "Given the sign-up form is open", "When I sign up with a taken email", "Then I see 'That email is taken'". And / But continue the line above.`, items: { type: "string" } }
|
|
2251
|
+
},
|
|
2252
|
+
required: ["name", "steps"],
|
|
2253
|
+
additionalProperties: false
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
},
|
|
2257
|
+
required: ["feature", "cases"],
|
|
2258
|
+
additionalProperties: false
|
|
2259
|
+
}
|
|
2260
|
+
},
|
|
2261
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
2262
|
+
},
|
|
2263
|
+
required: ["features"],
|
|
2264
|
+
additionalProperties: false
|
|
2265
|
+
},
|
|
2266
|
+
run: async (args) => {
|
|
2267
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2268
|
+
const features = Array.isArray(args["features"]) ? args["features"] : [];
|
|
2269
|
+
const groups = features.map((f) => ({
|
|
2270
|
+
folder: String(f.feature ?? "").trim(),
|
|
2271
|
+
cases: (Array.isArray(f.cases) ? f.cases : []).map((c) => {
|
|
2272
|
+
const lines = Array.isArray(c.steps) ? c.steps.map(String) : [];
|
|
2273
|
+
const steps = parseGherkin(lines.join("\n"));
|
|
2274
|
+
if (!steps) throw new Error(`the steps of "${String(c.name ?? "")}" must each start with Given, When, Then, And or But`);
|
|
2275
|
+
return { name: String(c.name ?? "").trim(), ...typeof c.description === "string" ? { description: c.description } : {}, businessSteps: steps };
|
|
2276
|
+
})
|
|
2277
|
+
}));
|
|
2278
|
+
const done = await service.writeCases(projectId, groups, str(args["branch"]));
|
|
2279
|
+
catalogs.delete(projectId);
|
|
2280
|
+
const skipped = done.skipped.length ? ` Skipped ${done.skipped.length} the folder already had: ${done.skipped.map((s) => `"${s.name}"`).join(", ")}.` : "";
|
|
2281
|
+
return text(`Wrote ${done.created.length} test case${done.created.length === 1 ? "" : "s"}, not automated yet, on Claude's working branch ${done.branch}.${skipped}`);
|
|
2282
|
+
}
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
name: "create_sign_in",
|
|
2286
|
+
title: "Make a sign-in profile",
|
|
2287
|
+
description: "Turn a saved login flow into a sign-in profile that flows and test cases can start signed in with. The flow must not itself sign in or start after other flows, and must end with a check proving the login worked \u2014 its last check is switched on here. Returns the fields an account fills in. Making the same name again replaces Claude's earlier one.",
|
|
2288
|
+
inputSchema: {
|
|
2289
|
+
type: "object",
|
|
2290
|
+
properties: {
|
|
2291
|
+
projectId: { type: "string" },
|
|
2292
|
+
flow: { type: "string", description: "The login flow's name or id (project_catalog)." },
|
|
2293
|
+
name: { type: "string", description: `What a tester calls this way of signing in, e.g. "Customer sign-in". Default: the flow's name.` },
|
|
2294
|
+
description: { type: "string" },
|
|
2295
|
+
signOutFlow: { type: "string", description: "Optional: a flow that signs out, by name or id." },
|
|
2296
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
2297
|
+
},
|
|
2298
|
+
required: ["flow"],
|
|
2299
|
+
additionalProperties: false
|
|
2300
|
+
},
|
|
2301
|
+
run: async (args) => {
|
|
2302
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2303
|
+
const made = await service.createSignIn(projectId, {
|
|
2304
|
+
flow: str(args["flow"]) ?? "",
|
|
2305
|
+
...str(args["name"]) ? { name: str(args["name"]) } : {},
|
|
2306
|
+
...str(args["description"]) ? { description: str(args["description"]) } : {},
|
|
2307
|
+
...str(args["signOutFlow"]) ? { signOutFlow: str(args["signOutFlow"]) } : {},
|
|
2308
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2309
|
+
});
|
|
2310
|
+
catalogs.delete(projectId);
|
|
2311
|
+
return text(`${made.replaced ? "Replaced" : "Made"} the sign-in "${made.name}" (profileId ${made.profileId}) from "${made.flow}", on ${made.newBranch ? "a new branch" : "Claude's working branch"} ${made.branch}.
|
|
2312
|
+
An account fills in: ${made.fields.map((f) => `"${f.label}"${f.secret ? " (secret)" : ""}`).join(", ") || "nothing \u2014 the flow types no values"}.
|
|
2313
|
+
Next: create_account for each person who signs in this way.`);
|
|
2314
|
+
}
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
name: "create_account",
|
|
2318
|
+
title: "Make a sign-in account",
|
|
2319
|
+
description: "An account for a sign-in profile: who signs in, and the values its login flow types, by field label (create_sign_in and project_catalog list them). Secret fields are sealed in the project's vault and never shown again. Then start_flow / test cases can use signIn {profileId, accountId}. Making the same name again updates Claude's earlier one.",
|
|
2320
|
+
inputSchema: {
|
|
2321
|
+
type: "object",
|
|
2322
|
+
properties: {
|
|
2323
|
+
projectId: { type: "string" },
|
|
2324
|
+
signIn: { type: "string", description: "The sign-in profile's name or id." },
|
|
2325
|
+
name: { type: "string", description: 'Who this is, e.g. "Demo customer", "Kitchen admin".' },
|
|
2326
|
+
values: { type: "object", additionalProperties: { type: "string" }, description: 'Field label \u2192 value, e.g. {"Email": "demo@example.com", "Password": "\u2026"}.' },
|
|
2327
|
+
environments: { type: "array", items: { type: "string" }, description: "Optional: the environments this account exists on. Default: all." },
|
|
2328
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
2329
|
+
},
|
|
2330
|
+
required: ["signIn", "name", "values"],
|
|
2331
|
+
additionalProperties: false
|
|
2332
|
+
},
|
|
2333
|
+
run: async (args) => {
|
|
2334
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2335
|
+
const values = Object.fromEntries(Object.entries(args["values"] && typeof args["values"] === "object" ? args["values"] : {}).map(([k, v]) => [k, String(v)]));
|
|
2336
|
+
const made = await service.createAccount(projectId, {
|
|
2337
|
+
signIn: str(args["signIn"]) ?? "",
|
|
2338
|
+
name: str(args["name"]) ?? "",
|
|
2339
|
+
values,
|
|
2340
|
+
...Array.isArray(args["environments"]) ? { environments: args["environments"].map(String) } : {},
|
|
2341
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2342
|
+
});
|
|
2343
|
+
catalogs.delete(projectId);
|
|
2344
|
+
return text(`${made.replaced ? "Updated" : "Made"} the account "${made.name}" (accountId ${made.accountId}) for "${made.signIn}", on Claude's working branch ${made.branch}. Filled: ${made.filled.join(", ")} \u2014 secrets are in the vault, not the repository.
|
|
2345
|
+
Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
|
|
2346
|
+
}
|
|
2347
|
+
},
|
|
2348
|
+
{
|
|
2349
|
+
name: "create_helper",
|
|
2350
|
+
title: "Make a helper",
|
|
2351
|
+
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.`,
|
|
2352
|
+
inputSchema: {
|
|
2353
|
+
type: "object",
|
|
2354
|
+
properties: {
|
|
2355
|
+
projectId: { type: "string" },
|
|
2356
|
+
name: { type: "string", description: 'e.g. "QA inbox", "Admin authenticator".' },
|
|
2357
|
+
description: { type: "string" },
|
|
2358
|
+
config: { type: "object", description: "The helper's settings, by kind (see above). Never put a secret here." },
|
|
2359
|
+
secretValues: { type: "object", additionalProperties: { type: "string" }, description: "The secret for the kind, keyed as above." },
|
|
2360
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
2361
|
+
},
|
|
2362
|
+
required: ["name", "config"],
|
|
2363
|
+
additionalProperties: false
|
|
2364
|
+
},
|
|
2365
|
+
run: async (args) => {
|
|
2366
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2367
|
+
const made = await service.createHelper(projectId, {
|
|
2368
|
+
name: str(args["name"]) ?? "",
|
|
2369
|
+
...str(args["description"]) ? { description: str(args["description"]) } : {},
|
|
2370
|
+
config: args["config"] && typeof args["config"] === "object" ? args["config"] : {},
|
|
2371
|
+
...args["secretValues"] && typeof args["secretValues"] === "object" ? { secretValues: Object.fromEntries(Object.entries(args["secretValues"]).map(([k, v]) => [k, String(v)])) } : {},
|
|
2372
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2373
|
+
});
|
|
2374
|
+
catalogs.delete(projectId);
|
|
2375
|
+
return text(`${made.replaced ? "Updated" : "Made"} the ${made.kind} helper "${made.name}" (id ${made.helperId}) on Claude's working branch ${made.branch}.${made.inputs.length ? ` A step supplies ${made.inputs.map((n) => `{{${n}}}`).join(", ")}.` : ""} Use it with use_helper.`);
|
|
2376
|
+
}
|
|
2377
|
+
},
|
|
2378
|
+
{
|
|
2379
|
+
name: "project_brief",
|
|
2380
|
+
title: "Read the project's memory",
|
|
2381
|
+
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 first in every conversation. 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.",
|
|
2382
|
+
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 },
|
|
2383
|
+
annotations: { readOnlyHint: true },
|
|
2384
|
+
run: async (args) => {
|
|
2385
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2386
|
+
const platform = str(args["platform"]);
|
|
2387
|
+
const features = Array.isArray(args["features"]) ? args["features"].filter((f) => typeof f === "string") : [];
|
|
2388
|
+
if (platform || features.length) {
|
|
2389
|
+
try {
|
|
2390
|
+
return text((await service.brief(projectId, { ...platform ? { platform } : {}, features })).brief);
|
|
2391
|
+
} catch {
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
const { pages } = await service.knowledgePages(projectId);
|
|
2395
|
+
const read = async (path3) => pages.some((p) => p.path === path3) ? (await service.knowledgePage(projectId, path3)).content : null;
|
|
2396
|
+
const [product, glossary, decisions, web, android, ios, tagged2] = await Promise.all(["product.md", "glossary.md", "memory/decisions.md", "product/web.md", "product/android.md", "product/ios.md", "codebase.json"].map(read));
|
|
2397
|
+
const codebases = pages.filter((p) => p.path.startsWith("codebase/"));
|
|
2398
|
+
const tags = codebaseIndexOf(tagged2).folders;
|
|
2399
|
+
const headers = await Promise.all(codebases.map(async (p) => {
|
|
2400
|
+
const holds = tags[p.path.slice("codebase/".length, -".md".length)]?.tags ?? [];
|
|
2401
|
+
return `- ${p.path}${holds.length ? ` (${holds.map((t) => CODEBASE_TAG_NAME[t]).join(", ")} code)` : ""}: ${(await read(p.path))?.split("\n").filter((l) => l.trim()).slice(0, 3).join(" \xB7 ") ?? ""}`;
|
|
2402
|
+
}));
|
|
2403
|
+
const explored = pages.filter((p) => p.path.startsWith("explore/"));
|
|
2404
|
+
const walks = await Promise.all(explored.map(async (p) => {
|
|
2405
|
+
const s = exploreSummary(await read(p.path) ?? "");
|
|
2406
|
+
return `- ${p.path}: explored ${s.exploredAt ?? "?"}${s.environment ? ` in ${s.environment}` : ""}${s.screens !== null ? `, ${s.screens} screens` : ""} \u2014 read it with knowledge_read before testing that platform`;
|
|
2407
|
+
}));
|
|
2408
|
+
const shown = /* @__PURE__ */ new Set(["product.md", "glossary.md", "memory/decisions.md", "product/web.md", "product/android.md", "product/ios.md", "codebase.json", ...codebases.map((p) => p.path), ...explored.map((p) => p.path)]);
|
|
2409
|
+
const parts = [
|
|
2410
|
+
product ? `## About the product (product.md)
|
|
2411
|
+
${product}` : "No About the product yet \u2014 the person writes it on the Knowledge screen; offer to help draft it.",
|
|
2412
|
+
web ? `## About the website (product/web.md)
|
|
2413
|
+
${web}` : "",
|
|
2414
|
+
android ? `## About the Android app (product/android.md)
|
|
2415
|
+
${android}` : "",
|
|
2416
|
+
ios ? `## About the iOS app (product/ios.md)
|
|
2417
|
+
${ios}` : "",
|
|
2418
|
+
glossary ? `## glossary.md (older page; terms now belong in product.md)
|
|
2419
|
+
${glossary}` : "",
|
|
2420
|
+
decisions ? `## memory/decisions.md
|
|
2421
|
+
${decisions}` : "",
|
|
2422
|
+
codebases.length ? `## Codebase, as learned
|
|
2423
|
+
${headers.join("\n")}` : "No codebase learned yet.",
|
|
2424
|
+
walks.length ? `## The product, as explored
|
|
2425
|
+
${walks.join("\n")}` : "",
|
|
2426
|
+
`## Other pages
|
|
2427
|
+
${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") || "none"}`
|
|
2428
|
+
];
|
|
2429
|
+
return text(parts.filter(Boolean).join("\n\n"));
|
|
2430
|
+
}
|
|
2431
|
+
},
|
|
2432
|
+
{
|
|
2433
|
+
name: "knowledge_search",
|
|
2434
|
+
title: "Search everything the project knows",
|
|
2435
|
+
description: "Search knowledge pages, source documents, requirements and test cases at once. Returns where each hit is, so it can be cited ([[knowledge:path]], [[source:id@vN]], [[requirement:id]], [[testCase:TC-n]]).",
|
|
2436
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, q: { type: "string" } }, required: ["q"], additionalProperties: false },
|
|
2437
|
+
annotations: { readOnlyHint: true },
|
|
2438
|
+
run: async (args) => {
|
|
2439
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2440
|
+
const { hits } = await service.search(projectId, str(args["q"]) ?? "");
|
|
2441
|
+
if (!hits.length) return text("Nothing found.");
|
|
2442
|
+
return text(hits.map((h) => `[[${h.kind}:${h.ref}]] ${h.title}
|
|
2443
|
+
${h.snippet}`).join("\n"));
|
|
2444
|
+
}
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
name: "knowledge_read",
|
|
2448
|
+
title: "Read a knowledge page",
|
|
2449
|
+
description: "Read one knowledge page by path (product.md, product/<web|android|ios>.md, codebase/<folder>.md, features/<name>.md, memory/\u2026).",
|
|
2450
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, path: { type: "string" } }, required: ["path"], additionalProperties: false },
|
|
2451
|
+
annotations: { readOnlyHint: true },
|
|
2452
|
+
run: async (args) => {
|
|
2453
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2454
|
+
const page = await service.knowledgePage(projectId, str(args["path"]) ?? "");
|
|
2455
|
+
return text(page.content);
|
|
2456
|
+
}
|
|
2457
|
+
},
|
|
2458
|
+
{
|
|
2459
|
+
name: "write_knowledge",
|
|
2460
|
+
title: "Write a knowledge page",
|
|
2461
|
+
description: `Write what the project should remember, on Claude's working branch for review: product.md (About the product \u2014 the person's own description; write it only when they ask) and product/<web|android|ios>.md (what is particular to that platform's app), explore/<web|android|ios>.md (what you saw walking that platform: "# Explored: <Platform>", "Explored: <yyyy-mm-dd>", "Environment: <name>", "Screens: <n>" lines, then Screens, Journeys, Rules observed, Vocabulary, Suggested features), codebase/<folder>.md (what a linked folder holds \u2014 starting with "# <folder>", "Commit: <sha>" and "Learned: <yyyy-mm-dd>" lines, then routes, screens, forms and their validation, roles, APIs, flags; summarised, never copied code), features/<name>.md (screens, rules, quirks), memory/decisions.md (decisions and conventions), memory/sessions/<yyyy-mm-dd>-<topic>.md (what a job did). append: true adds to the end instead of replacing. Never a password, key or token \u2014 those are refused.`,
|
|
2462
|
+
inputSchema: {
|
|
2463
|
+
type: "object",
|
|
2464
|
+
properties: { projectId: { type: "string" }, path: { type: "string" }, content: { type: "string" }, append: { type: "boolean" }, branch: { type: "string" } },
|
|
2465
|
+
required: ["path", "content"],
|
|
2466
|
+
additionalProperties: false
|
|
2467
|
+
},
|
|
2468
|
+
run: async (args) => {
|
|
2469
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2470
|
+
const saved = await service.writeKnowledge(projectId, {
|
|
2471
|
+
path: str(args["path"]) ?? "",
|
|
2472
|
+
content: String(args["content"] ?? ""),
|
|
2473
|
+
...args["append"] === true ? { append: true } : {},
|
|
2474
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2475
|
+
});
|
|
2476
|
+
return text(`Wrote ${saved.path} (${saved.length} characters) on Claude's working branch ${saved.branch}.`);
|
|
2477
|
+
}
|
|
2478
|
+
},
|
|
2479
|
+
{
|
|
2480
|
+
name: "close_browser",
|
|
2481
|
+
title: "Close the browser",
|
|
2482
|
+
description: "Close the browser \u2014 or the app on the phone \u2014 and drop the flow being written, unsaved.",
|
|
2483
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
2484
|
+
annotations: { destructiveHint: true },
|
|
2485
|
+
run: async () => {
|
|
2486
|
+
await session.closeAndDrop();
|
|
2487
|
+
return text("Closed.");
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
];
|
|
2491
|
+
return tools.map((tool) => ({
|
|
2492
|
+
...tool,
|
|
2493
|
+
run: async (args) => {
|
|
2494
|
+
let result;
|
|
2495
|
+
try {
|
|
2496
|
+
result = await tool.run(args);
|
|
2497
|
+
} catch (error) {
|
|
2498
|
+
throw new Error(session.redact(error.message));
|
|
2499
|
+
}
|
|
2500
|
+
return { ...result, content: result.content.map((c) => c.type === "text" && c.text !== void 0 ? { ...c, text: session.redact(c.text) } : c) };
|
|
2501
|
+
}
|
|
2502
|
+
}));
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/mcp/upgrade.ts
|
|
2506
|
+
import { spawn } from "child_process";
|
|
2507
|
+
import readline2 from "readline";
|
|
2508
|
+
var PRIVATE = "flowriki-upgrade";
|
|
2509
|
+
var startWithNpx = (version) => spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["-y", `@flowriki/agent@${version}`, "mcp"], {
|
|
2510
|
+
env: { ...process.env, FLOWRIKI_MCP_CHILD: "1" },
|
|
2511
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
2512
|
+
});
|
|
2513
|
+
var toolShape = (tools) => JSON.stringify(tools.map((t) => [t.name, t.description ?? "", t.inputSchema ?? null]).sort());
|
|
2514
|
+
function serveWithUpgrades(tools, info, opts) {
|
|
2515
|
+
const input = opts.input ?? process.stdin;
|
|
2516
|
+
const output = opts.output ?? process.stdout;
|
|
2517
|
+
const log = opts.log ?? ((line) => process.stderr.write(`flowriki: ${line}
|
|
2518
|
+
`));
|
|
2519
|
+
const send = (m) => output.write(`${JSON.stringify(m)}
|
|
2520
|
+
`);
|
|
2521
|
+
if (!opts.output) {
|
|
2522
|
+
console.log = console.info = console.warn = console.debug = (...args) => {
|
|
2523
|
+
process.stderr.write(`${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
2524
|
+
`);
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
let initialize = null;
|
|
2528
|
+
let localCalls = 0;
|
|
2529
|
+
let ready = null;
|
|
2530
|
+
let active = null;
|
|
2531
|
+
let preparing = false;
|
|
2532
|
+
let notice = null;
|
|
2533
|
+
const forwarded = /* @__PURE__ */ new Set();
|
|
2534
|
+
let timer = null;
|
|
2535
|
+
const withNotice = (answer) => {
|
|
2536
|
+
if (!notice || !answer.result || typeof answer.result !== "object") return answer;
|
|
2537
|
+
const result = answer.result;
|
|
2538
|
+
if (!Array.isArray(result.content)) return answer;
|
|
2539
|
+
const text2 = notice;
|
|
2540
|
+
notice = null;
|
|
2541
|
+
return { ...answer, result: { ...result, content: [{ type: "text", text: text2 }, ...result.content] } };
|
|
2542
|
+
};
|
|
2543
|
+
const prepare = async (version) => {
|
|
2544
|
+
if (preparing || ready || active || !initialize) return;
|
|
2545
|
+
preparing = true;
|
|
2546
|
+
let child = null;
|
|
2547
|
+
try {
|
|
2548
|
+
child = (opts.start ?? startWithNpx)(version);
|
|
2549
|
+
const c = child;
|
|
2550
|
+
const lines2 = readline2.createInterface({ input: c.stdout, crlfDelay: Infinity });
|
|
2551
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
2552
|
+
const exited = new Promise((_, reject) => c.once("exit", (code) => reject(new Error(`it stopped while starting (exit ${code})`))));
|
|
2553
|
+
exited.catch(() => void 0);
|
|
2554
|
+
c.once("error", (e) => waiting.forEach((resolve) => resolve({ jsonrpc: "2.0", error: { message: e.message } })));
|
|
2555
|
+
lines2.on("line", (line) => {
|
|
2556
|
+
let m;
|
|
2557
|
+
try {
|
|
2558
|
+
m = JSON.parse(line);
|
|
2559
|
+
} catch {
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
if (typeof m.id === "string" && m.id.startsWith(PRIVATE)) {
|
|
2563
|
+
waiting.get(m.id)?.(m);
|
|
2564
|
+
waiting.delete(m.id);
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
if (active?.child !== c) return;
|
|
2568
|
+
if (m.id !== void 0 && m.id !== null) forwarded.delete(m.id);
|
|
2569
|
+
send(m.id !== void 0 && m.id !== null && "result" in m ? withNotice(m) : m);
|
|
2570
|
+
});
|
|
2571
|
+
const ask = (method, params, id) => new Promise((resolve, reject) => {
|
|
2572
|
+
waiting.set(id, resolve);
|
|
2573
|
+
c.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, ...params ? { params } : {} })}
|
|
2574
|
+
`);
|
|
2575
|
+
setTimeout(() => reject(new Error("it did not answer in time")), opts.readyWithinMs ?? 18e4).unref();
|
|
2576
|
+
});
|
|
2577
|
+
const hello = await Promise.race([ask("initialize", initialize.params, `${PRIVATE}-init`), exited]);
|
|
2578
|
+
if (hello.error) throw new Error(JSON.stringify(hello.error));
|
|
2579
|
+
c.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}
|
|
2580
|
+
`);
|
|
2581
|
+
const listed = await Promise.race([ask("tools/list", void 0, `${PRIVATE}-tools`), exited]);
|
|
2582
|
+
const theirs = listed.result?.tools ?? [];
|
|
2583
|
+
ready = { child: c, version, lines: lines2, toolsChanged: toolShape(theirs) !== toolShape(tools) };
|
|
2584
|
+
c.once("exit", () => {
|
|
2585
|
+
if (ready?.child === c) ready = null;
|
|
2586
|
+
if (active?.child === c) {
|
|
2587
|
+
active = null;
|
|
2588
|
+
for (const id of forwarded) send({ jsonrpc: "2.0", id, error: { code: -32603, message: "the updated Flowriki stopped \u2014 ask again" } });
|
|
2589
|
+
forwarded.clear();
|
|
2590
|
+
notice = `The updated Flowriki (${version}) stopped, so this connection carries on with ${opts.current}.`;
|
|
2591
|
+
log(`updated server ${version} exited; back on ${opts.current}`);
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
log(`${version} is ready to take over from ${opts.current}`);
|
|
2595
|
+
} catch (error) {
|
|
2596
|
+
child?.kill();
|
|
2597
|
+
log(`could not start ${version}: ${error.message}`);
|
|
2598
|
+
notice ??= `Flowriki ${version} is out (this connection runs ${opts.current}) and could not switch over by itself: ${error.message}. A new Claude session starts the new version.`;
|
|
2599
|
+
} finally {
|
|
2600
|
+
preparing = false;
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
const maybeSwitch = async (incomingTool) => {
|
|
2604
|
+
if (!ready || active || localCalls > 0) return false;
|
|
2605
|
+
if (!opts.idle() && incomingTool !== "start_flow") return false;
|
|
2606
|
+
const next = ready;
|
|
2607
|
+
ready = null;
|
|
2608
|
+
await opts.handOff().catch(() => void 0);
|
|
2609
|
+
active = next;
|
|
2610
|
+
if (timer) clearInterval(timer);
|
|
2611
|
+
notice = `Flowriki updated itself from ${opts.current} to ${next.version} \u2014 the new version is answering now.` + (next.toolsChanged ? " It brings new or changed tools; if Claude does not offer them yet, start a new Claude session (in the terminal version of Claude Code, /mcp then Reconnect also works)." : "");
|
|
2612
|
+
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
2613
|
+
log(`switched to ${next.version}`);
|
|
2614
|
+
return true;
|
|
2615
|
+
};
|
|
2616
|
+
const look = async () => {
|
|
2617
|
+
if (active || ready || preparing) return;
|
|
2618
|
+
const version = await opts.check().catch(() => null);
|
|
2619
|
+
if (version) {
|
|
2620
|
+
await prepare(version);
|
|
2621
|
+
await maybeSwitch();
|
|
2622
|
+
}
|
|
2623
|
+
};
|
|
2624
|
+
setTimeout(() => void look(), opts.firstCheckMs ?? 15e3).unref();
|
|
2625
|
+
timer = setInterval(() => void look(), opts.everyMs ?? 30 * 6e4);
|
|
2626
|
+
timer.unref();
|
|
2627
|
+
const lines = readline2.createInterface({ input, crlfDelay: Infinity });
|
|
2628
|
+
const pending = /* @__PURE__ */ new Set();
|
|
2629
|
+
lines.on("line", (line) => {
|
|
2630
|
+
if (!line.trim()) return;
|
|
2631
|
+
let message;
|
|
2632
|
+
try {
|
|
2633
|
+
message = JSON.parse(line);
|
|
2634
|
+
} catch {
|
|
2635
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
if (message.method === "initialize") initialize = message;
|
|
2639
|
+
const task = (async () => {
|
|
2640
|
+
const tool = message.method === "tools/call" ? String(message.params?.["name"] ?? "") : void 0;
|
|
2641
|
+
if (!active && tool) await maybeSwitch(tool);
|
|
2642
|
+
if (active) {
|
|
2643
|
+
if (message.id !== void 0 && message.id !== null) forwarded.add(message.id);
|
|
2644
|
+
active.child.stdin.write(`${line}
|
|
2645
|
+
`);
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
if (tool) localCalls++;
|
|
2649
|
+
try {
|
|
2650
|
+
const answer = await handle(message, tools, info);
|
|
2651
|
+
if (answer) send(tool ? withNotice(answer) : answer);
|
|
2652
|
+
if (message.method === "notifications/initialized") send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
2653
|
+
} finally {
|
|
2654
|
+
if (tool) localCalls--;
|
|
2655
|
+
}
|
|
2656
|
+
if (tool && ready) await maybeSwitch();
|
|
2657
|
+
})();
|
|
2658
|
+
pending.add(task);
|
|
2659
|
+
void task.finally(() => pending.delete(task));
|
|
2660
|
+
});
|
|
2661
|
+
return new Promise((resolve) => lines.on("close", () => {
|
|
2662
|
+
void Promise.allSettled([...pending]).then(() => {
|
|
2663
|
+
if (timer) clearInterval(timer);
|
|
2664
|
+
const child = active?.child ?? ready?.child;
|
|
2665
|
+
if (!child) return resolve();
|
|
2666
|
+
child.stdin?.end();
|
|
2667
|
+
const done = () => resolve();
|
|
2668
|
+
child.once("exit", done);
|
|
2669
|
+
setTimeout(() => {
|
|
2670
|
+
child.kill();
|
|
2671
|
+
done();
|
|
2672
|
+
}, 5e3).unref();
|
|
2673
|
+
});
|
|
2674
|
+
}));
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
// src/updates.ts
|
|
2678
|
+
import fs2 from "fs";
|
|
2679
|
+
import path2 from "path";
|
|
2680
|
+
var CACHE_MS = 30 * 60 * 1e3;
|
|
2681
|
+
async function latestPublished(opts = {}) {
|
|
2682
|
+
const file = path2.join(dataDir(), "latest-agent.json");
|
|
2683
|
+
if (!opts.fresh) {
|
|
2684
|
+
try {
|
|
2685
|
+
const kept = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
2686
|
+
if (kept.version && kept.at && Date.now() - kept.at < CACHE_MS) return kept.version;
|
|
2687
|
+
} catch {
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
try {
|
|
2691
|
+
const res = await fetch(AGENT_LATEST_URL, { signal: AbortSignal.timeout(5e3), headers: { accept: "application/json" } });
|
|
2692
|
+
if (!res.ok) return null;
|
|
2693
|
+
const version = (await res.json()).version ?? null;
|
|
2694
|
+
if (version) {
|
|
2695
|
+
try {
|
|
2696
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true });
|
|
2697
|
+
fs2.writeFileSync(file, JSON.stringify({ version, at: Date.now() }));
|
|
2698
|
+
} catch {
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
return version;
|
|
2702
|
+
} catch {
|
|
2703
|
+
return null;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
async function updateFor(running) {
|
|
2707
|
+
const latest = await latestPublished();
|
|
2708
|
+
return agentUpdateFor(running, latest) ? latest : null;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/mcp/index.ts
|
|
2712
|
+
async function runMcp(opts) {
|
|
2713
|
+
let service;
|
|
2714
|
+
try {
|
|
2715
|
+
service = resolveService(opts.service);
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
process.stderr.write(`${error.message}
|
|
2718
|
+
`);
|
|
2719
|
+
return 2;
|
|
2720
|
+
}
|
|
2721
|
+
const connection = connectionFor(service);
|
|
2722
|
+
const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
|
|
2723
|
+
let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
|
|
2724
|
+
if (!connection) {
|
|
2725
|
+
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.`;
|
|
2726
|
+
tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
|
|
2727
|
+
}
|
|
2728
|
+
const closing = async () => {
|
|
2729
|
+
await session?.close();
|
|
2730
|
+
process.exit(0);
|
|
2731
|
+
};
|
|
2732
|
+
process.on("SIGINT", () => void closing());
|
|
2733
|
+
process.on("SIGTERM", () => void closing());
|
|
2734
|
+
const info = { name: "flowriki", version: opts.version, instructions: INSTRUCTIONS };
|
|
2735
|
+
if (process.env["FLOWRIKI_MCP_CHILD"] === "1" || process.env["FLOWRIKI_NO_UPDATE"] === "1" || !/^\d+\.\d+\.\d+/.test(opts.version)) {
|
|
2736
|
+
await serve(tools, info);
|
|
2737
|
+
} else {
|
|
2738
|
+
await serveWithUpgrades(tools, info, {
|
|
2739
|
+
current: opts.version,
|
|
2740
|
+
check: () => updateFor(opts.version),
|
|
2741
|
+
idle: () => !session?.open,
|
|
2742
|
+
handOff: async () => {
|
|
2743
|
+
await session?.close();
|
|
2744
|
+
if (session) session.draft = null;
|
|
2745
|
+
}
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
await session?.close();
|
|
2749
|
+
return 0;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// src/cli.ts
|
|
15
2753
|
var paint = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
|
|
16
2754
|
var bold = paint("1");
|
|
17
2755
|
var dim = paint("2");
|
|
@@ -30,6 +2768,13 @@ ${bold("USAGE")}
|
|
|
30
2768
|
flowriki-agent doctor diagnose setup problems
|
|
31
2769
|
flowriki-agent prepare download the browser recording needs
|
|
32
2770
|
flowriki-agent disconnect forget this computer's credential
|
|
2771
|
+
flowriki-agent mobile setup install what testing Android and iOS apps
|
|
2772
|
+
needs (Appium and its drivers)
|
|
2773
|
+
flowriki-agent mobile doctor is this computer ready to test phones
|
|
2774
|
+
flowriki-agent mobile devices the phones, emulators and simulators here
|
|
2775
|
+
flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
|
|
2776
|
+
Claude Code or Claude Desktop, not by you:
|
|
2777
|
+
claude mcp add flowriki -- flowriki-agent mcp
|
|
33
2778
|
|
|
34
2779
|
${bold("OPTIONS")}
|
|
35
2780
|
--project <dir> a checkout to serve; repeat for several.
|
|
@@ -37,6 +2782,7 @@ ${bold("OPTIONS")}
|
|
|
37
2782
|
--name <label> how this computer appears in your dashboard
|
|
38
2783
|
--service <url> advanced: a self-hosted Flowriki. Defaults to the
|
|
39
2784
|
hosted service, which is what you want.
|
|
2785
|
+
--no-open print the approval link without opening a browser
|
|
40
2786
|
--json machine-readable output for status and doctor
|
|
41
2787
|
-h, --help this
|
|
42
2788
|
-v, --version version
|
|
@@ -56,6 +2802,7 @@ async function main(argv) {
|
|
|
56
2802
|
name: { type: "string" },
|
|
57
2803
|
service: { type: "string" },
|
|
58
2804
|
json: { type: "boolean" },
|
|
2805
|
+
"no-open": { type: "boolean" },
|
|
59
2806
|
help: { type: "boolean", short: "h" },
|
|
60
2807
|
version: { type: "boolean", short: "v" }
|
|
61
2808
|
}
|
|
@@ -79,8 +2826,13 @@ Run \`flowriki-agent --help\`.`);
|
|
|
79
2826
|
const service = values.service ? String(values.service) : void 0;
|
|
80
2827
|
const projects = (values.project ?? []).map(String);
|
|
81
2828
|
switch (command) {
|
|
2829
|
+
// FIRST, and before anything writes to stdout: in this mode stdout is the
|
|
2830
|
+
// protocol, and one stray line of output is a message Claude rejects.
|
|
2831
|
+
case "mcp":
|
|
2832
|
+
return runMcp({ ...service ? { service } : {}, version: AGENT_VERSION });
|
|
82
2833
|
case "connect": {
|
|
83
2834
|
const result = await connect({
|
|
2835
|
+
open: !values["no-open"],
|
|
84
2836
|
...service ? { service } : {},
|
|
85
2837
|
...values.name ? { machineName: String(values.name) } : {},
|
|
86
2838
|
onProgress: (note) => {
|
|
@@ -91,7 +2843,7 @@ Run \`flowriki-agent --help\`.`);
|
|
|
91
2843
|
console.log(` ${url}
|
|
92
2844
|
`);
|
|
93
2845
|
if (note.detail?.["canOpen"] === false) {
|
|
94
|
-
console.log(dim("
|
|
2846
|
+
console.log(dim(" Open the approval link in your browser.\n"));
|
|
95
2847
|
} else {
|
|
96
2848
|
console.log(dim(" Opening it for you. If nothing happens, use the link above.\n"));
|
|
97
2849
|
}
|
|
@@ -108,6 +2860,11 @@ Run \`flowriki-agent --help\`.`);
|
|
|
108
2860
|
return 0;
|
|
109
2861
|
}
|
|
110
2862
|
case "start": {
|
|
2863
|
+
process.on("unhandledRejection", (reason) => {
|
|
2864
|
+
const said = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
|
|
2865
|
+
console.error(red(`! a command failed without being handled \u2014 the agent is still running`));
|
|
2866
|
+
console.error(dim(said.split("\n").slice(0, 4).join("\n")));
|
|
2867
|
+
});
|
|
111
2868
|
const started = await start({
|
|
112
2869
|
...service ? { service } : {},
|
|
113
2870
|
...projects.length ? { projects } : {},
|
|
@@ -137,6 +2894,17 @@ Run \`flowriki-agent --help\`.`);
|
|
|
137
2894
|
console.log(dim(` project ${project.name} (${project.root})`));
|
|
138
2895
|
}
|
|
139
2896
|
console.log(dim("\n Running in the foreground. Ctrl-C to stop.\n"));
|
|
2897
|
+
let told = null;
|
|
2898
|
+
const tellIfBehind = async () => {
|
|
2899
|
+
const newer = await updateFor(AGENT_VERSION).catch(() => null);
|
|
2900
|
+
if (!newer || newer === told) return;
|
|
2901
|
+
told = newer;
|
|
2902
|
+
console.log(yellow(` ! Flowriki agent ${newer} is out \u2014 this is ${AGENT_VERSION}.`));
|
|
2903
|
+
console.log(dim(` Update: npm install -g @flowriki/agent@latest then stop (Ctrl-C) and start again
|
|
2904
|
+
`));
|
|
2905
|
+
};
|
|
2906
|
+
void tellIfBehind();
|
|
2907
|
+
setInterval(() => void tellIfBehind(), 6 * 60 * 6e4).unref();
|
|
140
2908
|
let stopping = false;
|
|
141
2909
|
const shutdown = async (signal) => {
|
|
142
2910
|
if (stopping) return;
|
|
@@ -188,7 +2956,11 @@ Run \`flowriki-agent --help\`.`);
|
|
|
188
2956
|
console.log(` node ${found.node} ${dim(`${found.platform}/${found.arch}`)}`);
|
|
189
2957
|
console.log(` protocol ${found.protocolVersion}`);
|
|
190
2958
|
console.log(` data ${found.dataDir}`);
|
|
191
|
-
console.log(
|
|
2959
|
+
console.log(
|
|
2960
|
+
` service ${found.service} ${found.reachable ? green("reachable") : red("unreachable")}` + // WHICH FLOWRIKI, from the service's own mouth. Not the site being
|
|
2961
|
+
// tested — that is a project setting and changes nothing here.
|
|
2962
|
+
(found.environment ? dim(` [${found.environment}]`) : "")
|
|
2963
|
+
);
|
|
192
2964
|
console.log(` credential ${found.credentialFingerprint ?? dim("none stored")}`);
|
|
193
2965
|
console.log(
|
|
194
2966
|
` browsers ${found.playwright.chromiumInstalled ? green("chromium ready") : yellow("chromium not installed")} ${dim(found.playwright.browsersPath ?? "")}`
|
|
@@ -206,6 +2978,42 @@ Run \`flowriki-agent --help\`.`);
|
|
|
206
2978
|
}
|
|
207
2979
|
return 0;
|
|
208
2980
|
}
|
|
2981
|
+
case "mobile": {
|
|
2982
|
+
const sub = positionals[1] ?? "doctor";
|
|
2983
|
+
const report = (found) => {
|
|
2984
|
+
for (const [title, lines] of [["Appium", found.appium], ["Android", found.android], ["iOS", found.ios]]) {
|
|
2985
|
+
console.log(bold(title));
|
|
2986
|
+
for (const line of lines) {
|
|
2987
|
+
console.log(` ${line.ok ? green("\u2713") : line.optional ? yellow("\u2013") : red("\u2717")} ${line.item.padEnd(15)} ${line.ok ? dim(line.detail) : line.detail}`);
|
|
2988
|
+
if (!line.ok && line.fix) console.log(` ${yellow("\u2192")} ${line.fix}`);
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
console.log(`
|
|
2992
|
+
Android ${found.androidReady ? green("ready") : yellow("not ready")} iOS ${found.iosReady ? green("ready") : yellow("not ready")}`);
|
|
2993
|
+
return found.androidReady || found.iosReady ? 0 : 1;
|
|
2994
|
+
};
|
|
2995
|
+
if (sub === "setup") return report(await mobileSetup((line) => console.log(dim(` ${line}`))));
|
|
2996
|
+
if (sub === "doctor") {
|
|
2997
|
+
const found = await mobileDoctor();
|
|
2998
|
+
if (values.json) {
|
|
2999
|
+
console.log(JSON.stringify(found, null, 2));
|
|
3000
|
+
return found.androidReady || found.iosReady ? 0 : 1;
|
|
3001
|
+
}
|
|
3002
|
+
return report(found);
|
|
3003
|
+
}
|
|
3004
|
+
if (sub === "devices") {
|
|
3005
|
+
const found = await listDevices();
|
|
3006
|
+
if (values.json) {
|
|
3007
|
+
console.log(JSON.stringify(found, null, 2));
|
|
3008
|
+
return 0;
|
|
3009
|
+
}
|
|
3010
|
+
if (!found.length) console.log(yellow(" No phones, emulators or simulators found."));
|
|
3011
|
+
for (const d of found) console.log(` ${d.state === "ready" ? green("\u25CF") : dim("\u25CB")} ${d.name.padEnd(28)} ${dim(`${d.platform === "ios" ? "iOS" : "Android"} ${d.osVersion} \xB7 ${d.kind}${d.state === "ready" ? "" : ` \xB7 ${d.state}`}`)}`);
|
|
3012
|
+
return 0;
|
|
3013
|
+
}
|
|
3014
|
+
console.error(`unknown: mobile ${sub} \u2014 use setup, doctor or devices`);
|
|
3015
|
+
return 2;
|
|
3016
|
+
}
|
|
209
3017
|
case "projects": {
|
|
210
3018
|
const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
|
|
211
3019
|
for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);
|