@beryl-so/cli 0.67.0 → 0.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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)
@@ -182,17 +189,11 @@ function identityIssues(plan, provisioning) {
182
189
  : [];
183
190
  }
184
191
  export const THIRD_PARTY_AUTHORIZE = "third_party_authorize";
185
- export const AWAIT_THIRD_PARTY = "await_third_party";
186
192
  const executed = (plan) => [
187
193
  ...steps(plan.before),
188
194
  ...steps(plan.steps),
189
195
  ...steps(plan.after),
190
196
  ];
191
- export function planUsesThirdParty(plan) {
192
- if (typeof plan !== "object" || plan === null)
193
- return false;
194
- return executed(plan).some((s) => s.action === THIRD_PARTY_AUTHORIZE || s.action === AWAIT_THIRD_PARTY);
195
- }
196
197
  // Mirrors the server's ActionPlan validator, which raises the first sorted mismatch only.
197
198
  function thirdPartyIssues(plan) {
198
199
  if (typeof plan !== "object" || plan === null)
@@ -212,6 +213,118 @@ function thirdPartyIssues(plan) {
212
213
  return [{ path: "third_party_integrations", message: THIRD_PARTY_UNUSED(unused) }];
213
214
  return [];
214
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
+ }
215
328
  const TEARDOWN_PROOF_KINDS = new Set([
216
329
  "gone",
217
330
  "hidden",
@@ -301,6 +414,7 @@ export function lintPlan(plan, opts = {}) {
301
414
  if (issues.length > 0)
302
415
  return issues;
303
416
  return [
417
+ ...deviceIssues(plan),
304
418
  ...thirdPartyIssues(plan),
305
419
  ...identityIssues(plan, opts.provisioning === true),
306
420
  ...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
@@ -8,6 +8,7 @@ import { groupCommands } from "../commands/groups.js";
8
8
  import { healthCommands } from "../commands/health.js";
9
9
  import { mailboxCommands } from "../commands/mailboxes.js";
10
10
  import { initCommands } from "../commands/init.js";
11
+ import { jobCommands } from "../commands/jobs.js";
11
12
  import { mcpCommands } from "../commands/mcp.js";
12
13
  import { projectCommands } from "../commands/projects.js";
13
14
  import { runCommands } from "../commands/runs.js";
@@ -68,6 +69,7 @@ export const commands = [
68
69
  ...runCommands,
69
70
  ...healthCommands,
70
71
  ...trackerCommands,
72
+ ...jobCommands,
71
73
  ...explorationCommands,
72
74
  ...configCommands,
73
75
  ...mailboxCommands,
@@ -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"
@@ -110,6 +115,38 @@ export const ACTION_PLAN_SCHEMA = {
110
115
  ]
111
116
  }
112
117
  },
118
+ {
119
+ "if": {
120
+ "properties": {
121
+ "action": {
122
+ "const": "launch"
123
+ }
124
+ },
125
+ "required": [
126
+ "action"
127
+ ]
128
+ },
129
+ "then": {
130
+ "properties": {},
131
+ "required": []
132
+ }
133
+ },
134
+ {
135
+ "if": {
136
+ "properties": {
137
+ "action": {
138
+ "const": "back"
139
+ }
140
+ },
141
+ "required": [
142
+ "action"
143
+ ]
144
+ },
145
+ "then": {
146
+ "properties": {},
147
+ "required": []
148
+ }
149
+ },
113
150
  {
114
151
  "if": {
115
152
  "properties": {
@@ -496,6 +533,36 @@ export const ACTION_PLAN_SCHEMA = {
496
533
  ]
497
534
  }
498
535
  },
536
+ {
537
+ "if": {
538
+ "properties": {
539
+ "action": {
540
+ "const": "send_third_party"
541
+ }
542
+ },
543
+ "required": [
544
+ "action"
545
+ ]
546
+ },
547
+ "then": {
548
+ "properties": {
549
+ "provider": {
550
+ "not": {
551
+ "type": "null"
552
+ }
553
+ },
554
+ "value": {
555
+ "not": {
556
+ "type": "null"
557
+ }
558
+ }
559
+ },
560
+ "required": [
561
+ "provider",
562
+ "value"
563
+ ]
564
+ }
565
+ },
499
566
  {
500
567
  "if": {
501
568
  "properties": {
@@ -1292,45 +1359,103 @@ export const ACTION_PLAN_SCHEMA = {
1292
1359
  }
1293
1360
  },
1294
1361
  {
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]).",
1362
+ "$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
1363
  "else": {
1297
- "properties": {
1298
- "steps": {
1299
- "prefixItems": [
1300
- {
1301
- "properties": {
1302
- "action": {
1303
- "const": "goto"
1364
+ "else": {
1365
+ "properties": {
1366
+ "steps": {
1367
+ "prefixItems": [
1368
+ {
1369
+ "properties": {
1370
+ "action": {
1371
+ "const": "goto"
1372
+ }
1304
1373
  }
1305
1374
  }
1306
- }
1307
- ]
1375
+ ]
1376
+ }
1377
+ }
1378
+ },
1379
+ "if": {
1380
+ "properties": {
1381
+ "before": {
1382
+ "minItems": 1,
1383
+ "type": "array"
1384
+ }
1385
+ },
1386
+ "required": [
1387
+ "before"
1388
+ ]
1389
+ },
1390
+ "then": {
1391
+ "properties": {
1392
+ "before": {
1393
+ "prefixItems": [
1394
+ {
1395
+ "properties": {
1396
+ "action": {
1397
+ "const": "goto"
1398
+ }
1399
+ }
1400
+ }
1401
+ ]
1402
+ }
1308
1403
  }
1309
1404
  }
1310
1405
  },
1311
1406
  "if": {
1312
1407
  "properties": {
1313
- "before": {
1314
- "minItems": 1,
1315
- "type": "array"
1408
+ "device": {
1409
+ "enum": [
1410
+ "android",
1411
+ "ios"
1412
+ ]
1316
1413
  }
1317
1414
  },
1318
1415
  "required": [
1319
- "before"
1416
+ "device"
1320
1417
  ]
1321
1418
  },
1322
1419
  "then": {
1323
- "properties": {
1324
- "before": {
1325
- "prefixItems": [
1326
- {
1327
- "properties": {
1328
- "action": {
1329
- "const": "goto"
1420
+ "else": {
1421
+ "properties": {
1422
+ "steps": {
1423
+ "prefixItems": [
1424
+ {
1425
+ "properties": {
1426
+ "action": {
1427
+ "const": "launch"
1428
+ }
1330
1429
  }
1331
1430
  }
1332
- }
1333
- ]
1431
+ ]
1432
+ }
1433
+ }
1434
+ },
1435
+ "if": {
1436
+ "properties": {
1437
+ "before": {
1438
+ "minItems": 1,
1439
+ "type": "array"
1440
+ }
1441
+ },
1442
+ "required": [
1443
+ "before"
1444
+ ]
1445
+ },
1446
+ "then": {
1447
+ "properties": {
1448
+ "before": {
1449
+ "prefixItems": [
1450
+ {
1451
+ "properties": {
1452
+ "action": {
1453
+ "const": "launch"
1454
+ }
1455
+ }
1456
+ }
1457
+ ]
1458
+ }
1334
1459
  }
1335
1460
  }
1336
1461
  }
@@ -1417,7 +1542,7 @@ export const ACTION_PLAN_SCHEMA = {
1417
1542
  "device": {
1418
1543
  "$ref": "#/$defs/DeviceClass",
1419
1544
  "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."
1545
+ "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
1546
  },
1422
1547
  "requires_auth": {
1423
1548
  "default": false,
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.67.0",
3
+ "version": "0.77.1",
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.29.0",
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
  },
@@ -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
- }