@beryl-so/cli 0.73.2 → 0.78.0
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 +6 -10
- package/dist/adapters/mcp.js +1 -1
- package/dist/beryl-test-skill.js +90 -52
- package/dist/commands/accounts.js +31 -2
- package/dist/commands/config-vars.js +86 -15
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +32 -98
- package/dist/commands/jobs.js +4 -0
- package/dist/commands/projects.js +19 -45
- package/dist/commands/runs.js +6 -433
- package/dist/commands/tests.js +5 -107
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +21 -10
- package/dist/lint.js +120 -0
- package/dist/schema.generated.js +183 -28
- package/dist/skill-tables.js +4 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
package/dist/commands/watch.js
CHANGED
|
@@ -46,6 +46,13 @@ export function findingsGateLine(gate) {
|
|
|
46
46
|
}
|
|
47
47
|
return `findings gate unknown: ${gate.reason ?? "not evaluated"}`;
|
|
48
48
|
}
|
|
49
|
+
export function healNote(r) {
|
|
50
|
+
if (r.heal_attempt) {
|
|
51
|
+
const why = r.heal_attempt.reason ? `: ${r.heal_attempt.reason.slice(0, 300)}` : "";
|
|
52
|
+
return `Auto-heal did not fix it (${r.heal_attempt.status})${why}`;
|
|
53
|
+
}
|
|
54
|
+
return r.heal_skip_reason ?? null;
|
|
55
|
+
}
|
|
49
56
|
// Auth, client and wrong-host errors fail identically on every retry; only a network
|
|
50
57
|
// blip, a timeout, throttling or a 5xx is worth waiting out.
|
|
51
58
|
function isRetryable(e) {
|
|
@@ -102,8 +109,9 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
102
109
|
ctx.err(red(detail.replace(/^/gm, " ")));
|
|
103
110
|
process.stdout.write(`::error title=${escapeAnnotationProp(r.title || r.id)}::${escapeAnnotationData(detail)}\n`);
|
|
104
111
|
}
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
const note = ok ? null : healNote(r);
|
|
113
|
+
if (note)
|
|
114
|
+
ctx.err(dim(` ${note}`));
|
|
107
115
|
};
|
|
108
116
|
if (!ctx.json)
|
|
109
117
|
ctx.err(dim(`watching run ${runId}`));
|
|
@@ -260,14 +268,17 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
260
268
|
`| ${counters.passed} | ${counters.failed} | ${counters.errored} | ${counters.cancelled} | ${counters.quarantined} | ${counters.total} |`,
|
|
261
269
|
...(gate ? ["", findingsGateLine(gate)] : []),
|
|
262
270
|
...(link ? ["", `[Open the run in Beryl](${link})`] : []),
|
|
263
|
-
...failures.flatMap((r) =>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
...failures.flatMap((r) => {
|
|
272
|
+
const note = healNote(r);
|
|
273
|
+
return [
|
|
274
|
+
"",
|
|
275
|
+
`**✗ ${r.title || r.id}**`,
|
|
276
|
+
"```",
|
|
277
|
+
String(r.error_message ?? "").slice(0, CI_ERROR_MAX_LEN),
|
|
278
|
+
"```",
|
|
279
|
+
...(note ? ["", note] : []),
|
|
280
|
+
];
|
|
281
|
+
}),
|
|
271
282
|
"",
|
|
272
283
|
];
|
|
273
284
|
try {
|
package/dist/lint.js
CHANGED
|
@@ -142,6 +142,13 @@ function validate(value, raw, root, path) {
|
|
|
142
142
|
else
|
|
143
143
|
issues.push(...found);
|
|
144
144
|
}
|
|
145
|
+
// A branch can itself be an if/then/else (the first-step rule nests on device, then
|
|
146
|
+
// on whether `before` exists).
|
|
147
|
+
if (schema.if) {
|
|
148
|
+
const branch = matches(value, schema.if, root) ? schema.then : schema.else;
|
|
149
|
+
if (branch)
|
|
150
|
+
issues.push(...validate(value, branch, root, path));
|
|
151
|
+
}
|
|
145
152
|
return issues;
|
|
146
153
|
}
|
|
147
154
|
const steps = (section) => Array.isArray(section)
|
|
@@ -206,6 +213,118 @@ function thirdPartyIssues(plan) {
|
|
|
206
213
|
return [{ path: "third_party_integrations", message: THIRD_PARTY_UNUSED(unused) }];
|
|
207
214
|
return [];
|
|
208
215
|
}
|
|
216
|
+
// Mirrors ActionPlan._validate_nonempty and _assert_app_plan in
|
|
217
|
+
// api/app/ta_shared/schemas.py, which raise the first violation only.
|
|
218
|
+
const APP_DEVICES = ["android", "ios"];
|
|
219
|
+
const APP_PLATFORM_NAMES = {
|
|
220
|
+
android: "Android",
|
|
221
|
+
ios: "iOS",
|
|
222
|
+
};
|
|
223
|
+
export const APP_ACTIONS = [
|
|
224
|
+
"launch",
|
|
225
|
+
"back",
|
|
226
|
+
"fill",
|
|
227
|
+
"click",
|
|
228
|
+
"press",
|
|
229
|
+
"scroll",
|
|
230
|
+
"wait_for",
|
|
231
|
+
"expect",
|
|
232
|
+
"await_email",
|
|
233
|
+
];
|
|
234
|
+
export const APP_EXPECTS = [
|
|
235
|
+
"visible",
|
|
236
|
+
"hidden",
|
|
237
|
+
"checked",
|
|
238
|
+
"enabled",
|
|
239
|
+
"disabled",
|
|
240
|
+
"have_text",
|
|
241
|
+
"persisted",
|
|
242
|
+
"gone",
|
|
243
|
+
];
|
|
244
|
+
const APP_KEYS = {
|
|
245
|
+
android: ["Enter", "Backspace", "Tab"],
|
|
246
|
+
ios: ["Enter", "Backspace"],
|
|
247
|
+
};
|
|
248
|
+
const APP_ID_SELECTOR = {
|
|
249
|
+
android: "resource id",
|
|
250
|
+
ios: "accessibility identifier",
|
|
251
|
+
};
|
|
252
|
+
const APP_SELECTOR_RE = /^(id|text)=[\s\S]+$/;
|
|
253
|
+
const APP_STRING_FIELDS = [
|
|
254
|
+
"selector",
|
|
255
|
+
"value",
|
|
256
|
+
"key",
|
|
257
|
+
"expect_text",
|
|
258
|
+
"from_contains",
|
|
259
|
+
"subject_contains",
|
|
260
|
+
];
|
|
261
|
+
const sortedList = (values) => [...values].sort().join(", ");
|
|
262
|
+
const sectionSteps = (plan) => ["before", "steps", "after"].flatMap((section) => steps(plan[section]).map((step, i) => ({ path: `${section}[${i}]`, section, step })));
|
|
263
|
+
const appStepRules = (p) => [
|
|
264
|
+
(s) => p === "ios" && s.action === "back"
|
|
265
|
+
? "is not available on ios: iOS has no back button; tap the app's own back control."
|
|
266
|
+
: null,
|
|
267
|
+
(s) => APP_ACTIONS.includes(String(s.action))
|
|
268
|
+
? null
|
|
269
|
+
: `is not available on ${p}. ${APP_PLATFORM_NAMES[p]} steps: ${sortedList(APP_ACTIONS)}.`,
|
|
270
|
+
(s) => s.action !== "expect" || APP_EXPECTS.includes(String(s.expect_kind))
|
|
271
|
+
? null
|
|
272
|
+
: `expect_kind '${typeof s.expect_kind === "string" ? s.expect_kind : "?"}' is not ` +
|
|
273
|
+
`available on ${p}. ${APP_PLATFORM_NAMES[p]} expects: ${sortedList(APP_EXPECTS)}.`,
|
|
274
|
+
(s) => (s.iframe ? "sets iframe, which only exists on web pages." : null),
|
|
275
|
+
(s) => s.action === "scroll" && !s.selector
|
|
276
|
+
? `needs a selector: on ${p}, scroll moves until that element is on screen.`
|
|
277
|
+
: null,
|
|
278
|
+
(s) => typeof s.selector === "string" && !APP_SELECTOR_RE.test(s.selector)
|
|
279
|
+
? `selector ${pyRepr(s.selector)} is not an ${p} selector. Use ` +
|
|
280
|
+
`'id=<${APP_ID_SELECTOR[p]}>' or 'text=<visible text or accessibility label>'.`
|
|
281
|
+
: null,
|
|
282
|
+
(s) => s.expect_kind === "have_text" &&
|
|
283
|
+
typeof s.selector === "string" &&
|
|
284
|
+
s.selector &&
|
|
285
|
+
!s.selector.startsWith("id=")
|
|
286
|
+
? "checks text, so it needs an 'id=' selector; to assert that some text is on " +
|
|
287
|
+
"screen, use expect_kind 'visible' with 'text=<text>'."
|
|
288
|
+
: null,
|
|
289
|
+
(s) => s.action === "press" && !APP_KEYS[p].includes(String(s.key))
|
|
290
|
+
? `key ${pyRepr(s.key)} is not available on ${p}. Keys: ${APP_KEYS[p].join(", ")}.`
|
|
291
|
+
: null,
|
|
292
|
+
(s) => {
|
|
293
|
+
const field = APP_STRING_FIELDS.find((f) => {
|
|
294
|
+
const text = s[f];
|
|
295
|
+
return typeof text === "string" && text.includes("${");
|
|
296
|
+
});
|
|
297
|
+
return field
|
|
298
|
+
? `${field} contains '\${', which the app runner would evaluate as code. Remove it.`
|
|
299
|
+
: null;
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
function deviceIssues(plan) {
|
|
303
|
+
if (typeof plan !== "object" || plan === null)
|
|
304
|
+
return [];
|
|
305
|
+
const record = plan;
|
|
306
|
+
const issue = (path, message) => [{ path, message }];
|
|
307
|
+
const platform = String(record.device);
|
|
308
|
+
if (!APP_DEVICES.includes(platform)) {
|
|
309
|
+
const appOnly = [...new Set(executed(record).map((s) => String(s.action)))]
|
|
310
|
+
.filter((a) => a === "launch" || a === "back")
|
|
311
|
+
.sort();
|
|
312
|
+
return appOnly.length
|
|
313
|
+
? issue("(root)", `${appOnly.join(", ")} drives a native app, so it is only for devices 'android' ` +
|
|
314
|
+
"and 'ios'; a web plan starts with a goto")
|
|
315
|
+
: [];
|
|
316
|
+
}
|
|
317
|
+
// The server names a flat plan's steps `step[N]` and a sectioned one's `after[0]`.
|
|
318
|
+
const flat = steps(record.before).length === 0 && steps(record.after).length === 0;
|
|
319
|
+
for (const [i, { path, step }] of sectionSteps(record).entries()) {
|
|
320
|
+
for (const rule of appStepRules(platform)) {
|
|
321
|
+
const why = rule(step);
|
|
322
|
+
if (why)
|
|
323
|
+
return issue(path, `${flat ? `step[${i}]` : path} (${String(step.action)}) ${why}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
209
328
|
const TEARDOWN_PROOF_KINDS = new Set([
|
|
210
329
|
"gone",
|
|
211
330
|
"hidden",
|
|
@@ -295,6 +414,7 @@ export function lintPlan(plan, opts = {}) {
|
|
|
295
414
|
if (issues.length > 0)
|
|
296
415
|
return issues;
|
|
297
416
|
return [
|
|
417
|
+
...deviceIssues(plan),
|
|
298
418
|
...thirdPartyIssues(plan),
|
|
299
419
|
...identityIssues(plan, opts.provisioning === true),
|
|
300
420
|
...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
|
package/dist/schema.generated.js
CHANGED
|
@@ -4,6 +4,8 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
4
4
|
"ActionType": {
|
|
5
5
|
"enum": [
|
|
6
6
|
"goto",
|
|
7
|
+
"launch",
|
|
8
|
+
"back",
|
|
7
9
|
"fill",
|
|
8
10
|
"click",
|
|
9
11
|
"press",
|
|
@@ -24,7 +26,8 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
24
26
|
"close_tab",
|
|
25
27
|
"drag",
|
|
26
28
|
"third_party_authorize",
|
|
27
|
-
"await_third_party"
|
|
29
|
+
"await_third_party",
|
|
30
|
+
"send_third_party"
|
|
28
31
|
],
|
|
29
32
|
"title": "ActionType",
|
|
30
33
|
"type": "string"
|
|
@@ -39,11 +42,13 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
39
42
|
"type": "string"
|
|
40
43
|
},
|
|
41
44
|
"DeviceClass": {
|
|
42
|
-
"description": "The viewport and device a test runs at. The AUTHOR sets it when a flow is specific\nto a form factor (a mobile-only nav, a hamburger, a stacked layout); omit it and the\ntest runs at the default desktop viewport.\n\n``mobile`` / ``tablet`` are full Chromium device emulation, not a bare resize: the\nviewport, a mobile Chrome user-agent, touch, and the device pixel ratio all change\ntogether, so a control gated behind a touch or mobile-UA media query actually appears.",
|
|
45
|
+
"description": "The viewport and device a test runs at. The AUTHOR sets it when a flow is specific\nto a form factor (a mobile-only nav, a hamburger, a stacked layout); omit it and the\ntest runs at the default desktop viewport.\n\n``mobile`` / ``tablet`` are full Chromium device emulation, not a bare resize: the\nviewport, a mobile Chrome user-agent, touch, and the device pixel ratio all change\ntogether, so a control gated behind a touch or mobile-UA media query actually appears.\n``android`` and ``ios`` are not browsers: the plan drives the environment's app on\nan Android emulator or an iOS simulator.",
|
|
43
46
|
"enum": [
|
|
44
47
|
"desktop",
|
|
45
48
|
"tablet",
|
|
46
|
-
"mobile"
|
|
49
|
+
"mobile",
|
|
50
|
+
"android",
|
|
51
|
+
"ios"
|
|
47
52
|
],
|
|
48
53
|
"title": "DeviceClass",
|
|
49
54
|
"type": "string"
|
|
@@ -80,7 +85,8 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
80
85
|
"have_count",
|
|
81
86
|
"persisted",
|
|
82
87
|
"gone",
|
|
83
|
-
"count_delta"
|
|
88
|
+
"count_delta",
|
|
89
|
+
"judged"
|
|
84
90
|
],
|
|
85
91
|
"title": "ExpectKind",
|
|
86
92
|
"type": "string"
|
|
@@ -110,6 +116,38 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
110
116
|
]
|
|
111
117
|
}
|
|
112
118
|
},
|
|
119
|
+
{
|
|
120
|
+
"if": {
|
|
121
|
+
"properties": {
|
|
122
|
+
"action": {
|
|
123
|
+
"const": "launch"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"required": [
|
|
127
|
+
"action"
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
"then": {
|
|
131
|
+
"properties": {},
|
|
132
|
+
"required": []
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"if": {
|
|
137
|
+
"properties": {
|
|
138
|
+
"action": {
|
|
139
|
+
"const": "back"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"required": [
|
|
143
|
+
"action"
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
"then": {
|
|
147
|
+
"properties": {},
|
|
148
|
+
"required": []
|
|
149
|
+
}
|
|
150
|
+
},
|
|
113
151
|
{
|
|
114
152
|
"if": {
|
|
115
153
|
"properties": {
|
|
@@ -496,6 +534,36 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
496
534
|
]
|
|
497
535
|
}
|
|
498
536
|
},
|
|
537
|
+
{
|
|
538
|
+
"if": {
|
|
539
|
+
"properties": {
|
|
540
|
+
"action": {
|
|
541
|
+
"const": "send_third_party"
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
"required": [
|
|
545
|
+
"action"
|
|
546
|
+
]
|
|
547
|
+
},
|
|
548
|
+
"then": {
|
|
549
|
+
"properties": {
|
|
550
|
+
"provider": {
|
|
551
|
+
"not": {
|
|
552
|
+
"type": "null"
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
"value": {
|
|
556
|
+
"not": {
|
|
557
|
+
"type": "null"
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
"required": [
|
|
562
|
+
"provider",
|
|
563
|
+
"value"
|
|
564
|
+
]
|
|
565
|
+
}
|
|
566
|
+
},
|
|
499
567
|
{
|
|
500
568
|
"if": {
|
|
501
569
|
"properties": {
|
|
@@ -768,6 +836,35 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
768
836
|
]
|
|
769
837
|
}
|
|
770
838
|
},
|
|
839
|
+
{
|
|
840
|
+
"if": {
|
|
841
|
+
"properties": {
|
|
842
|
+
"expect_kind": {
|
|
843
|
+
"const": "judged"
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
"required": [
|
|
847
|
+
"expect_kind"
|
|
848
|
+
]
|
|
849
|
+
},
|
|
850
|
+
"then": {
|
|
851
|
+
"properties": {
|
|
852
|
+
"expect_text": {
|
|
853
|
+
"not": {
|
|
854
|
+
"type": "null"
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
"selector": {
|
|
858
|
+
"minLength": 1,
|
|
859
|
+
"type": "string"
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
"required": [
|
|
863
|
+
"expect_text",
|
|
864
|
+
"selector"
|
|
865
|
+
]
|
|
866
|
+
}
|
|
867
|
+
},
|
|
771
868
|
{
|
|
772
869
|
"if": {
|
|
773
870
|
"properties": {
|
|
@@ -1292,45 +1389,103 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
1292
1389
|
}
|
|
1293
1390
|
},
|
|
1294
1391
|
{
|
|
1295
|
-
"$comment": "The first executed step must be a goto, so the test loads a page before acting (that is before[0] when there is a setup section, else steps[0]).",
|
|
1392
|
+
"$comment": "The first executed step must be a goto, so the test loads a page before acting (that is before[0] when there is a setup section, else steps[0]). An android or ios plan opens its app instead, so its first executed step is a launch.",
|
|
1296
1393
|
"else": {
|
|
1297
|
-
"
|
|
1298
|
-
"
|
|
1299
|
-
"
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
"
|
|
1303
|
-
"
|
|
1394
|
+
"else": {
|
|
1395
|
+
"properties": {
|
|
1396
|
+
"steps": {
|
|
1397
|
+
"prefixItems": [
|
|
1398
|
+
{
|
|
1399
|
+
"properties": {
|
|
1400
|
+
"action": {
|
|
1401
|
+
"const": "goto"
|
|
1402
|
+
}
|
|
1304
1403
|
}
|
|
1305
1404
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1405
|
+
]
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
},
|
|
1409
|
+
"if": {
|
|
1410
|
+
"properties": {
|
|
1411
|
+
"before": {
|
|
1412
|
+
"minItems": 1,
|
|
1413
|
+
"type": "array"
|
|
1414
|
+
}
|
|
1415
|
+
},
|
|
1416
|
+
"required": [
|
|
1417
|
+
"before"
|
|
1418
|
+
]
|
|
1419
|
+
},
|
|
1420
|
+
"then": {
|
|
1421
|
+
"properties": {
|
|
1422
|
+
"before": {
|
|
1423
|
+
"prefixItems": [
|
|
1424
|
+
{
|
|
1425
|
+
"properties": {
|
|
1426
|
+
"action": {
|
|
1427
|
+
"const": "goto"
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
]
|
|
1432
|
+
}
|
|
1308
1433
|
}
|
|
1309
1434
|
}
|
|
1310
1435
|
},
|
|
1311
1436
|
"if": {
|
|
1312
1437
|
"properties": {
|
|
1313
|
-
"
|
|
1314
|
-
"
|
|
1315
|
-
|
|
1438
|
+
"device": {
|
|
1439
|
+
"enum": [
|
|
1440
|
+
"android",
|
|
1441
|
+
"ios"
|
|
1442
|
+
]
|
|
1316
1443
|
}
|
|
1317
1444
|
},
|
|
1318
1445
|
"required": [
|
|
1319
|
-
"
|
|
1446
|
+
"device"
|
|
1320
1447
|
]
|
|
1321
1448
|
},
|
|
1322
1449
|
"then": {
|
|
1323
|
-
"
|
|
1324
|
-
"
|
|
1325
|
-
"
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
"
|
|
1329
|
-
"
|
|
1450
|
+
"else": {
|
|
1451
|
+
"properties": {
|
|
1452
|
+
"steps": {
|
|
1453
|
+
"prefixItems": [
|
|
1454
|
+
{
|
|
1455
|
+
"properties": {
|
|
1456
|
+
"action": {
|
|
1457
|
+
"const": "launch"
|
|
1458
|
+
}
|
|
1330
1459
|
}
|
|
1331
1460
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1461
|
+
]
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
},
|
|
1465
|
+
"if": {
|
|
1466
|
+
"properties": {
|
|
1467
|
+
"before": {
|
|
1468
|
+
"minItems": 1,
|
|
1469
|
+
"type": "array"
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
"required": [
|
|
1473
|
+
"before"
|
|
1474
|
+
]
|
|
1475
|
+
},
|
|
1476
|
+
"then": {
|
|
1477
|
+
"properties": {
|
|
1478
|
+
"before": {
|
|
1479
|
+
"prefixItems": [
|
|
1480
|
+
{
|
|
1481
|
+
"properties": {
|
|
1482
|
+
"action": {
|
|
1483
|
+
"const": "launch"
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
]
|
|
1488
|
+
}
|
|
1334
1489
|
}
|
|
1335
1490
|
}
|
|
1336
1491
|
}
|
|
@@ -1417,7 +1572,7 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
1417
1572
|
"device": {
|
|
1418
1573
|
"$ref": "#/$defs/DeviceClass",
|
|
1419
1574
|
"default": "desktop",
|
|
1420
|
-
"description": "Viewport and device to run at. 'mobile' and 'tablet' apply full Chromium device emulation (viewport, mobile user-agent, touch, pixel ratio); 'desktop' (default) runs at the standard desktop viewport."
|
|
1575
|
+
"description": "Viewport and device to run at. 'mobile' and 'tablet' apply full Chromium device emulation (viewport, mobile user-agent, touch, pixel ratio); 'desktop' (default) runs at the standard desktop viewport; 'android' and 'ios' run the environment's app on an Android emulator or iOS simulator."
|
|
1421
1576
|
},
|
|
1422
1577
|
"requires_auth": {
|
|
1423
1578
|
"default": false,
|
package/dist/skill-tables.js
CHANGED
|
@@ -17,6 +17,8 @@ function expectRequires(kind) {
|
|
|
17
17
|
}
|
|
18
18
|
export const ACTION_DOCS = {
|
|
19
19
|
goto: "Navigate to `url` — a path on your app, absolute only for another origin",
|
|
20
|
+
launch: "App plans only (android, ios), and their first step: open the app (a clean start unless the test rides a session, a relaunch keeping its data after)",
|
|
21
|
+
back: "Android only: press the system back button (iOS has none)",
|
|
20
22
|
fill: "Type `value` into the `selector` element",
|
|
21
23
|
click: "Click the `selector` element",
|
|
22
24
|
press: "Press keyboard `key` (e.g. `Enter`) on the `selector` element",
|
|
@@ -38,6 +40,7 @@ export const ACTION_DOCS = {
|
|
|
38
40
|
drag: "Drag the `selector` element onto the `value` TARGET selector",
|
|
39
41
|
third_party_authorize: "Approve the `provider` install on its consent page as Beryl's account (cloud only, see third party integration tests)",
|
|
40
42
|
await_third_party: "Wait for the connected app's message on Beryl's `provider` account (cloud only)",
|
|
43
|
+
send_third_party: "Mention the connected app's bot with `value` as Beryl's `provider` account (cloud only)",
|
|
41
44
|
};
|
|
42
45
|
export const EXPECT_DOCS = {
|
|
43
46
|
visible: "The element is visible",
|
|
@@ -54,6 +57,7 @@ export const EXPECT_DOCS = {
|
|
|
54
57
|
persisted: "The record just created is present — a visible match for the `{{unique}}`-named row",
|
|
55
58
|
gone: "Zero matches — absent from the DOM, stronger than `hidden`",
|
|
56
59
|
count_delta: "The match count moved by signed `expect_delta` vs the `capture_ref` baseline",
|
|
60
|
+
judged: "A model judges the LAST match's text, once it stops changing, against the plain-language criteria in `expect_text`: for answers that vary in wording (a chatbot reply)",
|
|
57
61
|
};
|
|
58
62
|
export const EXTRACT_DOCS = {
|
|
59
63
|
code: "the one-time code",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"description": "Beryl on the command line: projects, tests, runs, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
"docs": "tsx scripts/gen-docs.ts"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
35
|
-
"@playwright/test": "^1.61.1",
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
36
35
|
"@posthog/mcp": "^0.11.6",
|
|
37
36
|
"posthog-node": "^5.49.1"
|
|
38
37
|
},
|
package/dist/email-pump.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { ApiError } from "./http.js";
|
|
3
|
-
const SIDECAR_POLL_MS = 500;
|
|
4
|
-
// The server caps one blocking wait at 50s; stay under it and loop.
|
|
5
|
-
const SERVER_WAIT_MAX_S = 45;
|
|
6
|
-
const DEFAULT_WAIT_S = 30;
|
|
7
|
-
const EXTRACT_RETRY_MS = 1000;
|
|
8
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
-
export function startEmailPump(opts) {
|
|
10
|
-
let stopped = false;
|
|
11
|
-
const served = new Set();
|
|
12
|
-
const consumed = new Set();
|
|
13
|
-
let storedAfter = opts.storedAfter;
|
|
14
|
-
let since;
|
|
15
|
-
const fetchLatest = async (req, timeoutS) => {
|
|
16
|
-
try {
|
|
17
|
-
return (await opts.client.get(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/latest`, {
|
|
18
|
-
timeout_s: timeoutS,
|
|
19
|
-
since,
|
|
20
|
-
stored_after: storedAfter,
|
|
21
|
-
from_contains: req.from_contains,
|
|
22
|
-
subject_contains: req.subject_contains,
|
|
23
|
-
recipient_contains: opts.recipientContains,
|
|
24
|
-
}));
|
|
25
|
-
}
|
|
26
|
-
catch (err) {
|
|
27
|
-
if (err instanceof ApiError && err.status === 404)
|
|
28
|
-
return null;
|
|
29
|
-
throw err;
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
// A 422 is a real verdict — "this email carries no artifact" — and sends the wait on
|
|
33
|
-
// to the next email; anything else is transport trouble, retried until the step's own
|
|
34
|
-
// deadline does the failing.
|
|
35
|
-
const extractOnServer = async (emailId, req, deadline) => {
|
|
36
|
-
while (!stopped && Date.now() < deadline) {
|
|
37
|
-
try {
|
|
38
|
-
const res = (await opts.client.post(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/${emailId}/extract`, { extract: req.extract ?? "code", extract_pattern: req.pattern }));
|
|
39
|
-
return { ok: true, value: res.value };
|
|
40
|
-
}
|
|
41
|
-
catch (err) {
|
|
42
|
-
// 424 is fatal; 422 walks on; the rest retries.
|
|
43
|
-
if (err instanceof ApiError && (err.status === 422 || err.status === 424)) {
|
|
44
|
-
const detail = typeof err.detail === "string" ? err.detail : err.message;
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
fatal: err.status === 424,
|
|
48
|
-
error: detail.replace(/^EMAIL_(EXTRACTION_FAILED|EXTRACTOR_UNAVAILABLE):\s*/, ""),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
await sleep(EXTRACT_RETRY_MS);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return { ok: false, error: "extraction did not complete before the wait deadline" };
|
|
55
|
-
};
|
|
56
|
-
const resolveRequest = async (req) => {
|
|
57
|
-
// Wait exactly wait_s, like the cloud resolver — the spec's own grace window
|
|
58
|
-
// covers this side's poll tick + round trip.
|
|
59
|
-
const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
|
|
60
|
-
const deadline = Date.now() + waitS * 1000;
|
|
61
|
-
let lastExtractError;
|
|
62
|
-
while (!stopped && Date.now() < deadline) {
|
|
63
|
-
const remainingS = Math.ceil((deadline - Date.now()) / 1000);
|
|
64
|
-
const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
|
|
65
|
-
if (email === null)
|
|
66
|
-
continue;
|
|
67
|
-
if (consumed.has(email.id)) {
|
|
68
|
-
// The fence rides received_at, which comes from the sender's Date header — a
|
|
69
|
-
// lagging mailer clock can re-serve a consumed mail. Nudge past it instead.
|
|
70
|
-
since = new Date(new Date(email.received_at).getTime() + 1000).toISOString();
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
consumed.add(email.id);
|
|
74
|
-
since = email.received_at;
|
|
75
|
-
opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
|
|
76
|
-
const extracted = await extractOnServer(email.id, req, deadline);
|
|
77
|
-
if (extracted.ok)
|
|
78
|
-
return extracted.value;
|
|
79
|
-
if (extracted.fatal)
|
|
80
|
-
throw new Error(extracted.error);
|
|
81
|
-
// Same move as the cloud resolver: an email without the artifact (a welcome mail
|
|
82
|
-
// racing the code mail) is consumed and the wait continues for the next one.
|
|
83
|
-
lastExtractError = extracted.error;
|
|
84
|
-
opts.onEvent?.(`await_email: ${extracted.error} — waiting for the next email`);
|
|
85
|
-
}
|
|
86
|
-
throw new Error(lastExtractError ?? `no matching email arrived in the run inbox within ${waitS}s`);
|
|
87
|
-
};
|
|
88
|
-
const writeResponse = (response) => {
|
|
89
|
-
let box = {};
|
|
90
|
-
try {
|
|
91
|
-
box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
// keep at least the response readable
|
|
95
|
-
}
|
|
96
|
-
box.response = response;
|
|
97
|
-
// Write-then-rename so the spec never reads a half-written response.
|
|
98
|
-
const tmp = `${opts.sidecarPath}.tmp`;
|
|
99
|
-
fs.writeFileSync(tmp, JSON.stringify(box), "utf8");
|
|
100
|
-
fs.renameSync(tmp, opts.sidecarPath);
|
|
101
|
-
};
|
|
102
|
-
void (async () => {
|
|
103
|
-
while (!stopped) {
|
|
104
|
-
await sleep(SIDECAR_POLL_MS);
|
|
105
|
-
let box;
|
|
106
|
-
try {
|
|
107
|
-
box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
continue; // torn read or not yet written — the spec re-polls
|
|
111
|
-
}
|
|
112
|
-
const req = box.request;
|
|
113
|
-
if (!req || typeof req.id !== "string" || served.has(req.id))
|
|
114
|
-
continue;
|
|
115
|
-
served.add(req.id);
|
|
116
|
-
const response = { id: req.id };
|
|
117
|
-
try {
|
|
118
|
-
response.value = await resolveRequest(req);
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
response.error = err instanceof Error ? err.message : String(err);
|
|
122
|
-
}
|
|
123
|
-
if (stopped)
|
|
124
|
-
return;
|
|
125
|
-
try {
|
|
126
|
-
writeResponse(response);
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
// run dir vanished mid-write — the run is over anyway
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
})();
|
|
133
|
-
return {
|
|
134
|
-
stop: () => {
|
|
135
|
-
stopped = true;
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|