@yoqa/cli 1.0.2 → 1.0.4

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.
Files changed (2) hide show
  1. package/dist/main.js +1770 -1565
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -13045,13 +13045,20 @@ var healthResponseSchema = exports_external.object({
13045
13045
  version: exports_external.string(),
13046
13046
  uptimeMs: exports_external.number().nonnegative()
13047
13047
  });
13048
- var devicePlatformSchema = exports_external.union([exports_external.literal("ios"), exports_external.literal("android")]);
13048
+ var devicePlatformSchema = exports_external.union([
13049
+ exports_external.literal("ios"),
13050
+ exports_external.literal("android"),
13051
+ exports_external.literal("web")
13052
+ ]);
13049
13053
  var laneNameSchema = exports_external.literal("direct");
13050
13054
  var deviceKindSchema = exports_external.union([
13051
13055
  exports_external.literal("physical"),
13052
13056
  exports_external.literal("simulator"),
13053
- exports_external.literal("emulator")
13057
+ exports_external.literal("emulator"),
13058
+ exports_external.literal("browser")
13054
13059
  ]);
13060
+ var VIEWPORT_PRESET_NAMES = ["desktop", "mobile"];
13061
+ var viewportPresetSchema = exports_external.enum(VIEWPORT_PRESET_NAMES);
13055
13062
  var deviceSchema = exports_external.object({
13056
13063
  id: exports_external.string().min(1),
13057
13064
  name: exports_external.string().min(1),
@@ -13191,13 +13198,15 @@ var catalogAppSchema = exports_external.object({
13191
13198
  iosBundleId: exports_external.string(),
13192
13199
  iosAppStoreId: exports_external.string(),
13193
13200
  androidApplicationId: exports_external.string(),
13201
+ webUrl: exports_external.string().default(""),
13194
13202
  capabilities: exports_external.array(capabilitySchema),
13195
13203
  createdAt: exports_external.number().int().nonnegative(),
13196
13204
  updatedAt: exports_external.number().int().nonnegative()
13197
13205
  });
13198
13206
  var createAppRequestSchema = exports_external.object({
13199
13207
  name: exports_external.string().min(1),
13200
- prefix: exports_external.string().min(1).optional()
13208
+ prefix: exports_external.string().min(1).optional(),
13209
+ webUrl: exports_external.string().optional()
13201
13210
  });
13202
13211
  var updateAppRequestSchema = exports_external.object({
13203
13212
  name: exports_external.string().min(1).optional(),
@@ -13207,6 +13216,7 @@ var updateAppRequestSchema = exports_external.object({
13207
13216
  iosBundleId: exports_external.string().optional(),
13208
13217
  iosAppStoreId: exports_external.string().optional(),
13209
13218
  androidApplicationId: exports_external.string().optional(),
13219
+ webUrl: exports_external.string().optional(),
13210
13220
  capabilities: exports_external.array(capabilitySchema).optional()
13211
13221
  });
13212
13222
  var listAppsResponseSchema = exports_external.object({
@@ -13255,6 +13265,25 @@ var caseScriptDragSchema = exports_external.object({
13255
13265
  durationMs: exports_external.number().min(50).max(5000).optional(),
13256
13266
  reason: exports_external.string().optional()
13257
13267
  });
13268
+ var KEY_NAMES = [
13269
+ "Enter",
13270
+ "Tab",
13271
+ "Escape",
13272
+ "Backspace",
13273
+ "Delete",
13274
+ "ArrowUp",
13275
+ "ArrowDown",
13276
+ "ArrowLeft",
13277
+ "ArrowRight",
13278
+ "Home",
13279
+ "End",
13280
+ "PageUp",
13281
+ "PageDown",
13282
+ "Space"
13283
+ ];
13284
+ var keyNameSchema = exports_external.enum(KEY_NAMES);
13285
+ var KEY_MODIFIERS = ["Shift", "Control", "Alt", "Meta"];
13286
+ var keyModifierSchema = exports_external.enum(KEY_MODIFIERS);
13258
13287
  function caseScriptAppIdSchema(type) {
13259
13288
  return exports_external.object({
13260
13289
  type: exports_external.literal(type),
@@ -13262,60 +13291,99 @@ function caseScriptAppIdSchema(type) {
13262
13291
  reason: exports_external.string().optional()
13263
13292
  });
13264
13293
  }
13294
+ var caseScriptTypeSchema = exports_external.object({
13295
+ type: exports_external.literal("type"),
13296
+ text: exports_external.string(),
13297
+ reason: exports_external.string().optional()
13298
+ });
13299
+ var caseScriptWaitSchema = exports_external.object({
13300
+ type: exports_external.literal("wait"),
13301
+ ms: exports_external.number().min(0).max(1e4),
13302
+ reason: exports_external.string().optional()
13303
+ });
13304
+ var caseScriptAssertionSchema = exports_external.union([exports_external.literal("visible"), exports_external.literal("not-visible")]).default("visible");
13305
+ var caseScriptAssertSchema = exports_external.object({
13306
+ type: exports_external.literal("assert"),
13307
+ assertion: caseScriptAssertionSchema,
13308
+ text: exports_external.string().min(1),
13309
+ timeoutMs: exports_external.number().min(0).max(120000).optional(),
13310
+ reason: exports_external.string().optional()
13311
+ });
13312
+ var caseScriptAlertSchema = exports_external.object({
13313
+ type: exports_external.literal("alert"),
13314
+ alertAction: exports_external.union([exports_external.literal("accept"), exports_external.literal("dismiss")]).default("accept"),
13315
+ reason: exports_external.string().optional()
13316
+ });
13317
+ var caseScriptBackgroundAppSchema = exports_external.object({
13318
+ type: exports_external.literal("background-app"),
13319
+ seconds: exports_external.number().min(0).max(120).optional(),
13320
+ reason: exports_external.string().optional()
13321
+ });
13322
+ var caseScriptOpenUrlSchema = exports_external.object({
13323
+ type: exports_external.literal("open-url"),
13324
+ url: exports_external.string().min(1),
13325
+ reason: exports_external.string().optional()
13326
+ });
13327
+ var caseScriptKeySchema = exports_external.object({
13328
+ type: exports_external.literal("key"),
13329
+ key: keyNameSchema,
13330
+ modifiers: exports_external.array(keyModifierSchema).min(1).optional(),
13331
+ reason: exports_external.string().optional()
13332
+ });
13333
+ var caseScriptGestureSchema = exports_external.union([
13334
+ caseScriptTapSchema,
13335
+ caseScriptSwipeSchema,
13336
+ caseScriptDragSchema,
13337
+ caseScriptTypeSchema,
13338
+ caseScriptWaitSchema,
13339
+ caseScriptAssertSchema,
13340
+ caseScriptAlertSchema,
13341
+ caseScriptAppIdSchema("activate-app"),
13342
+ caseScriptAppIdSchema("terminate-app"),
13343
+ caseScriptAppIdSchema("restart-app"),
13344
+ caseScriptBackgroundAppSchema,
13345
+ caseScriptOpenUrlSchema,
13346
+ caseScriptKeySchema
13347
+ ]);
13348
+ var BRANCH_DEFAULT_TIMEOUT_MS = 2000;
13349
+ var caseScriptStepsSchema = exports_external.array(exports_external.lazy(() => caseScriptActionSchema));
13350
+ var caseScriptBranchSchema = exports_external.object({
13351
+ type: exports_external.literal("branch"),
13352
+ text: exports_external.string().min(1).optional(),
13353
+ id: exports_external.string().min(1).optional(),
13354
+ assertion: caseScriptAssertionSchema,
13355
+ timeoutMs: exports_external.number().min(0).max(120000).optional(),
13356
+ then: caseScriptStepsSchema.min(1),
13357
+ else: caseScriptStepsSchema.optional().transform((actions) => actions?.length ? actions : undefined),
13358
+ reason: exports_external.string().optional()
13359
+ }).superRefine((value, ctx) => {
13360
+ if (value.text == null === (value.id == null)) {
13361
+ ctx.addIssue({
13362
+ code: exports_external.ZodIssueCode.custom,
13363
+ message: "branch requires exactly one of text or id"
13364
+ });
13365
+ }
13366
+ });
13367
+ var caseScriptActionSchema = exports_external.union([
13368
+ caseScriptGestureSchema,
13369
+ caseScriptBranchSchema
13370
+ ]);
13265
13371
  var caseScriptActionSchemaByType = {
13266
13372
  tap: caseScriptTapSchema,
13267
13373
  swipe: caseScriptSwipeSchema,
13268
13374
  drag: caseScriptDragSchema,
13269
- type: exports_external.object({
13270
- type: exports_external.literal("type"),
13271
- text: exports_external.string(),
13272
- reason: exports_external.string().optional()
13273
- }),
13274
- wait: exports_external.object({
13275
- type: exports_external.literal("wait"),
13276
- ms: exports_external.number().min(0).max(1e4),
13277
- reason: exports_external.string().optional()
13278
- }),
13279
- assert: exports_external.object({
13280
- type: exports_external.literal("assert"),
13281
- assertion: exports_external.union([exports_external.literal("visible"), exports_external.literal("not-visible")]).default("visible"),
13282
- text: exports_external.string().min(1),
13283
- timeoutMs: exports_external.number().min(0).max(120000).optional(),
13284
- reason: exports_external.string().optional()
13285
- }),
13286
- alert: exports_external.object({
13287
- type: exports_external.literal("alert"),
13288
- alertAction: exports_external.union([exports_external.literal("accept"), exports_external.literal("dismiss")]).default("accept"),
13289
- reason: exports_external.string().optional()
13290
- }),
13375
+ type: caseScriptTypeSchema,
13376
+ wait: caseScriptWaitSchema,
13377
+ assert: caseScriptAssertSchema,
13378
+ alert: caseScriptAlertSchema,
13291
13379
  "activate-app": caseScriptAppIdSchema("activate-app"),
13292
13380
  "terminate-app": caseScriptAppIdSchema("terminate-app"),
13293
13381
  "restart-app": caseScriptAppIdSchema("restart-app"),
13294
- "background-app": exports_external.object({
13295
- type: exports_external.literal("background-app"),
13296
- seconds: exports_external.number().min(0).max(120).optional(),
13297
- reason: exports_external.string().optional()
13298
- }),
13299
- "open-url": exports_external.object({
13300
- type: exports_external.literal("open-url"),
13301
- url: exports_external.string().min(1),
13302
- reason: exports_external.string().optional()
13303
- })
13382
+ "background-app": caseScriptBackgroundAppSchema,
13383
+ "open-url": caseScriptOpenUrlSchema,
13384
+ key: caseScriptKeySchema,
13385
+ branch: caseScriptBranchSchema
13304
13386
  };
13305
- var caseScriptActionSchema = exports_external.union([
13306
- caseScriptActionSchemaByType.tap,
13307
- caseScriptActionSchemaByType.swipe,
13308
- caseScriptActionSchemaByType.drag,
13309
- caseScriptActionSchemaByType.type,
13310
- caseScriptActionSchemaByType.wait,
13311
- caseScriptActionSchemaByType.assert,
13312
- caseScriptActionSchemaByType.alert,
13313
- caseScriptActionSchemaByType["activate-app"],
13314
- caseScriptActionSchemaByType["terminate-app"],
13315
- caseScriptActionSchemaByType["restart-app"],
13316
- caseScriptActionSchemaByType["background-app"],
13317
- caseScriptActionSchemaByType["open-url"]
13318
- ]);
13319
13387
  var caseScriptSchema = exports_external.object({
13320
13388
  version: exports_external.literal(1),
13321
13389
  sourceRunId: exports_external.string().min(1).optional(),
@@ -13626,6 +13694,8 @@ var runSchema = exports_external.object({
13626
13694
  screenMode: runScreenModeSchema.optional(),
13627
13695
  lane: laneNameSchema.optional(),
13628
13696
  laneWarning: exports_external.string().min(1).optional(),
13697
+ browser: exports_external.string().min(1).optional(),
13698
+ viewport: viewportPresetSchema.optional(),
13629
13699
  error: exports_external.string().nullable(),
13630
13700
  createdAt: exports_external.number().int().nonnegative(),
13631
13701
  startedAt: exports_external.number().int().nonnegative().nullable(),
@@ -13644,7 +13714,8 @@ var connectDeviceRequestSchema = exports_external.object({
13644
13714
  platform: devicePlatformSchema,
13645
13715
  bundleId: exports_external.string().min(1).optional(),
13646
13716
  appPackage: exports_external.string().min(1).optional(),
13647
- lane: exports_external.union([laneNameSchema, exports_external.literal("auto")]).optional()
13717
+ lane: exports_external.union([laneNameSchema, exports_external.literal("auto")]).optional(),
13718
+ viewport: viewportPresetSchema.optional()
13648
13719
  });
13649
13720
  var streamStateSchema = exports_external.union([
13650
13721
  exports_external.literal("ready"),
@@ -13662,7 +13733,8 @@ var activeDeviceResponseSchema = exports_external.object({
13662
13733
  heldByRun: exports_external.boolean().optional(),
13663
13734
  heldByRunId: exports_external.string().min(1).optional(),
13664
13735
  lane: laneNameSchema.optional(),
13665
- laneWarning: exports_external.string().min(1).optional()
13736
+ laneWarning: exports_external.string().min(1).optional(),
13737
+ viewport: viewportPresetSchema.optional()
13666
13738
  });
13667
13739
  var retargetDeviceRequestSchema = exports_external.object({
13668
13740
  bundleId: exports_external.string().min(1).optional(),
@@ -13708,7 +13780,8 @@ var actionKindSchema = exports_external.union([
13708
13780
  exports_external.literal("restart-app"),
13709
13781
  exports_external.literal("background-app"),
13710
13782
  exports_external.literal("open-url"),
13711
- exports_external.literal("alert")
13783
+ exports_external.literal("alert"),
13784
+ exports_external.literal("key")
13712
13785
  ]);
13713
13786
  var actionRequestSchema = exports_external.object({
13714
13787
  kind: actionKindSchema,
@@ -13726,6 +13799,8 @@ var actionRequestSchema = exports_external.object({
13726
13799
  url: exports_external.string().optional(),
13727
13800
  alertAction: exports_external.union([exports_external.literal("accept"), exports_external.literal("dismiss")]).optional(),
13728
13801
  seconds: exports_external.number().optional(),
13802
+ key: keyNameSchema.optional(),
13803
+ modifiers: exports_external.array(keyModifierSchema).optional(),
13729
13804
  screenshot: exports_external.boolean().optional(),
13730
13805
  settleMs: exports_external.number().int().min(0).max(1e4).optional(),
13731
13806
  fullImage: exports_external.boolean().optional(),
@@ -13883,109 +13958,71 @@ function pickLatestRun(runs) {
13883
13958
  return runs.reduce((best, run) => run.createdAt > best.createdAt ? run : best);
13884
13959
  }
13885
13960
 
13886
- // ../runner-client/src/script-format.ts
13887
- function shellSingleQuote(value) {
13888
- return `'${value.replace(/'/g, `'\\''`)}'`;
13889
- }
13890
- function actionToShellLine(action) {
13891
- if (action.type === "tap") {
13892
- const parts = ["yoqa", "action", "tap"];
13893
- if (action.label) {
13894
- parts.push("--label", shellSingleQuote(action.label));
13895
- } else if (action.id) {
13896
- parts.push("--id", shellSingleQuote(action.id));
13897
- } else {
13898
- parts.push("--x", String(Math.round(action.x ?? 0)), "--y", String(Math.round(action.y ?? 0)));
13899
- }
13900
- if (action.double)
13901
- parts.push("--double");
13902
- if (action.durationMs != null)
13903
- parts.push("--duration", String(Math.round(action.durationMs)));
13904
- return parts.join(" ");
13905
- }
13906
- if (action.type === "swipe") {
13907
- const parts = [
13908
- "yoqa",
13909
- "action",
13910
- "swipe",
13911
- "--x",
13912
- String(Math.round(action.x)),
13913
- "--y",
13914
- String(Math.round(action.y)),
13915
- "--x2",
13916
- String(Math.round(action.x2)),
13917
- "--y2",
13918
- String(Math.round(action.y2))
13919
- ];
13920
- if (action.durationMs != null) {
13921
- parts.push("--duration", String(Math.round(action.durationMs)));
13922
- }
13923
- return parts.join(" ");
13924
- }
13925
- if (action.type === "drag") {
13926
- const parts = [
13927
- "yoqa",
13928
- "action",
13929
- "drag",
13930
- "--x",
13931
- String(Math.round(action.x)),
13932
- "--y",
13933
- String(Math.round(action.y)),
13934
- "--x2",
13935
- String(Math.round(action.x2)),
13936
- "--y2",
13937
- String(Math.round(action.y2))
13938
- ];
13939
- if (action.durationMs != null) {
13940
- parts.push("--duration", String(Math.round(action.durationMs)));
13941
- }
13942
- return parts.join(" ");
13943
- }
13944
- if (action.type === "type") {
13945
- return `yoqa action input --text ${shellSingleQuote(action.text)}`;
13946
- }
13947
- if (action.type === "assert") {
13948
- const timeoutSeconds = action.timeoutMs != null ? Math.max(1, Math.round(action.timeoutMs / 1000)) : undefined;
13949
- const parts = ["yoqa", "assert", action.assertion, "--text", shellSingleQuote(action.text)];
13950
- if (timeoutSeconds != null) {
13951
- parts.push("--timeout", String(timeoutSeconds));
13952
- }
13953
- return parts.join(" ");
13954
- }
13955
- if (action.type === "alert") {
13956
- return action.alertAction === "dismiss" ? "yoqa action alert --dismiss" : "yoqa action alert";
13957
- }
13958
- if (action.type === "activate-app" || action.type === "terminate-app" || action.type === "restart-app") {
13959
- return `yoqa action ${action.type} --app-id ${shellSingleQuote(action.appId)}`;
13960
- }
13961
- if (action.type === "background-app") {
13962
- return action.seconds != null ? `yoqa action background-app --seconds ${action.seconds}` : "yoqa action background-app";
13963
- }
13964
- if (action.type === "open-url") {
13965
- return `yoqa action open-url --url ${shellSingleQuote(action.url)}`;
13961
+ // ../../node_modules/yaml/dist/index.js
13962
+ var composer = require_composer();
13963
+ var Document = require_Document();
13964
+ var Schema = require_Schema();
13965
+ var errors2 = require_errors();
13966
+ var Alias = require_Alias();
13967
+ var identity = require_identity();
13968
+ var Pair = require_Pair();
13969
+ var Scalar = require_Scalar();
13970
+ var YAMLMap = require_YAMLMap();
13971
+ var YAMLSeq = require_YAMLSeq();
13972
+ var cst = require_cst();
13973
+ var lexer = require_lexer();
13974
+ var lineCounter = require_line_counter();
13975
+ var parser = require_parser();
13976
+ var publicApi = require_public_api();
13977
+ var visit = require_visit();
13978
+ var $Composer = composer.Composer;
13979
+ var $Document = Document.Document;
13980
+ var $Schema = Schema.Schema;
13981
+ var $YAMLError = errors2.YAMLError;
13982
+ var $YAMLParseError = errors2.YAMLParseError;
13983
+ var $YAMLWarning = errors2.YAMLWarning;
13984
+ var $Alias = Alias.Alias;
13985
+ var $isAlias = identity.isAlias;
13986
+ var $isCollection = identity.isCollection;
13987
+ var $isDocument = identity.isDocument;
13988
+ var $isMap = identity.isMap;
13989
+ var $isNode = identity.isNode;
13990
+ var $isPair = identity.isPair;
13991
+ var $isScalar = identity.isScalar;
13992
+ var $isSeq = identity.isSeq;
13993
+ var $Pair = Pair.Pair;
13994
+ var $Scalar = Scalar.Scalar;
13995
+ var $YAMLMap = YAMLMap.YAMLMap;
13996
+ var $YAMLSeq = YAMLSeq.YAMLSeq;
13997
+ var $Lexer = lexer.Lexer;
13998
+ var $LineCounter = lineCounter.LineCounter;
13999
+ var $Parser = parser.Parser;
14000
+ var $parse = publicApi.parse;
14001
+ var $parseAllDocuments = publicApi.parseAllDocuments;
14002
+ var $parseDocument = publicApi.parseDocument;
14003
+ var $stringify = publicApi.stringify;
14004
+ var $visit = visit.visit;
14005
+ var $visitAsync = visit.visitAsync;
14006
+
14007
+ // ../runner-client/src/keys.ts
14008
+ function byName(names, raw, what) {
14009
+ const match = names.find((name) => name.toLowerCase() === raw.trim().toLowerCase());
14010
+ if (!match) {
14011
+ throw new Error(`Unknown ${what} "${raw}" (expected one of: ${names.join(", ")})`);
13966
14012
  }
13967
- const seconds = Math.max(0.1, action.ms / 1000);
13968
- return `sleep ${seconds}`;
14013
+ return match;
13969
14014
  }
13970
- function formatCaseScriptShell(script, meta = {}) {
13971
- const titleParts = [
13972
- meta.caseNumber != null ? `#${meta.caseNumber}` : null,
13973
- meta.caseName?.trim() || null
13974
- ].filter(Boolean);
13975
- const title = titleParts.length > 0 ? titleParts.join(" ") : "exported script";
13976
- const lines = [
13977
- "#!/usr/bin/env bash",
13978
- `# Yoqa exported script — ${title}`,
13979
- "# Requires an active device session: yoqa devices connect <id>",
13980
- "# Prefer the Case Script file: `yoqa script run <file.yoqa.yaml>`.",
13981
- "set -euo pipefail",
13982
- "",
13983
- ...script.actions.map(actionToShellLine),
13984
- ""
13985
- ];
13986
- return lines.join(`
13987
- `);
14015
+ function parseKeyName(raw) {
14016
+ return byName(KEY_NAMES, raw, "key");
14017
+ }
14018
+ function parseKeyModifiers(raw) {
14019
+ const names = raw.split(",").map((part) => part.trim()).filter(Boolean);
14020
+ return [...new Set(names.map((name) => byName(KEY_MODIFIERS, name, "modifier")))];
14021
+ }
14022
+ function formatKeyChord(key, modifiers = []) {
14023
+ return [...modifiers, key].join("+");
13988
14024
  }
14025
+
13989
14026
  // ../runner-client/src/xml-entities.ts
13990
14027
  var NAMED = {
13991
14028
  amp: "&",
@@ -14012,303 +14049,66 @@ function decodeXmlEntities(value) {
14012
14049
  }
14013
14050
 
14014
14051
  // ../runner-client/src/shell-script.ts
14015
- function shellSingleQuote2(value) {
14052
+ function shellSingleQuote(value) {
14016
14053
  return `'${value.replace(/'/g, `'\\''`)}'`;
14017
14054
  }
14018
- function tokenizeShellLine(line) {
14019
- const tokens = [];
14020
- let current = "";
14021
- let quote = null;
14022
- for (let i = 0;i < line.length; i++) {
14023
- const ch = line[i];
14024
- if (ch == null)
14025
- continue;
14026
- if (quote === "'") {
14027
- if (ch === "'") {
14028
- quote = null;
14029
- } else {
14030
- current += ch;
14031
- }
14032
- continue;
14033
- }
14034
- if (quote === '"') {
14035
- if (ch === "\\") {
14036
- const next = line[i + 1];
14037
- if (next != null) {
14038
- current += next;
14039
- i++;
14040
- }
14041
- continue;
14042
- }
14043
- if (ch === '"') {
14044
- quote = null;
14045
- } else {
14046
- current += ch;
14047
- }
14048
- continue;
14049
- }
14050
- if (ch === "'" || ch === '"') {
14051
- quote = ch;
14052
- continue;
14053
- }
14054
- if (/\s/.test(ch)) {
14055
- if (current.length > 0) {
14056
- tokens.push(current);
14057
- current = "";
14058
- }
14059
- continue;
14060
- }
14061
- current += ch;
14062
- }
14063
- if (quote != null) {
14064
- throw new Error("Unclosed quote");
14055
+ function formatActionShellLine(action) {
14056
+ const parts = ["yoqa", "action", action.kind];
14057
+ if (action.kind === "key" && action.key)
14058
+ parts.push(action.key);
14059
+ if (action.modifiers?.length)
14060
+ parts.push("--modifiers", action.modifiers.join(","));
14061
+ if (action.id) {
14062
+ parts.push("--id", shellSingleQuote(action.id));
14065
14063
  }
14066
- if (current.length > 0) {
14067
- tokens.push(current);
14064
+ if (action.label) {
14065
+ parts.push("--label", shellSingleQuote(action.label));
14068
14066
  }
14069
- return tokens;
14070
- }
14071
- function parseFlagMap(tokens) {
14072
- const flags = new Map;
14073
- for (let i = 0;i < tokens.length; i++) {
14074
- const token = tokens[i];
14075
- if (token == null || !token.startsWith("-"))
14076
- continue;
14077
- const eq = token.indexOf("=");
14078
- if (eq > 0) {
14079
- flags.set(token.slice(0, eq), token.slice(eq + 1));
14080
- continue;
14081
- }
14082
- const next = tokens[i + 1];
14083
- if (next != null && !next.startsWith("-")) {
14084
- flags.set(token, next);
14085
- i++;
14086
- } else {
14087
- flags.set(token, true);
14088
- }
14067
+ if (action.description) {
14068
+ parts.push("-d", shellSingleQuote(action.description));
14089
14069
  }
14090
- return flags;
14091
- }
14092
- function flagString(flags, ...names) {
14093
- for (const name of names) {
14094
- const value = flags.get(name);
14095
- if (typeof value === "string")
14096
- return value;
14070
+ if (action.x != null)
14071
+ parts.push("--x", String(Math.round(action.x)));
14072
+ if (action.y != null)
14073
+ parts.push("--y", String(Math.round(action.y)));
14074
+ if (action.x2 != null)
14075
+ parts.push("--x2", String(Math.round(action.x2)));
14076
+ if (action.y2 != null)
14077
+ parts.push("--y2", String(Math.round(action.y2)));
14078
+ if (action.durationMs != null)
14079
+ parts.push("--duration", String(Math.round(action.durationMs)));
14080
+ if (action.double)
14081
+ parts.push("--double");
14082
+ if (action.text != null)
14083
+ parts.push("--text", shellSingleQuote(action.text));
14084
+ if (action.appId != null)
14085
+ parts.push("--app-id", shellSingleQuote(action.appId));
14086
+ if (action.url != null)
14087
+ parts.push("--url", shellSingleQuote(action.url));
14088
+ if (action.seconds != null)
14089
+ parts.push("--seconds", String(action.seconds));
14090
+ if (action.kind === "alert") {
14091
+ if (action.alertAction === "dismiss")
14092
+ parts.push("--dismiss");
14097
14093
  }
14098
- return;
14094
+ return parts.join(" ");
14099
14095
  }
14100
- function flagNumber(flags, ...names) {
14101
- const raw = flagString(flags, ...names);
14102
- if (raw == null)
14103
- return;
14104
- const n = Number(raw);
14105
- return Number.isFinite(n) ? n : undefined;
14096
+ function formatSleepShellLine(seconds) {
14097
+ const safe = Math.max(0, seconds);
14098
+ const rounded = Number.isInteger(safe) ? String(safe) : String(Number(safe.toFixed(3)));
14099
+ return `sleep ${rounded}`;
14106
14100
  }
14107
- function parseActionFromTokens(tokens, lineNumber, raw) {
14108
- if (tokens[0] !== "yoqa" || tokens[1] !== "action") {
14109
- throw new Error("Expected `yoqa action <kind> …`");
14110
- }
14111
- const kindRaw = tokens[2];
14112
- if (!kindRaw) {
14113
- throw new Error("Missing action kind");
14114
- }
14115
- const kindParsed = actionKindSchema.safeParse(kindRaw);
14116
- if (!kindParsed.success) {
14117
- throw new Error(`Unknown action kind: ${kindRaw}`);
14118
- }
14119
- const kind = kindParsed.data;
14120
- const flags = parseFlagMap(tokens.slice(3));
14121
- const action = { kind };
14122
- const description = flagString(flags, "-d", "--description");
14123
- if (description != null)
14124
- action.description = description;
14125
- const label = flagString(flags, "--label");
14126
- if (label != null)
14127
- action.label = label;
14128
- const id = flagString(flags, "--id");
14129
- if (id != null)
14130
- action.id = id;
14131
- const x = flagNumber(flags, "--x");
14132
- const y = flagNumber(flags, "--y");
14133
- const x2 = flagNumber(flags, "--x2");
14134
- const y2 = flagNumber(flags, "--y2");
14135
- const durationMs = flagNumber(flags, "--duration");
14136
- const text = flagString(flags, "--text");
14137
- const appId = flagString(flags, "--app-id", "--bundle-id");
14138
- const url = flagString(flags, "--url");
14139
- const seconds = flagNumber(flags, "--seconds");
14140
- if (x != null)
14141
- action.x = x;
14142
- if (y != null)
14143
- action.y = y;
14144
- if (x2 != null)
14145
- action.x2 = x2;
14146
- if (y2 != null)
14147
- action.y2 = y2;
14148
- if (durationMs != null)
14149
- action.durationMs = durationMs;
14150
- if (text != null)
14151
- action.text = text;
14152
- if (appId != null)
14153
- action.appId = appId;
14154
- if (url != null)
14155
- action.url = url;
14156
- if (seconds != null)
14157
- action.seconds = seconds;
14158
- if (flags.has("--double"))
14159
- action.double = true;
14160
- if (flags.has("--dismiss")) {
14161
- action.alertAction = "dismiss";
14162
- } else if (flags.has("--accept") || kind === "alert") {
14163
- const alertAction = flagString(flags, "--action");
14164
- if (alertAction === "dismiss")
14165
- action.alertAction = "dismiss";
14166
- else if (alertAction === "accept" || kind === "alert")
14167
- action.alertAction = "accept";
14168
- }
14169
- return { kind: "action", action, lineNumber, raw };
14101
+ function assertTargetFlags(target) {
14102
+ return [
14103
+ ...target.text != null ? ["--text", shellSingleQuote(target.text.trim())] : [],
14104
+ ...target.id != null ? ["--id", shellSingleQuote(target.id.trim())] : []
14105
+ ];
14170
14106
  }
14171
- function parseScreenshotFromTokens(tokens, lineNumber, raw) {
14172
- if (tokens[0] !== "yoqa" || tokens[1] !== "screenshot") {
14173
- throw new Error("Expected `yoqa screenshot [path]`");
14174
- }
14175
- const pathToken = tokens[2];
14176
- if (pathToken?.startsWith("-")) {
14177
- throw new Error("`yoqa screenshot` accepts an optional output path only");
14178
- }
14179
- if (tokens.length > 3) {
14180
- throw new Error("`yoqa screenshot` accepts at most one path argument");
14181
- }
14182
- const path = pathToken?.trim() ? pathToken.trim() : null;
14183
- return { kind: "screenshot", path, lineNumber, raw };
14184
- }
14185
- function parseAssertFromTokens(tokens, lineNumber, raw) {
14186
- if (tokens[0] !== "yoqa" || tokens[1] !== "assert") {
14187
- throw new Error("Expected `yoqa assert <visible|not-visible> …`");
14188
- }
14189
- const assertionRaw = tokens[2];
14190
- if (assertionRaw !== "visible" && assertionRaw !== "not-visible") {
14191
- throw new Error("Assert kind must be `visible` or `not-visible`");
14192
- }
14193
- const flags = parseFlagMap(tokens.slice(3));
14194
- const text = flagString(flags, "--text", "-t");
14195
- if (!text || text.trim().length === 0) {
14196
- throw new Error("assert requires --text");
14197
- }
14198
- const timeout = flagNumber(flags, "--timeout") ?? 5;
14199
- if (timeout < 0) {
14200
- throw new Error("--timeout must be non-negative");
14201
- }
14202
- return {
14203
- kind: "assert",
14204
- assertion: assertionRaw,
14205
- text: text.trim(),
14206
- timeoutSeconds: timeout,
14207
- lineNumber,
14208
- raw
14209
- };
14210
- }
14211
- function isIgnorableLine(trimmed) {
14212
- if (trimmed.length === 0)
14213
- return true;
14214
- if (trimmed.startsWith("#"))
14215
- return true;
14216
- if (trimmed === "set -euo pipefail" || trimmed === "set -e" || trimmed === "set -eu")
14217
- return true;
14218
- return false;
14219
- }
14220
- function parseYoqaShellScript(text) {
14221
- const lines = text.split(/\r?\n/);
14222
- const steps = [];
14223
- const errors2 = [];
14224
- for (let i = 0;i < lines.length; i++) {
14225
- const lineNumber = i + 1;
14226
- const raw = lines[i] ?? "";
14227
- const trimmed = raw.trim();
14228
- if (isIgnorableLine(trimmed))
14229
- continue;
14230
- try {
14231
- const tokens = tokenizeShellLine(trimmed);
14232
- if (tokens.length === 0)
14233
- continue;
14234
- if (tokens[0] === "sleep") {
14235
- const seconds = Number(tokens[1]);
14236
- if (!Number.isFinite(seconds) || seconds < 0) {
14237
- throw new Error("`sleep` requires a non-negative number of seconds");
14238
- }
14239
- steps.push({ kind: "sleep", seconds, lineNumber, raw: trimmed });
14240
- continue;
14241
- }
14242
- if (tokens[0] === "yoqa" && tokens[1] === "assert") {
14243
- steps.push(parseAssertFromTokens(tokens, lineNumber, trimmed));
14244
- continue;
14245
- }
14246
- if (tokens[0] === "yoqa" && tokens[1] === "screenshot") {
14247
- steps.push(parseScreenshotFromTokens(tokens, lineNumber, trimmed));
14248
- continue;
14249
- }
14250
- if (tokens[0] === "yoqa") {
14251
- steps.push(parseActionFromTokens(tokens, lineNumber, trimmed));
14252
- continue;
14253
- }
14254
- throw new Error(`Unsupported command: ${tokens[0]}`);
14255
- } catch (error) {
14256
- errors2.push({
14257
- lineNumber,
14258
- raw: trimmed,
14259
- message: error instanceof Error ? error.message : String(error)
14260
- });
14261
- }
14262
- }
14263
- return { steps, errors: errors2 };
14264
- }
14265
- function formatActionShellLine(action) {
14266
- const parts = ["yoqa", "action", action.kind];
14267
- if (action.id) {
14268
- parts.push("--id", shellSingleQuote2(action.id));
14269
- }
14270
- if (action.label) {
14271
- parts.push("--label", shellSingleQuote2(action.label));
14272
- }
14273
- if (action.description) {
14274
- parts.push("-d", shellSingleQuote2(action.description));
14275
- }
14276
- if (action.x != null)
14277
- parts.push("--x", String(Math.round(action.x)));
14278
- if (action.y != null)
14279
- parts.push("--y", String(Math.round(action.y)));
14280
- if (action.x2 != null)
14281
- parts.push("--x2", String(Math.round(action.x2)));
14282
- if (action.y2 != null)
14283
- parts.push("--y2", String(Math.round(action.y2)));
14284
- if (action.durationMs != null)
14285
- parts.push("--duration", String(Math.round(action.durationMs)));
14286
- if (action.double)
14287
- parts.push("--double");
14288
- if (action.text != null)
14289
- parts.push("--text", shellSingleQuote2(action.text));
14290
- if (action.appId != null)
14291
- parts.push("--app-id", shellSingleQuote2(action.appId));
14292
- if (action.url != null)
14293
- parts.push("--url", shellSingleQuote2(action.url));
14294
- if (action.seconds != null)
14295
- parts.push("--seconds", String(action.seconds));
14296
- if (action.kind === "alert") {
14297
- if (action.alertAction === "dismiss")
14298
- parts.push("--dismiss");
14299
- }
14300
- return parts.join(" ");
14301
- }
14302
- function formatSleepShellLine(seconds) {
14303
- const safe = Math.max(0, seconds);
14304
- const rounded = Number.isInteger(safe) ? String(safe) : String(Number(safe.toFixed(3)));
14305
- return `sleep ${rounded}`;
14306
- }
14307
- function formatAssertShellLine(input) {
14308
- const parts = ["yoqa", "assert", input.assertion, "--text", shellSingleQuote2(input.text.trim())];
14309
- const timeout = input.timeoutSeconds ?? 5;
14310
- if (timeout !== 5) {
14311
- parts.push("--timeout", String(timeout));
14107
+ function formatAssertShellLine(input) {
14108
+ const parts = ["yoqa", "assert", input.assertion, ...assertTargetFlags(input)];
14109
+ const timeout = input.timeoutSeconds ?? 5;
14110
+ if (timeout !== 5) {
14111
+ parts.push("--timeout", String(timeout));
14312
14112
  }
14313
14113
  return parts.join(" ");
14314
14114
  }
@@ -14320,25 +14120,6 @@ var DEFAULT_SHELL_SCRIPT_HEADER = [
14320
14120
  ""
14321
14121
  ].join(`
14322
14122
  `);
14323
- function sleepMs(ms, signal) {
14324
- if (ms <= 0)
14325
- return Promise.resolve();
14326
- return new Promise((resolve, reject) => {
14327
- if (signal?.aborted) {
14328
- reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
14329
- return;
14330
- }
14331
- const timer = setTimeout(() => {
14332
- signal?.removeEventListener("abort", onAbort);
14333
- resolve();
14334
- }, ms);
14335
- const onAbort = () => {
14336
- clearTimeout(timer);
14337
- reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
14338
- };
14339
- signal?.addEventListener("abort", onAbort, { once: true });
14340
- });
14341
- }
14342
14123
  function normalizedLabel(value) {
14343
14124
  return decodeXmlEntities(value ?? "").trim().toLowerCase();
14344
14125
  }
@@ -14352,1156 +14133,1495 @@ function screenHasText(elements, text) {
14352
14133
  return label.includes(needle) || type.includes(needle);
14353
14134
  });
14354
14135
  }
14355
- async function runAssertStep(client, step, signal) {
14356
- const deadline = Date.now() + step.timeoutSeconds * 1000;
14357
- for (;; ) {
14358
- if (signal?.aborted) {
14359
- throw Object.assign(new Error("Aborted"), { name: "AbortError" });
14360
- }
14361
- const screen = await client.getScreen();
14362
- const found = screenHasText(screen.elements, step.text);
14363
- if (step.assertion === "visible" && found)
14364
- return;
14365
- if (step.assertion === "not-visible" && !found)
14366
- return;
14367
- if (Date.now() >= deadline) {
14368
- throw new Error(step.assertion === "visible" ? `Expected visible text not found within ${step.timeoutSeconds}s: ${step.text}` : `Unexpected text still visible after ${step.timeoutSeconds}s: ${step.text}`);
14369
- }
14370
- await sleepMs(400, signal);
14371
- }
14136
+ function screenHasTarget(elements, target) {
14137
+ if (target.id != null)
14138
+ return findElementById(elements, target.id) != null;
14139
+ return screenHasText(elements, target.text ?? "");
14372
14140
  }
14373
- async function runYoqaShellScript(client, text, options = {}) {
14374
- const parsed = parseYoqaShellScript(text);
14375
- if (parsed.errors.length > 0) {
14376
- const first = parsed.errors[0];
14377
- if (!first) {
14378
- return { ok: false, completed: 0, total: parsed.steps.length, error: "Parse error" };
14379
- }
14380
- return {
14381
- ok: false,
14382
- completed: 0,
14383
- total: parsed.steps.length,
14384
- error: `Line ${first.lineNumber}: ${first.message}`
14385
- };
14386
- }
14387
- const { steps } = parsed;
14388
- const total = steps.length;
14389
- if (total === 0) {
14390
- return { ok: true, completed: 0, total: 0 };
14141
+ function findElementById(elements, id) {
14142
+ const needle = id.trim().toLowerCase();
14143
+ if (!needle)
14144
+ return null;
14145
+ const list = elements ?? [];
14146
+ const exact = list.filter((el) => (el.id?.trim().toLowerCase() ?? "") === needle);
14147
+ if (exact.length > 0) {
14148
+ exact.sort((a, b) => a.width * a.height - b.width * b.height);
14149
+ return exact[0] ?? null;
14150
+ }
14151
+ const partial = list.filter((el) => {
14152
+ const value = el.id?.toLowerCase() ?? "";
14153
+ return value === needle || value.endsWith(`/${needle}`) || value.endsWith(`:id/${needle}`);
14154
+ });
14155
+ if (partial.length === 0)
14156
+ return null;
14157
+ partial.sort((a, b) => a.width * a.height - b.width * b.height);
14158
+ return partial[0] ?? null;
14159
+ }
14160
+
14161
+ // ../runner-client/src/case-script-file.ts
14162
+ var ACTION_TYPES = Object.keys(caseScriptActionSchemaByType).filter((type) => type !== "branch");
14163
+ var BRANCH_KEY = "if";
14164
+ var ITEM_NAMES = [...ACTION_TYPES, BRANCH_KEY];
14165
+ var BRANCH_FIELDS = ["text", "label", "id", "assertion", "timeoutMs", "then", "else", "reason"];
14166
+ var SHORT_FORM_FIELD = {
14167
+ tap: "label",
14168
+ type: "text",
14169
+ wait: "ms",
14170
+ assert: "text",
14171
+ alert: "alertAction",
14172
+ "open-url": "url",
14173
+ "activate-app": "appId",
14174
+ "terminate-app": "appId",
14175
+ "restart-app": "appId",
14176
+ "background-app": "seconds",
14177
+ key: "key"
14178
+ };
14179
+ var TEXT_SHORT_FORM_FIELDS = new Set(["label", "text", "alertAction", "url", "appId", "key"]);
14180
+ var FIELD_ORDER = [
14181
+ "label",
14182
+ "id",
14183
+ "text",
14184
+ "appId",
14185
+ "url",
14186
+ "key",
14187
+ "modifiers",
14188
+ "assertion",
14189
+ "alertAction",
14190
+ "x",
14191
+ "y",
14192
+ "x2",
14193
+ "y2",
14194
+ "ms",
14195
+ "seconds",
14196
+ "timeoutMs",
14197
+ "double",
14198
+ "durationMs",
14199
+ "reason"
14200
+ ];
14201
+ var TOP_LEVEL_KEYS = new Set(["version", "name", "sourceRunId", "savedAt", "actions"]);
14202
+ function isActionType(value) {
14203
+ return typeof value === "string" && ACTION_TYPES.includes(value);
14204
+ }
14205
+ function actionFieldNames(type) {
14206
+ const schema = caseScriptActionSchemaByType[type];
14207
+ const object = schema instanceof exports_external.ZodEffects ? schema.innerType() : schema;
14208
+ return new Set(Object.keys(object.shape).filter((key) => key !== "type"));
14209
+ }
14210
+ function lineOf(node, lineCounter2) {
14211
+ const start = node?.range?.[0];
14212
+ return start == null ? null : lineCounter2.linePos(start).line;
14213
+ }
14214
+ function zodMessage(error) {
14215
+ return error.issues.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
14216
+ }
14217
+ function readAction(raw, legacy) {
14218
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
14219
+ return { ok: false, message: "expected an action such as `- tap: Home`" };
14391
14220
  }
14392
- for (let index = 0;index < steps.length; index++) {
14393
- if (options.signal?.aborted) {
14394
- return { ok: false, completed: index, total, error: "Aborted" };
14221
+ const entries = Object.entries(raw);
14222
+ let type;
14223
+ let fields;
14224
+ if (legacy) {
14225
+ const declared = raw.type;
14226
+ if (!isActionType(declared)) {
14227
+ return { ok: false, message: `unknown action type ${JSON.stringify(declared)}` };
14395
14228
  }
14396
- const step = steps[index];
14397
- if (!step)
14398
- continue;
14399
- await options.onStep?.({ index, total, step, status: "running" });
14400
- try {
14401
- if (step.kind === "sleep") {
14402
- await sleepMs(step.seconds * 1000, options.signal);
14403
- } else if (step.kind === "assert") {
14404
- await runAssertStep(client, step, options.signal);
14405
- } else if (step.kind === "screenshot") {
14406
- await client.takeScreenshot(step.path ? { path: step.path } : {});
14407
- } else {
14408
- await client.performAction(step.action);
14409
- if (options.pauseAfterActionMs) {
14410
- await sleepMs(options.pauseAfterActionMs, options.signal);
14411
- }
14412
- }
14413
- await options.onStep?.({ index, total, step, status: "ok" });
14414
- } catch (error) {
14415
- const message = error instanceof Error ? error.message : String(error);
14416
- await options.onStep?.({ index, total, step, status: "error", error: message });
14229
+ type = declared;
14230
+ fields = Object.fromEntries(entries.filter(([key]) => key !== "type"));
14231
+ } else {
14232
+ if (entries.length !== 1) {
14233
+ return {
14234
+ ok: false,
14235
+ message: `an action has exactly one key (its name); found ${entries.map(([key2]) => key2).join(", ") || "none"}`
14236
+ };
14237
+ }
14238
+ const [key, value] = entries[0];
14239
+ if (!isActionType(key)) {
14417
14240
  return {
14418
14241
  ok: false,
14419
- completed: index,
14420
- total,
14421
- error: `Line ${step.lineNumber}: ${message}`
14242
+ message: `unknown action "${key}" (expected one of: ${ITEM_NAMES.join(", ")})`
14243
+ };
14244
+ }
14245
+ type = key;
14246
+ if (value == null) {
14247
+ fields = {};
14248
+ } else if (typeof value === "object" && !Array.isArray(value)) {
14249
+ fields = value;
14250
+ } else if (Array.isArray(value)) {
14251
+ return { ok: false, message: `${type}: expected a value or a map of fields, not a list` };
14252
+ } else {
14253
+ const field = SHORT_FORM_FIELD[type];
14254
+ if (!field) {
14255
+ return { ok: false, message: `${type} has no short form; write it as a map of fields` };
14256
+ }
14257
+ fields = {
14258
+ [field]: TEXT_SHORT_FORM_FIELDS.has(field) && typeof value !== "string" ? String(value) : value
14422
14259
  };
14423
14260
  }
14424
14261
  }
14425
- return { ok: true, completed: total, total };
14262
+ const allowed = actionFieldNames(type);
14263
+ const unknown = Object.keys(fields).filter((key) => !allowed.has(key));
14264
+ if (unknown.length > 0) {
14265
+ return {
14266
+ ok: false,
14267
+ message: `${type}: unknown field ${unknown.map((key) => `"${key}"`).join(", ")} (allowed: ${[...allowed].join(", ")})`
14268
+ };
14269
+ }
14270
+ const parsed = caseScriptActionSchemaByType[type].safeParse({ type, ...fields });
14271
+ if (!parsed.success) {
14272
+ return { ok: false, message: `${type}: ${zodMessage(parsed.error)}` };
14273
+ }
14274
+ return { ok: true, action: parsed.data };
14426
14275
  }
14427
-
14428
- // ../runner-client/src/run-report.ts
14429
- function readStepCycle(action) {
14430
- if (!action || typeof action !== "object" || !("cycle" in action))
14431
- return null;
14432
- const cycle = action.cycle;
14433
- if (!cycle || typeof cycle !== "object")
14276
+ function pushItemError(reader, line, at, message) {
14277
+ reader.errors.push({
14278
+ line,
14279
+ action: at.action,
14280
+ ...at.nested.length > 0 ? { nested: at.nested } : {},
14281
+ message
14282
+ });
14283
+ }
14284
+ function branchValue(item, legacy) {
14285
+ if (!$isMap(item))
14434
14286
  return null;
14435
- const record = cycle;
14436
- const asRecord = (value) => value && typeof value === "object" ? value : null;
14437
- const verify = asRecord(record.verify);
14438
- if (!verify || typeof verify.type !== "string" || !verify.type)
14287
+ if (legacy)
14288
+ return item.get("type") === "branch" ? { value: item } : null;
14289
+ const only = item.items.length === 1 ? item.items[0] : undefined;
14290
+ const key = only && $isScalar(only.key) ? only.key.value : null;
14291
+ return key === BRANCH_KEY ? { value: only?.value ?? null } : null;
14292
+ }
14293
+ function readItem(reader, item, at) {
14294
+ const line = lineOf(item, reader.lineCounter);
14295
+ const branch = branchValue(item, reader.legacy);
14296
+ if (branch)
14297
+ return readBranch(reader, branch.value, line, at);
14298
+ const raw = $isNode(item) ? item.toJS(reader.doc) : item;
14299
+ const result = readAction(raw, reader.legacy);
14300
+ if (result.ok)
14301
+ return result.action;
14302
+ pushItemError(reader, line, at, result.message);
14303
+ return null;
14304
+ }
14305
+ function readBranch(reader, value, itemLine, at) {
14306
+ const name = reader.legacy ? "branch" : BRANCH_KEY;
14307
+ if (!$isMap(value)) {
14308
+ pushItemError(reader, itemLine, at, `${name} has no short form; write it as a map with \`text:\` and \`then:\``);
14439
14309
  return null;
14440
- return {
14441
- action: asRecord(record.action),
14442
- decision: asRecord(record.decision),
14443
- verify: {
14444
- type: verify.type,
14445
- reason: typeof verify.reason === "string" ? verify.reason : null,
14446
- thoughts: typeof verify.thoughts === "string" ? verify.thoughts : null
14310
+ }
14311
+ let ok = true;
14312
+ const fail = (line, message) => {
14313
+ pushItemError(reader, line ?? itemLine, at, `${name}: ${message}`);
14314
+ ok = false;
14315
+ };
14316
+ const keyLine = (key) => {
14317
+ const pair = value.items.find((item) => $isScalar(item.key) && item.key.value === key);
14318
+ return lineOf(pair?.key, reader.lineCounter);
14319
+ };
14320
+ const field = (key) => {
14321
+ const node = value.get(key, true);
14322
+ return $isNode(node) ? node.toJS(reader.doc) : node;
14323
+ };
14324
+ const unknown = value.items.map((item) => $isScalar(item.key) ? item.key.value : item.key).filter((key) => !(reader.legacy && key === "type")).filter((key) => typeof key !== "string" || !BRANCH_FIELDS.includes(key));
14325
+ if (unknown.length > 0) {
14326
+ fail(keyLine(String(unknown[0])), `unknown field ${unknown.map((key) => `"${String(key)}"`).join(", ")} (allowed: ${BRANCH_FIELDS.join(", ")})`);
14327
+ }
14328
+ const text = field("text");
14329
+ const label = field("label");
14330
+ const id = field("id");
14331
+ if (text != null && label != null)
14332
+ fail(keyLine("label"), "write `text` or `label`, not both");
14333
+ const textTarget = text ?? label;
14334
+ if (textTarget != null && id != null)
14335
+ fail(keyLine("id"), "write `text` or `id`, not both");
14336
+ if (textTarget == null && id == null) {
14337
+ fail(itemLine, "needs `text:` or `id:` (what to look for on the Screen)");
14338
+ }
14339
+ const readSide = (side) => {
14340
+ const node = value.get(side, true);
14341
+ if (node == null || $isScalar(node) && node.value == null)
14342
+ return;
14343
+ if (!$isSeq(node)) {
14344
+ fail(keyLine(side), `${side} must be a list of actions (each item starts with \`- \`)`);
14345
+ return;
14447
14346
  }
14347
+ const actions = [];
14348
+ node.items.forEach((item, index) => {
14349
+ const action = readItem(reader, item, {
14350
+ action: at.action,
14351
+ nested: [...at.nested, { side, action: index + 1 }]
14352
+ });
14353
+ if (action)
14354
+ actions.push(action);
14355
+ else
14356
+ ok = false;
14357
+ });
14358
+ return actions;
14448
14359
  };
14449
- }
14450
- function summarizeActionRecord(record) {
14451
- const type = typeof record.type === "string" ? record.type : "step";
14452
- if (type === "tap") {
14453
- if (typeof record.label === "string" && record.label.trim())
14454
- return `Tap: ${record.label}`;
14455
- if (typeof record.id === "string" && record.id.trim())
14456
- return `Tap #${record.id}`;
14457
- return "Tap";
14360
+ const then = readSide("then");
14361
+ if (ok && (then == null || then.length === 0)) {
14362
+ fail(then == null ? itemLine : keyLine("then"), "needs `then:` with at least one action");
14458
14363
  }
14459
- if (type === "type")
14460
- return `Type${typeof record.text === "string" ? `: ${record.text}` : ""}`;
14461
- if (type === "wait")
14462
- return "Wait";
14463
- if (type === "alert") {
14464
- return record.alertAction === "dismiss" ? "Dismiss alert" : "Accept alert";
14364
+ const otherwise = readSide("else");
14365
+ if (!ok)
14366
+ return null;
14367
+ const fields = Object.fromEntries(["assertion", "timeoutMs", "reason"].map((key) => [key, field(key)]).filter(([, fieldValue]) => fieldValue !== undefined));
14368
+ const parsed = caseScriptActionSchemaByType.branch.safeParse({
14369
+ type: "branch",
14370
+ ...id != null ? { id: String(id) } : { text: String(textTarget) },
14371
+ ...fields,
14372
+ then,
14373
+ ...otherwise ? { else: otherwise } : {}
14374
+ });
14375
+ if (!parsed.success) {
14376
+ pushItemError(reader, itemLine, at, `${name}: ${zodMessage(parsed.error)}`);
14377
+ return null;
14465
14378
  }
14466
- if (type === "assert") {
14467
- const assertion = typeof record.assertion === "string" ? record.assertion : "visible";
14468
- const text = typeof record.text === "string" ? record.text : "";
14469
- return text ? `Assert ${assertion}: ${text}` : `Assert ${assertion}`;
14379
+ const { else: elseActions, ...branch } = parsed.data;
14380
+ return elseActions ? { ...branch, else: elseActions } : branch;
14381
+ }
14382
+ function documentErrors(doc, lineCounter2) {
14383
+ return doc.errors.map((error) => ({
14384
+ line: lineCounter2.linePos(error.pos[0]).line,
14385
+ action: null,
14386
+ message: error.message
14387
+ }));
14388
+ }
14389
+ function parseCaseScriptFile(text, options = {}) {
14390
+ const lineCounter2 = new $LineCounter;
14391
+ const doc = $parseDocument(text, { lineCounter: lineCounter2, prettyErrors: false });
14392
+ if (doc.errors.length > 0) {
14393
+ return { ok: false, errors: documentErrors(doc, lineCounter2) };
14470
14394
  }
14471
- if (type === "verify")
14472
- return "Verify";
14473
- if (type === "done")
14474
- return "Done";
14475
- if (type === "fail")
14476
- return "Failed";
14477
- if (type === "swipe") {
14478
- if (typeof record.direction === "string" && record.direction.trim()) {
14479
- return `Swipe ${record.direction}`;
14395
+ const root = doc.contents;
14396
+ if (!$isMap(root)) {
14397
+ return {
14398
+ ok: false,
14399
+ errors: [
14400
+ {
14401
+ line: lineOf(root, lineCounter2) ?? 1,
14402
+ action: null,
14403
+ message: "expected a Case Script with `version: 1` and `actions:`"
14404
+ }
14405
+ ]
14406
+ };
14407
+ }
14408
+ const errors3 = [];
14409
+ const top = root.toJS(doc);
14410
+ for (const pair of root.items) {
14411
+ const key = $isScalar(pair.key) ? pair.key.value : pair.key;
14412
+ if (typeof key !== "string" || !TOP_LEVEL_KEYS.has(key)) {
14413
+ errors3.push({
14414
+ line: lineOf(pair.key, lineCounter2),
14415
+ action: null,
14416
+ message: `unknown key "${String(key)}" (allowed: ${[...TOP_LEVEL_KEYS].join(", ")})`
14417
+ });
14480
14418
  }
14481
- const x = typeof record.x === "number" ? record.x : null;
14482
- const y = typeof record.y === "number" ? record.y : null;
14483
- const x2 = typeof record.x2 === "number" ? record.x2 : null;
14484
- const y2 = typeof record.y2 === "number" ? record.y2 : null;
14485
- if (y != null && y2 != null && x != null && x2 != null) {
14486
- const dy = y2 - y;
14487
- const dx = x2 - x;
14488
- if (Math.abs(dy) >= Math.abs(dx)) {
14489
- if (dy < -50)
14490
- return "Swipe up";
14491
- if (dy > 50)
14492
- return "Swipe down";
14493
- } else {
14494
- if (dx < -50)
14495
- return "Swipe left";
14496
- if (dx > 50)
14497
- return "Swipe right";
14498
- }
14419
+ }
14420
+ const versionNode = root.get("version", true);
14421
+ if (top.version !== 1) {
14422
+ errors3.push({
14423
+ line: lineOf(versionNode, lineCounter2) ?? 1,
14424
+ action: null,
14425
+ message: top.version === undefined ? "missing `version: 1`" : `unsupported version ${JSON.stringify(top.version)} (expected 1)`
14426
+ });
14427
+ }
14428
+ const name = top.name;
14429
+ if (name != null && typeof name !== "string") {
14430
+ errors3.push({
14431
+ line: lineOf(root.get("name", true), lineCounter2),
14432
+ action: null,
14433
+ message: "name must be text"
14434
+ });
14435
+ }
14436
+ const sourceRunId = top.sourceRunId;
14437
+ if (sourceRunId != null && (typeof sourceRunId !== "string" || sourceRunId.length === 0)) {
14438
+ errors3.push({
14439
+ line: lineOf(root.get("sourceRunId", true), lineCounter2),
14440
+ action: null,
14441
+ message: "sourceRunId must be text"
14442
+ });
14443
+ }
14444
+ const savedAt = top.savedAt;
14445
+ const legacy = savedAt !== undefined;
14446
+ if (legacy && (typeof savedAt !== "number" || !Number.isInteger(savedAt) || savedAt < 0)) {
14447
+ errors3.push({
14448
+ line: lineOf(root.get("savedAt", true), lineCounter2),
14449
+ action: null,
14450
+ message: "savedAt must be a non-negative whole number"
14451
+ });
14452
+ }
14453
+ const actionsNode = root.get("actions", true);
14454
+ const actions = [];
14455
+ const actionLines = [];
14456
+ if (actionsNode != null && !($isScalar(actionsNode) && actionsNode.value == null)) {
14457
+ if (!$isSeq(actionsNode)) {
14458
+ errors3.push({
14459
+ line: lineOf(actionsNode, lineCounter2),
14460
+ action: null,
14461
+ message: "actions must be a list (each item starts with `- `)"
14462
+ });
14463
+ } else {
14464
+ const reader = { doc, lineCounter: lineCounter2, legacy, errors: errors3 };
14465
+ actionsNode.items.forEach((item, index) => {
14466
+ const action = readItem(reader, item, { action: index + 1, nested: [] });
14467
+ if (action) {
14468
+ actions.push(action);
14469
+ actionLines.push(lineOf(item, lineCounter2) ?? 0);
14470
+ }
14471
+ });
14499
14472
  }
14500
- return "Swipe";
14473
+ } else if (!root.has("actions")) {
14474
+ errors3.push({ line: null, action: null, message: "missing `actions:`" });
14501
14475
  }
14502
- if (type === "input") {
14503
- return `Input${typeof record.text === "string" ? `: ${record.text}` : ""}`;
14476
+ if (errors3.length === 0 && actions.length === 0 && !options.allowEmpty) {
14477
+ errors3.push({
14478
+ line: lineOf(actionsNode, lineCounter2),
14479
+ action: null,
14480
+ message: "actions: add at least one action"
14481
+ });
14504
14482
  }
14505
- if (type === "drag")
14506
- return "Drag";
14507
- if (type === "activate-app")
14508
- return "Activate app";
14509
- if (type === "terminate-app")
14510
- return "Terminate app";
14511
- if (type === "restart-app")
14512
- return "Restart app";
14513
- if (type === "background-app")
14514
- return "Background app";
14515
- if (type === "open-url") {
14516
- return typeof record.url === "string" && record.url.trim() ? `Open URL: ${record.url}` : "Open URL";
14483
+ if (errors3.length > 0) {
14484
+ return {
14485
+ ok: false,
14486
+ errors: errors3.sort((a, b) => (a.line ?? Number.POSITIVE_INFINITY) - (b.line ?? Number.POSITIVE_INFINITY))
14487
+ };
14517
14488
  }
14518
- return type.charAt(0).toUpperCase() + type.slice(1);
14489
+ return {
14490
+ ok: true,
14491
+ name: typeof name === "string" && name.trim().length > 0 ? name.trim() : null,
14492
+ actionLines,
14493
+ script: {
14494
+ version: 1,
14495
+ ...typeof sourceRunId === "string" ? { sourceRunId } : {},
14496
+ savedAt: typeof savedAt === "number" ? savedAt : options.savedAt ?? Date.now(),
14497
+ actions
14498
+ }
14499
+ };
14519
14500
  }
14520
- function actionSummary(action) {
14521
- if (!action || typeof action !== "object")
14522
- return "Step";
14523
- const cycle = readStepCycle(action);
14524
- if (cycle?.action)
14525
- return summarizeActionRecord(cycle.action);
14526
- if (cycle?.decision)
14527
- return summarizeActionRecord(cycle.decision);
14528
- return summarizeActionRecord(action);
14501
+ function formatCaseScriptFileErrors(errors3) {
14502
+ return errors3.map((error) => {
14503
+ const nested = (error.nested ?? []).map((step) => ` → ${step.side} → action ${step.action}`).join("");
14504
+ const where = [
14505
+ error.line != null ? `Line ${error.line}` : null,
14506
+ error.action != null ? `action ${error.action}${nested}` : null
14507
+ ].filter(Boolean).join(", ");
14508
+ return where ? `${where}: ${error.message}` : error.message;
14509
+ }).join(`
14510
+ `);
14529
14511
  }
14530
- function optionalTrimmedString(value) {
14531
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
14512
+ var FLOW_OPTIONS = {
14513
+ collectionStyle: "flow",
14514
+ lineWidth: 0,
14515
+ blockQuote: false,
14516
+ flowCollectionPadding: false
14517
+ };
14518
+ function yamlScalar(value) {
14519
+ if (typeof value === "string" && /[\n\r\t]/.test(value))
14520
+ return JSON.stringify(value);
14521
+ return $stringify([value], FLOW_OPTIONS).trim().slice(1, -1);
14532
14522
  }
14533
- function optionalFiniteNumber(value) {
14534
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
14523
+ function orderedFields(action) {
14524
+ const fields = Object.entries(action).filter(([key, value]) => key !== "type" && value !== undefined);
14525
+ const meaningful = fields.filter(([key, value]) => !(action.type === "assert" && key === "assertion" && value === "visible") && !(action.type === "alert" && key === "alertAction" && value === "accept"));
14526
+ const rank = (key) => {
14527
+ const at = FIELD_ORDER.indexOf(key);
14528
+ return at < 0 ? FIELD_ORDER.length : at;
14529
+ };
14530
+ return meaningful.sort(([a], [b]) => rank(a) - rank(b));
14535
14531
  }
14536
- function formatStepCommand(action) {
14537
- if (!action || typeof action !== "object")
14538
- return null;
14539
- const record = action;
14540
- const type = typeof record.type === "string" ? record.type : null;
14541
- if (type == null || type === "verify" || type === "done" || type === "fail")
14542
- return null;
14543
- if (type === "wait") {
14544
- const ms = optionalFiniteNumber(record.ms) ?? 0;
14545
- return formatSleepShellLine(ms / 1000);
14546
- }
14547
- if (type === "assert") {
14548
- const text = optionalTrimmedString(record.text);
14549
- if (!text)
14550
- return null;
14551
- const assertion = record.assertion === "not-visible" ? "not-visible" : "visible";
14552
- const timeoutMs = optionalFiniteNumber(record.timeoutMs);
14553
- return formatAssertShellLine({
14554
- assertion,
14555
- text,
14556
- ...timeoutMs != null ? { timeoutSeconds: timeoutMs / 1000 } : {}
14532
+ function branchFields(branch) {
14533
+ const fields = branch.id != null ? [["id", branch.id]] : [["text", branch.text]];
14534
+ if (branch.assertion === "not-visible")
14535
+ fields.push(["assertion", branch.assertion]);
14536
+ if (branch.timeoutMs != null)
14537
+ fields.push(["timeoutMs", branch.timeoutMs]);
14538
+ if (branch.reason != null)
14539
+ fields.push(["reason", branch.reason]);
14540
+ return fields;
14541
+ }
14542
+ function formatCaseScriptFileAction(action) {
14543
+ if (action.type === "branch") {
14544
+ const sides = ["then", "else"].flatMap((side) => {
14545
+ const actions = action[side];
14546
+ if (!actions?.length)
14547
+ return [];
14548
+ return `${side}: [${actions.map((inner) => `{ ${formatCaseScriptFileAction(inner)} }`).join(", ")}]`;
14557
14549
  });
14550
+ const body2 = [
14551
+ ...branchFields(action).map(([key, value]) => `${key}: ${yamlScalar(value)}`),
14552
+ ...sides
14553
+ ].join(", ");
14554
+ return `${BRANCH_KEY}: { ${body2} }`;
14558
14555
  }
14559
- let request = null;
14560
- if (type === "tap") {
14561
- request = { kind: "tap" };
14562
- const label = optionalTrimmedString(record.label);
14563
- const id = optionalTrimmedString(record.id);
14564
- const x = optionalFiniteNumber(record.x);
14565
- const y = optionalFiniteNumber(record.y);
14566
- const durationMs = optionalFiniteNumber(record.durationMs);
14567
- if (label)
14568
- request.label = label;
14569
- if (id)
14570
- request.id = id;
14571
- if (x != null)
14572
- request.x = x;
14573
- if (y != null)
14574
- request.y = y;
14575
- if (record.double === true)
14576
- request.double = true;
14577
- if (durationMs != null)
14578
- request.durationMs = durationMs;
14579
- } else if (type === "swipe" || type === "drag") {
14580
- request = { kind: type };
14581
- const x = optionalFiniteNumber(record.x);
14582
- const y = optionalFiniteNumber(record.y);
14583
- const x2 = optionalFiniteNumber(record.x2);
14584
- const y2 = optionalFiniteNumber(record.y2);
14585
- const durationMs = optionalFiniteNumber(record.durationMs);
14586
- if (x != null)
14587
- request.x = x;
14588
- if (y != null)
14589
- request.y = y;
14590
- if (x2 != null)
14591
- request.x2 = x2;
14592
- if (y2 != null)
14593
- request.y2 = y2;
14594
- if (durationMs != null)
14595
- request.durationMs = durationMs;
14596
- } else if (type === "type" || type === "input") {
14597
- request = { kind: "input" };
14598
- const text = optionalTrimmedString(record.text);
14599
- if (text)
14600
- request.text = text;
14601
- } else if (type === "alert") {
14602
- request = {
14603
- kind: "alert",
14604
- alertAction: record.alertAction === "dismiss" ? "dismiss" : "accept"
14605
- };
14606
- } else if (type === "activate-app" || type === "terminate-app" || type === "restart-app") {
14607
- request = { kind: type };
14608
- const appId = optionalTrimmedString(record.appId);
14609
- if (appId)
14610
- request.appId = appId;
14611
- } else if (type === "background-app") {
14612
- request = { kind: "background-app" };
14613
- const seconds = optionalFiniteNumber(record.seconds);
14614
- if (seconds != null)
14615
- request.seconds = seconds;
14616
- } else if (type === "open-url") {
14617
- request = { kind: "open-url" };
14618
- const url = optionalTrimmedString(record.url);
14619
- if (url)
14620
- request.url = url;
14556
+ const fields = orderedFields(action);
14557
+ const shortField = SHORT_FORM_FIELD[action.type];
14558
+ if (action.type === "alert" && fields.length === 0) {
14559
+ return `alert: ${action.alertAction}`;
14621
14560
  }
14622
- return request ? formatActionShellLine(request) : null;
14623
- }
14624
- function stepReasoning(step) {
14625
- const action = step.action && typeof step.action === "object" ? step.action : null;
14626
- const reasonFromAction = typeof action?.reason === "string" && action.reason.trim() ? action.reason.trim() : null;
14627
- const thoughtsFromAction = typeof action?.thoughts === "string" && action.thoughts.trim() ? action.thoughts.trim() : null;
14628
- const reason = reasonFromAction ?? (step.detail?.trim() ? step.detail.trim() : null);
14629
- const thoughts = thoughtsFromAction && thoughtsFromAction !== reason ? thoughtsFromAction : null;
14630
- return { reason, thoughts };
14561
+ const only = fields[0];
14562
+ if (fields.length === 1 && only && only[0] === shortField) {
14563
+ return `${action.type}: ${yamlScalar(only[1])}`;
14564
+ }
14565
+ if (fields.length === 0) {
14566
+ return `${action.type}: {}`;
14567
+ }
14568
+ const body = fields.map(([key, value]) => `${key}: ${yamlScalar(value)}`).join(", ");
14569
+ return `${action.type}: { ${body} }`;
14631
14570
  }
14632
- function toReportStatus(status) {
14633
- if (status === "passed" || status === "errored" || status === "cancelled")
14634
- return status;
14635
- return null;
14571
+ function formatCaseScriptFileItemLines(action, indent) {
14572
+ if (action.type !== "branch")
14573
+ return [`${indent}- ${formatCaseScriptFileAction(action)}`];
14574
+ const fieldIndent = `${indent} `;
14575
+ const lines = [`${indent}- ${BRANCH_KEY}:`];
14576
+ for (const [key, value] of branchFields(action)) {
14577
+ lines.push(`${fieldIndent}${key}: ${yamlScalar(value)}`);
14578
+ }
14579
+ for (const side of ["then", "else"]) {
14580
+ const actions = action[side];
14581
+ if (!actions?.length)
14582
+ continue;
14583
+ lines.push(`${fieldIndent}${side}:`);
14584
+ for (const inner of actions) {
14585
+ lines.push(...formatCaseScriptFileItemLines(inner, `${fieldIndent} `));
14586
+ }
14587
+ }
14588
+ return lines;
14636
14589
  }
14637
- function formatStepPhases(phases) {
14638
- const parts = [
14639
- `capture ${Math.round(phases.captureMs)}ms`,
14640
- `screen ${Math.round(phases.screenMs)}ms`,
14641
- `image ${Math.round(phases.prepareMs)}ms`,
14642
- `decide ${Math.round(phases.decideMs)}ms`,
14643
- `action ${Math.round(phases.actionMs)}ms`,
14644
- `settle ${Math.round(phases.settleMs)}ms`
14645
- ];
14646
- if (phases.decideRetries > 0) {
14647
- parts.push(`retries ${phases.decideRetries}`);
14590
+ function formatCaseScriptFile(script, options = {}) {
14591
+ const lines = ["version: 1"];
14592
+ const name = options.name?.trim();
14593
+ if (name)
14594
+ lines.push(`name: ${yamlScalar(name)}`);
14595
+ if (script.sourceRunId)
14596
+ lines.push(`sourceRunId: ${yamlScalar(script.sourceRunId)}`);
14597
+ lines.push("actions:");
14598
+ for (const action of script.actions) {
14599
+ lines.push(...formatCaseScriptFileItemLines(action, " "));
14648
14600
  }
14649
- const usage = phases.usage ? formatCallUsage(phases.usage) : null;
14650
- if (usage)
14651
- parts.push(usage);
14652
- return parts.join(" · ");
14601
+ return `${lines.join(`
14602
+ `)}
14603
+ `;
14653
14604
  }
14654
- function formatCallUsage(usage) {
14655
- const fields = [
14656
- usage.inputTokens != null ? `in ${usage.inputTokens}` : null,
14657
- usage.cachedInputTokens != null ? `cached ${usage.cachedInputTokens}` : null,
14658
- usage.cacheWriteTokens != null ? `cache write ${usage.cacheWriteTokens}` : null,
14659
- usage.outputTokens != null ? `out ${usage.outputTokens}` : null
14660
- ].filter((field) => field != null);
14661
- if (fields.length === 0)
14662
- return null;
14663
- const cached = usage.cachedInputTokens ?? 0;
14664
- const totalInput = (usage.inputTokens ?? 0) + cached + (usage.cacheWriteTokens ?? 0);
14665
- const share = totalInput > 0 ? ` (${Math.round(cached / totalInput * 100)}% cached)` : "";
14666
- return `usage ${fields.join(", ")}${share}`;
14605
+
14606
+ // ../runner-client/src/screen-check.ts
14607
+ var SCREEN_CHECK_POLL_MS = 400;
14608
+ var ASSERT_EXIT_CODE = { met: 0, notMet: 1, couldNotCheck: 2 };
14609
+ function abortError() {
14610
+ return Object.assign(new Error("Aborted"), { name: "AbortError" });
14667
14611
  }
14668
- function mapCatalogStep(step, screenshotsByStepId) {
14669
- const { reason, thoughts } = stepReasoning(step);
14670
- return {
14671
- id: step.id,
14672
- index: step.idx + 1,
14673
- summary: actionSummary(step.action),
14674
- ok: step.ok,
14675
- latencyMs: step.latencyMs,
14676
- phases: step.phases ?? null,
14677
- detail: step.detail,
14678
- reason,
14679
- thoughts,
14680
- command: step.command ?? formatStepCommand(step.action),
14681
- screenshotBase64: screenshotsByStepId[step.id] ?? null
14682
- };
14612
+ function describeScreenCheck(check) {
14613
+ return check.id != null ? `${check.assertion} id: ${check.id}` : `${check.assertion}: ${check.text}`;
14683
14614
  }
14684
- function buildRunReportFromCatalogRun(run, meta = {}, screenshotsByStepId = {}) {
14685
- const status = toReportStatus(run.status);
14686
- if (!status) {
14687
- throw new Error(`Cannot export report for live run status: ${run.status}`);
14615
+ function screenCheckMissMessage(options) {
14616
+ const seconds = Math.round(options.timeoutMs / 1000);
14617
+ const [kind, target] = options.id != null ? ["id", options.id] : ["text", options.text];
14618
+ return options.assertion === "visible" ? `Expected visible ${kind} not found within ${seconds}s: ${target}` : `Unexpected ${kind} still visible after ${seconds}s: ${target}`;
14619
+ }
14620
+ async function checkScreen(options) {
14621
+ const pollMs = options.pollMs ?? SCREEN_CHECK_POLL_MS;
14622
+ const deadline = options.now() + options.timeoutMs;
14623
+ for (;; ) {
14624
+ if (options.isAborted?.())
14625
+ throw abortError();
14626
+ let readError = null;
14627
+ try {
14628
+ const { elements } = await options.readScreen();
14629
+ const found = screenHasTarget(elements, options);
14630
+ if (options.assertion === "visible" ? found : !found)
14631
+ return { met: true };
14632
+ } catch (error) {
14633
+ if (options.isDeadSession?.(error))
14634
+ throw error;
14635
+ readError = error;
14636
+ }
14637
+ if (options.now() >= deadline) {
14638
+ if (readError) {
14639
+ const seconds = Math.round(options.timeoutMs / 1000);
14640
+ const message = readError instanceof Error ? readError.message : String(readError);
14641
+ throw new Error(`Could not read the screen within ${seconds}s: ${message}`);
14642
+ }
14643
+ return { met: false };
14644
+ }
14645
+ await options.sleep(pollMs);
14688
14646
  }
14689
- const caseTitles = meta.caseTitles ?? {};
14690
- const tests = run.tests.map((test) => ({
14691
- id: test.id,
14692
- title: caseTitles[test.caseId] ?? test.caseId,
14693
- status: test.status,
14694
- executionMode: test.executionMode ?? null,
14695
- error: test.error,
14696
- startedAt: test.startedAt,
14697
- finishedAt: test.finishedAt,
14698
- steps: (test.steps ?? []).map((step) => mapCatalogStep(step, screenshotsByStepId))
14699
- }));
14700
- const primaryTitle = tests[0]?.title ?? "Run";
14701
- return {
14702
- id: run.id,
14703
- source: "catalog",
14704
- status,
14705
- title: primaryTitle,
14706
- appLabel: meta.appLabel ?? null,
14707
- deviceLabel: meta.deviceLabel ?? null,
14708
- platform: run.platform,
14709
- executionMode: run.executionMode,
14710
- screenMode: run.screenMode ?? null,
14711
- lane: run.lane ?? null,
14712
- laneWarning: run.laneWarning ?? null,
14713
- error: run.error,
14714
- createdAt: run.createdAt,
14715
- startedAt: run.startedAt,
14716
- finishedAt: run.finishedAt,
14717
- tests
14718
- };
14719
14647
  }
14720
- function suggestedRunReportBasename(doc) {
14721
- const shortId = doc.id.replace(/[^a-zA-Z0-9]+/g, "").slice(0, 8) || "run";
14722
- const raw = ["yoqa-run", shortId, doc.status].join("-").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
14723
- return raw || "yoqa-run-report";
14648
+
14649
+ // ../runner-client/src/case-script-replay.ts
14650
+ var DEFAULT_ASSERT_TIMEOUT_MS = 5000;
14651
+ function abortError2() {
14652
+ return Object.assign(new Error("Aborted"), { name: "AbortError" });
14724
14653
  }
14725
- function escapeHtml(value) {
14726
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
14654
+ function realSleep(ms, signal) {
14655
+ if (ms <= 0)
14656
+ return Promise.resolve();
14657
+ return new Promise((resolve, reject) => {
14658
+ if (signal?.aborted) {
14659
+ reject(abortError2());
14660
+ return;
14661
+ }
14662
+ const timer = setTimeout(() => {
14663
+ signal?.removeEventListener("abort", onAbort);
14664
+ resolve();
14665
+ }, ms);
14666
+ const onAbort = () => {
14667
+ clearTimeout(timer);
14668
+ reject(abortError2());
14669
+ };
14670
+ signal?.addEventListener("abort", onAbort, { once: true });
14671
+ });
14727
14672
  }
14728
- function formatWhen(ms) {
14729
- if (ms == null)
14730
- return "—";
14731
- return new Date(ms).toLocaleString();
14673
+ function caseScriptActionRequest(action) {
14674
+ switch (action.type) {
14675
+ case "tap": {
14676
+ const request = { kind: "tap" };
14677
+ if (action.label)
14678
+ request.label = action.label;
14679
+ if (action.id)
14680
+ request.id = action.id;
14681
+ if (action.x != null)
14682
+ request.x = action.x;
14683
+ if (action.y != null)
14684
+ request.y = action.y;
14685
+ if (action.double)
14686
+ request.double = true;
14687
+ if (action.durationMs != null)
14688
+ request.durationMs = action.durationMs;
14689
+ return request;
14690
+ }
14691
+ case "swipe":
14692
+ case "drag":
14693
+ return {
14694
+ kind: action.type,
14695
+ x: action.x,
14696
+ y: action.y,
14697
+ x2: action.x2,
14698
+ y2: action.y2,
14699
+ ...action.durationMs != null ? { durationMs: action.durationMs } : {}
14700
+ };
14701
+ case "type":
14702
+ return { kind: "input", text: action.text };
14703
+ case "alert":
14704
+ return { kind: "alert", alertAction: action.alertAction };
14705
+ case "activate-app":
14706
+ case "terminate-app":
14707
+ case "restart-app":
14708
+ return { kind: action.type, appId: action.appId };
14709
+ case "background-app":
14710
+ return {
14711
+ kind: "background-app",
14712
+ ...action.seconds != null ? { seconds: action.seconds } : {}
14713
+ };
14714
+ case "open-url":
14715
+ return { kind: "open-url", url: action.url };
14716
+ case "key":
14717
+ return {
14718
+ kind: "key",
14719
+ key: action.key,
14720
+ ...action.modifiers?.length ? { modifiers: action.modifiers } : {}
14721
+ };
14722
+ case "wait":
14723
+ case "assert":
14724
+ case "branch":
14725
+ return null;
14726
+ }
14732
14727
  }
14733
- function formatDuration(startedAt, finishedAt) {
14734
- if (startedAt == null || finishedAt == null)
14735
- return "—";
14736
- const ms = Math.max(0, finishedAt - startedAt);
14737
- if (ms < 1000)
14738
- return `${ms}ms`;
14739
- const seconds = ms / 1000;
14740
- if (seconds < 60)
14741
- return `${seconds.toFixed(1)}s`;
14742
- const minutes = Math.floor(seconds / 60);
14743
- const rem = Math.round(seconds % 60);
14744
- return `${minutes}m ${rem}s`;
14728
+ function screenCheckTimeoutSeconds(action) {
14729
+ if (action.type === "branch")
14730
+ return (action.timeoutMs ?? BRANCH_DEFAULT_TIMEOUT_MS) / 1000;
14731
+ if (action.timeoutMs == null)
14732
+ return;
14733
+ return Math.max(1, Math.round(action.timeoutMs / 1000));
14745
14734
  }
14746
- function statusAccent(status) {
14747
- if (status === "passed") {
14748
- return { bg: "#dcfce7", fg: "#166534", label: "Passed" };
14749
- }
14750
- if (status === "errored") {
14751
- return { bg: "#fee2e2", fg: "#991b1b", label: "Failed" };
14752
- }
14753
- return { bg: "#e5e7eb", fg: "#374151", label: "Cancelled" };
14754
- }
14755
- function pngDataUri(base64) {
14756
- return `data:image/png;base64,${base64}`;
14757
- }
14758
- function formatRunReportHtml(doc) {
14759
- const accent = statusAccent(doc.status);
14760
- const screenRows = [
14761
- ...doc.screenMode ? [["Screen", doc.screenMode]] : [],
14762
- ...doc.lane ? [["Lane", doc.lane]] : [],
14763
- ...doc.laneWarning ? [["Lane warning", doc.laneWarning]] : []
14764
- ];
14765
- const metaRows = [
14766
- ["Status", accent.label],
14767
- ["Source", doc.source === "catalog" ? "Catalog run" : "Manual Inspector"],
14768
- ["App", doc.appLabel ?? "—"],
14769
- ["Device", doc.deviceLabel ?? "—"],
14770
- ["Platform", doc.platform ?? "—"],
14771
- ["Mode", doc.executionMode ?? "—"],
14772
- ...screenRows,
14773
- ["Started", formatWhen(doc.startedAt ?? doc.createdAt)],
14774
- ["Finished", formatWhen(doc.finishedAt)],
14775
- ["Duration", formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt)],
14776
- ["Run ID", doc.id]
14777
- ];
14778
- const testsHtml = doc.tests.map((test, testIndex) => {
14779
- const testAccent = statusAccent(test.status === "passed" || test.status === "errored" || test.status === "cancelled" ? test.status : doc.status);
14780
- const stepsHtml = test.steps.map((step) => {
14781
- const stepStatus = step.ok ? "Passed" : "Failed";
14782
- const stepColor = step.ok ? "#166534" : "#991b1b";
14783
- const reasonBlock = step.reason ? `<p class="reason">${escapeHtml(step.reason)}</p>` : "";
14784
- const thoughtsBlock = step.thoughts ? `<details class="thoughts"><summary>AI thoughts</summary><p>${escapeHtml(step.thoughts)}</p></details>` : "";
14785
- const detailBlock = step.detail && step.detail !== step.reason ? `<p class="detail">${escapeHtml(step.detail)}</p>` : "";
14786
- const shot = step.screenshotBase64 ? `<img class="shot" alt="Step ${step.index} screenshot" src="${pngDataUri(step.screenshotBase64)}" />` : `<p class="muted">No screenshot</p>`;
14787
- const latency = step.latencyMs != null ? `<span class="muted">${step.latencyMs}ms</span>` : "";
14788
- const phases = step.phases ? `<p class="muted phases">Phases: ${escapeHtml(formatStepPhases(step.phases))}</p>` : "";
14789
- const commandBlock = step.command ? `<pre class="command"><code>${escapeHtml(step.command)}</code></pre>` : "";
14790
- return `
14791
- <article class="step ${step.ok ? "ok" : "fail"}">
14792
- <header>
14793
- <span class="idx">${step.index}</span>
14794
- <div>
14795
- <h3>${escapeHtml(step.summary)}</h3>
14796
- <p class="step-status" style="color:${stepColor}">${stepStatus} ${latency}</p>
14797
- </div>
14798
- </header>
14799
- ${commandBlock}
14800
- ${phases}
14801
- ${reasonBlock}
14802
- ${detailBlock}
14803
- ${thoughtsBlock}
14804
- ${shot}
14805
- </article>`;
14806
- }).join(`
14807
- `);
14808
- return `
14809
- <section class="test">
14810
- <h2>
14811
- <span class="test-num">${testIndex + 1}</span>
14812
- ${escapeHtml(test.title)}
14813
- <span class="pill" style="background:${testAccent.bg};color:${testAccent.fg}">${escapeHtml(testAccent.label)}</span>
14814
- </h2>
14815
- ${test.error ? `<p class="error">${escapeHtml(test.error)}</p>` : ""}
14816
- ${test.executionMode ? `<p class="muted">Mode: ${escapeHtml(test.executionMode)}</p>` : ""}
14817
- <div class="steps">${stepsHtml || `<p class="muted">No steps recorded</p>`}</div>
14818
- </section>`;
14819
- }).join(`
14820
- `);
14821
- const metaHtml = metaRows.map(([label, value]) => `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`).join(`
14822
- `);
14823
- return `<!DOCTYPE html>
14824
- <html lang="en">
14825
- <head>
14826
- <meta charset="utf-8" />
14827
- <meta name="viewport" content="width=device-width, initial-scale=1" />
14828
- <title>Yoqa Report — ${escapeHtml(doc.title)} (${escapeHtml(accent.label)})</title>
14829
- <style>
14830
- :root { color-scheme: light; }
14831
- body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f8fafc; color: #0f172a; line-height: 1.5; }
14832
- .wrap { max-width: 52rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
14833
- .banner { border-radius: 1rem; padding: 1.25rem 1.5rem; background: ${accent.bg}; color: ${accent.fg}; margin-bottom: 1.5rem; }
14834
- .banner h1 { margin: 0 0 0.35rem; font-size: 1.5rem; }
14835
- .banner p { margin: 0; opacity: 0.9; }
14836
- table.meta { width: 100%; border-collapse: collapse; background: #fff; border-radius: 0.75rem; overflow: hidden; box-shadow: 0 1px 2px rgb(0 0 0 / 0.06); margin-bottom: 2rem; }
14837
- table.meta th, table.meta td { text-align: left; padding: 0.65rem 1rem; border-bottom: 1px solid #e2e8f0; font-size: 0.9rem; }
14838
- table.meta th { width: 8rem; color: #64748b; font-weight: 600; }
14839
- .error { background: #fee2e2; color: #991b1b; padding: 0.75rem 1rem; border-radius: 0.5rem; }
14840
- .test { margin-bottom: 2.5rem; }
14841
- .test h2 { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 1.15rem; }
14842
- .test-num { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 999px; background: #0f172a; color: #fff; font-size: 0.8rem; }
14843
- .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.15rem 0.65rem; font-size: 0.75rem; font-weight: 600; }
14844
- .step { background: #fff; border: 1px solid #e2e8f0; border-radius: 0.75rem; padding: 1rem; margin: 0.75rem 0; }
14845
- .step.fail { border-color: #fecaca; }
14846
- .step header { display: flex; gap: 0.75rem; align-items: flex-start; }
14847
- .step .idx { flex-shrink: 0; width: 1.75rem; height: 1.75rem; border-radius: 999px; background: #e2e8f0; display: inline-flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; }
14848
- .step.ok .idx { background: #bbf7d0; color: #166534; }
14849
- .step.fail .idx { background: #fecaca; color: #991b1b; }
14850
- .step h3 { margin: 0; font-size: 1rem; }
14851
- .step-status { margin: 0.15rem 0 0; font-size: 0.8rem; font-weight: 600; }
14852
- .command { margin: 0.5rem 0 0; padding: 0.4rem 0.65rem; background: #f1f5f9; border-radius: 0.4rem; font-size: 0.8rem; overflow-x: auto; }
14853
- .command code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
14854
- .reason, .detail { margin: 0.5rem 0 0; color: #475569; font-size: 0.9rem; }
14855
- .thoughts { margin-top: 0.5rem; font-size: 0.85rem; color: #64748b; }
14856
- .thoughts p { margin: 0.35rem 0 0; white-space: pre-wrap; }
14857
- .shot { display: block; max-width: 100%; width: min(22rem, 100%); margin-top: 0.75rem; border-radius: 0.75rem; border: 1px solid #e2e8f0; }
14858
- .muted { color: #94a3b8; font-size: 0.85rem; }
14859
- footer { margin-top: 2rem; color: #94a3b8; font-size: 0.8rem; }
14860
- @media print {
14861
- body { background: #fff; }
14862
- .shot { max-height: 60vh; object-fit: contain; }
14863
- }
14864
- </style>
14865
- </head>
14866
- <body>
14867
- <div class="wrap">
14868
- <header class="banner">
14869
- <h1>${escapeHtml(doc.title)}</h1>
14870
- <p>${escapeHtml(accent.label)} · Yoqa end-to-end report</p>
14871
- </header>
14872
- ${doc.error ? `<p class="error">${escapeHtml(doc.error)}</p>` : ""}
14873
- <table class="meta">${metaHtml}</table>
14874
- ${testsHtml}
14875
- <footer>Generated by Yoqa</footer>
14876
- </div>
14877
- </body>
14878
- </html>
14879
- `;
14880
- }
14881
- function escapeMd(value) {
14882
- return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
14883
- }
14884
- function formatRunReportMarkdown(doc) {
14885
- const accent = statusAccent(doc.status);
14886
- const lines = [
14887
- `# Yoqa Report — ${doc.title}`,
14888
- "",
14889
- `**Status:** ${accent.label}`,
14890
- "",
14891
- "| Field | Value |",
14892
- "| --- | --- |",
14893
- `| Source | ${doc.source === "catalog" ? "Catalog run" : "Manual Inspector"} |`,
14894
- `| App | ${escapeMd(doc.appLabel ?? "—")} |`,
14895
- `| Device | ${escapeMd(doc.deviceLabel ?? "—")} |`,
14896
- `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
14897
- `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
14898
- ...doc.screenMode ? [`| Screen | ${escapeMd(doc.screenMode)} |`] : [],
14899
- ...doc.lane ? [`| Lane | ${escapeMd(doc.lane)} |`] : [],
14900
- ...doc.laneWarning ? [`| Lane warning | ${escapeMd(doc.laneWarning)} |`] : [],
14901
- `| Started | ${escapeMd(formatWhen(doc.startedAt ?? doc.createdAt))} |`,
14902
- `| Finished | ${escapeMd(formatWhen(doc.finishedAt))} |`,
14903
- `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
14904
- `| Run ID | \`${doc.id}\` |`,
14905
- ""
14906
- ];
14907
- if (doc.error) {
14908
- lines.push(`> **Error:** ${doc.error}`, "");
14909
- }
14910
- doc.tests.forEach((test, testIndex) => {
14911
- const testLabel = test.status === "passed" ? "Passed" : test.status === "errored" ? "Failed" : test.status === "cancelled" ? "Cancelled" : test.status;
14912
- lines.push(`## ${testIndex + 1}. ${test.title} (${testLabel})`, "");
14913
- if (test.executionMode)
14914
- lines.push(`Mode: \`${test.executionMode}\``, "");
14915
- if (test.error)
14916
- lines.push(`**Error:** ${test.error}`, "");
14917
- if (test.steps.length === 0) {
14918
- lines.push("_No steps recorded_", "");
14919
- return;
14920
- }
14921
- for (const step of test.steps) {
14922
- lines.push(`### Step ${step.index}: ${step.summary}`);
14923
- lines.push("");
14924
- lines.push(`- Result: **${step.ok ? "Passed" : "Failed"}**`);
14925
- if (step.command)
14926
- lines.push(`- Command: \`${step.command.replace(/`/g, "'")}\``);
14927
- if (step.latencyMs != null)
14928
- lines.push(`- Latency: ${step.latencyMs}ms`);
14929
- if (step.phases)
14930
- lines.push(`- Phases: ${formatStepPhases(step.phases)}`);
14931
- if (step.reason)
14932
- lines.push(`- Reason: ${step.reason}`);
14933
- if (step.detail && step.detail !== step.reason)
14934
- lines.push(`- Detail: ${step.detail}`);
14935
- if (step.thoughts) {
14936
- lines.push("", "<details><summary>AI thoughts</summary>", "", step.thoughts, "", "</details>");
14937
- }
14938
- if (step.screenshotBase64) {
14939
- lines.push("", `![Step ${step.index} screenshot](${pngDataUri(step.screenshotBase64)})`);
14940
- } else {
14941
- lines.push("", "_No screenshot_");
14942
- }
14943
- lines.push("");
14944
- }
14945
- });
14946
- lines.push("---", "", "_Generated by Yoqa_", "");
14947
- return lines.join(`
14948
- `);
14949
- }
14950
- function formatRunReportGithubSummary(doc) {
14951
- const accent = statusAccent(doc.status);
14952
- const passed = doc.tests.filter((test) => test.status === "passed").length;
14953
- const failed = doc.tests.filter((test) => test.status === "errored").length;
14954
- const cancelled = doc.tests.filter((test) => test.status === "cancelled").length;
14955
- const failedSteps = doc.tests.flatMap((test) => test.steps.filter((step) => !step.ok).map((step) => ({ test, step })));
14956
- const lines = [
14957
- `## Yoqa Report — ${doc.title}`,
14958
- "",
14959
- `**${accent.label}** · ${doc.tests.length} test${doc.tests.length === 1 ? "" : "s"} · ${passed} passed · ${failed} failed · ${cancelled} cancelled`,
14960
- "",
14961
- "| Field | Value |",
14962
- "| --- | --- |",
14963
- `| App | ${escapeMd(doc.appLabel ?? "—")} |`,
14964
- `| Device | ${escapeMd(doc.deviceLabel ?? "—")} |`,
14965
- `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
14966
- `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
14967
- ...doc.screenMode ? [`| Screen | ${escapeMd(doc.screenMode)} |`] : [],
14968
- ...doc.lane ? [`| Lane | ${escapeMd(doc.lane)} |`] : [],
14969
- ...doc.laneWarning ? [`| Lane warning | ${escapeMd(doc.laneWarning)} |`] : [],
14970
- `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
14971
- `| Run ID | \`${doc.id}\` |`,
14972
- ""
14973
- ];
14974
- if (doc.error) {
14975
- lines.push(`> **Error:** ${doc.error}`, "");
14976
- }
14977
- if (doc.tests.length > 0) {
14978
- lines.push("### Tests", "", "| # | Test | Status | Steps |", "| --- | --- | --- | --- |");
14979
- doc.tests.forEach((test, testIndex) => {
14980
- const testLabel = test.status === "passed" ? "Passed" : test.status === "errored" ? "Failed" : test.status === "cancelled" ? "Cancelled" : test.status;
14981
- lines.push(`| ${testIndex + 1} | ${escapeMd(test.title)} | ${testLabel} | ${test.steps.length} |`);
14982
- });
14983
- lines.push("");
14984
- }
14985
- if (failedSteps.length > 0) {
14986
- lines.push("### Failed steps", "");
14987
- for (const { test, step } of failedSteps) {
14988
- lines.push(`- **${escapeMd(test.title)}** · step ${step.index}: ${escapeMd(step.summary)}`);
14989
- if (step.command)
14990
- lines.push(` - Command: \`${escapeMd(step.command)}\``);
14991
- if (step.reason)
14992
- lines.push(` - Reason: ${escapeMd(step.reason)}`);
14993
- if (step.detail && step.detail !== step.reason) {
14994
- lines.push(` - Detail: ${escapeMd(step.detail)}`);
14995
- }
14996
- }
14997
- lines.push("");
14998
- }
14999
- lines.push("Download the `yoqa-report` artifact for the full HTML report with screenshots.", "");
15000
- return lines.join(`
15001
- `);
15002
- }
15003
- // ../../node_modules/yaml/dist/index.js
15004
- var composer = require_composer();
15005
- var Document = require_Document();
15006
- var Schema = require_Schema();
15007
- var errors2 = require_errors();
15008
- var Alias = require_Alias();
15009
- var identity = require_identity();
15010
- var Pair = require_Pair();
15011
- var Scalar = require_Scalar();
15012
- var YAMLMap = require_YAMLMap();
15013
- var YAMLSeq = require_YAMLSeq();
15014
- var cst = require_cst();
15015
- var lexer = require_lexer();
15016
- var lineCounter = require_line_counter();
15017
- var parser = require_parser();
15018
- var publicApi = require_public_api();
15019
- var visit = require_visit();
15020
- var $Composer = composer.Composer;
15021
- var $Document = Document.Document;
15022
- var $Schema = Schema.Schema;
15023
- var $YAMLError = errors2.YAMLError;
15024
- var $YAMLParseError = errors2.YAMLParseError;
15025
- var $YAMLWarning = errors2.YAMLWarning;
15026
- var $Alias = Alias.Alias;
15027
- var $isAlias = identity.isAlias;
15028
- var $isCollection = identity.isCollection;
15029
- var $isDocument = identity.isDocument;
15030
- var $isMap = identity.isMap;
15031
- var $isNode = identity.isNode;
15032
- var $isPair = identity.isPair;
15033
- var $isScalar = identity.isScalar;
15034
- var $isSeq = identity.isSeq;
15035
- var $Pair = Pair.Pair;
15036
- var $Scalar = Scalar.Scalar;
15037
- var $YAMLMap = YAMLMap.YAMLMap;
15038
- var $YAMLSeq = YAMLSeq.YAMLSeq;
15039
- var $Lexer = lexer.Lexer;
15040
- var $LineCounter = lineCounter.LineCounter;
15041
- var $Parser = parser.Parser;
15042
- var $parse = publicApi.parse;
15043
- var $parseAllDocuments = publicApi.parseAllDocuments;
15044
- var $parseDocument = publicApi.parseDocument;
15045
- var $stringify = publicApi.stringify;
15046
- var $visit = visit.visit;
15047
- var $visitAsync = visit.visitAsync;
15048
-
15049
- // ../runner-client/src/case-script-file.ts
15050
- var ACTION_TYPES = Object.keys(caseScriptActionSchemaByType);
15051
- var SHORT_FORM_FIELD = {
15052
- tap: "label",
15053
- type: "text",
15054
- wait: "ms",
15055
- assert: "text",
15056
- alert: "alertAction",
15057
- "open-url": "url",
15058
- "activate-app": "appId",
15059
- "terminate-app": "appId",
15060
- "restart-app": "appId",
15061
- "background-app": "seconds"
15062
- };
15063
- var TEXT_SHORT_FORM_FIELDS = new Set(["label", "text", "alertAction", "url", "appId"]);
15064
- var FIELD_ORDER = [
15065
- "label",
15066
- "id",
15067
- "text",
15068
- "appId",
15069
- "url",
15070
- "assertion",
15071
- "alertAction",
15072
- "x",
15073
- "y",
15074
- "x2",
15075
- "y2",
15076
- "ms",
15077
- "seconds",
15078
- "timeoutMs",
15079
- "double",
15080
- "durationMs",
15081
- "reason"
15082
- ];
15083
- var TOP_LEVEL_KEYS = new Set(["version", "name", "sourceRunId", "savedAt", "actions"]);
15084
- function isActionType(value) {
15085
- return typeof value === "string" && ACTION_TYPES.includes(value);
14735
+ function caseScriptActionCommand(action) {
14736
+ if (action.type === "wait") {
14737
+ const seconds = action.ms / 1000;
14738
+ return `sleep ${Number.isInteger(seconds) ? seconds : Number(seconds.toFixed(3))}`;
14739
+ }
14740
+ if (action.type === "assert" || action.type === "branch") {
14741
+ const timeoutSeconds = screenCheckTimeoutSeconds(action);
14742
+ const timeout = timeoutSeconds != null ? ` --timeout ${timeoutSeconds}` : "";
14743
+ return ["yoqa", "assert", action.assertion, ...assertTargetFlags(action)].join(" ") + timeout;
14744
+ }
14745
+ const request = caseScriptActionRequest(action);
14746
+ return request ? formatActionShellLine(request) : action.type;
15086
14747
  }
15087
- function actionFieldNames(type) {
15088
- const schema = caseScriptActionSchemaByType[type];
15089
- const object = schema instanceof exports_external.ZodEffects ? schema.innerType() : schema;
15090
- return new Set(Object.keys(object.shape).filter((key) => key !== "type"));
14748
+ function formatCaseScriptStepPath(path) {
14749
+ return [
14750
+ `action ${path.index + 1}`,
14751
+ ...path.nested.map((step) => `${step.side} → action ${step.index + 1}`)
14752
+ ].join(" → ");
15091
14753
  }
15092
- function lineOf(node, lineCounter2) {
15093
- const start = node?.range?.[0];
15094
- return start == null ? null : lineCounter2.linePos(start).line;
14754
+ function formatCaseScriptReplayStep(event) {
14755
+ const { action } = event;
14756
+ const line = action.type === "branch" ? `if ${describeScreenCheck(action)}${event.outcome ? ` → ${event.outcome}` : ""}` : formatCaseScriptFileAction(action);
14757
+ return `${" ".repeat(event.path.nested.length)}${line}`;
15095
14758
  }
15096
- function zodMessage(error) {
15097
- return error.issues.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
14759
+ function replayErrorMessage(error, signal) {
14760
+ if (signal?.aborted || error instanceof Error && error.name === "AbortError")
14761
+ return "Aborted";
14762
+ if (error instanceof Error)
14763
+ return error.message;
14764
+ return String(error);
15098
14765
  }
15099
- function readAction(raw, legacy) {
15100
- if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
15101
- return { ok: false, message: "expected an action such as `- tap: Home`" };
15102
- }
15103
- const entries = Object.entries(raw);
15104
- let type;
15105
- let fields;
15106
- if (legacy) {
15107
- const declared = raw.type;
15108
- if (!isActionType(declared)) {
15109
- return { ok: false, message: `unknown action type ${JSON.stringify(declared)}` };
15110
- }
15111
- type = declared;
15112
- fields = Object.fromEntries(entries.filter(([key]) => key !== "type"));
15113
- } else {
15114
- if (entries.length !== 1) {
15115
- return {
15116
- ok: false,
15117
- message: `an action has exactly one key (its name); found ${entries.map(([key2]) => key2).join(", ") || "none"}`
15118
- };
15119
- }
15120
- const [key, value] = entries[0];
15121
- if (!isActionType(key)) {
15122
- return {
15123
- ok: false,
15124
- message: `unknown action "${key}" (expected one of: ${ACTION_TYPES.join(", ")})`
15125
- };
14766
+ async function runCaseScript(client, script, options = {}) {
14767
+ const sleep = options.sleep ?? realSleep;
14768
+ const now = options.now ?? Date.now;
14769
+ const pollMs = options.assertPollMs ?? SCREEN_CHECK_POLL_MS;
14770
+ let total = script.actions.length;
14771
+ let completed = 0;
14772
+ let position = 0;
14773
+ const check = async (action, timeoutMs) => checkScreen({
14774
+ readScreen: () => client.getScreen(),
14775
+ text: action.text,
14776
+ id: action.id,
14777
+ assertion: action.assertion,
14778
+ timeoutMs,
14779
+ pollMs,
14780
+ sleep: (ms) => sleep(ms, options.signal),
14781
+ now,
14782
+ isAborted: () => options.signal?.aborted ?? false
14783
+ });
14784
+ const assertText = async (action) => {
14785
+ const timeoutMs = action.timeoutMs ?? options.defaultAssertTimeoutMs ?? DEFAULT_ASSERT_TIMEOUT_MS;
14786
+ const { met } = await check(action, timeoutMs);
14787
+ if (!met)
14788
+ throw new Error(screenCheckMissMessage({ ...action, timeoutMs }));
14789
+ };
14790
+ const emit = (path, stepPosition, action, status, extra = {}) => options.onStep?.({
14791
+ index: path.index,
14792
+ path,
14793
+ position: stepPosition,
14794
+ total,
14795
+ action,
14796
+ status,
14797
+ ...extra
14798
+ });
14799
+ const skipFrom = async (actions, from, at) => {
14800
+ for (let index = from;index < actions.length; index++) {
14801
+ const action = actions[index];
14802
+ if (action)
14803
+ await emit(at(index), position++, action, "skipped");
15126
14804
  }
15127
- type = key;
15128
- if (value == null) {
15129
- fields = {};
15130
- } else if (typeof value === "object" && !Array.isArray(value)) {
15131
- fields = value;
15132
- } else if (Array.isArray(value)) {
15133
- return { ok: false, message: `${type}: expected a value or a map of fields, not a list` };
15134
- } else {
15135
- const field = SHORT_FORM_FIELD[type];
15136
- if (!field) {
15137
- return { ok: false, message: `${type} has no short form; write it as a map of fields` };
14805
+ };
14806
+ const replay = async (actions, at) => {
14807
+ for (let index = 0;index < actions.length; index++) {
14808
+ const action = actions[index];
14809
+ if (!action)
14810
+ continue;
14811
+ const path = at(index);
14812
+ if (options.signal?.aborted) {
14813
+ await skipFrom(actions, index, at);
14814
+ return { path, error: "Aborted" };
14815
+ }
14816
+ const step = position++;
14817
+ await emit(path, step, action, "running");
14818
+ let side = null;
14819
+ try {
14820
+ let response;
14821
+ if (action.type === "wait") {
14822
+ await sleep(action.ms, options.signal);
14823
+ } else if (action.type === "assert") {
14824
+ await assertText(action);
14825
+ } else if (action.type === "branch") {
14826
+ const timeoutMs = action.timeoutMs ?? options.defaultBranchTimeoutMs ?? BRANCH_DEFAULT_TIMEOUT_MS;
14827
+ const { met } = await check(action, timeoutMs);
14828
+ const outcome = met ? "then" : "else";
14829
+ side = { outcome, actions: (met ? action.then : action.else) ?? [] };
14830
+ total += side.actions.length;
14831
+ } else {
14832
+ const request = caseScriptActionRequest(action);
14833
+ if (request)
14834
+ response = await client.performAction(request);
14835
+ if (options.pauseAfterActionMs)
14836
+ await sleep(options.pauseAfterActionMs, options.signal);
14837
+ }
14838
+ completed += 1;
14839
+ await emit(path, step, action, "ok", {
14840
+ ...response ? { response } : {},
14841
+ ...side ? { outcome: side.outcome } : {}
14842
+ });
14843
+ } catch (error) {
14844
+ const message = replayErrorMessage(error, options.signal);
14845
+ await emit(path, step, action, "error", { error: message });
14846
+ await skipFrom(actions, index + 1, at);
14847
+ return { path, error: message };
14848
+ }
14849
+ if (side) {
14850
+ const outcome = side.outcome;
14851
+ const failure2 = await replay(side.actions, (inner) => ({
14852
+ index: path.index,
14853
+ nested: [...path.nested, { side: outcome, index: inner }]
14854
+ }));
14855
+ if (failure2) {
14856
+ await skipFrom(actions, index + 1, at);
14857
+ return failure2;
14858
+ }
15138
14859
  }
15139
- fields = {
15140
- [field]: TEXT_SHORT_FORM_FIELDS.has(field) && typeof value !== "string" ? String(value) : value
15141
- };
15142
14860
  }
15143
- }
15144
- const allowed = actionFieldNames(type);
15145
- const unknown = Object.keys(fields).filter((key) => !allowed.has(key));
15146
- if (unknown.length > 0) {
14861
+ return null;
14862
+ };
14863
+ const failure = await replay(script.actions, (index) => ({ index, nested: [] }));
14864
+ if (failure) {
15147
14865
  return {
15148
14866
  ok: false,
15149
- message: `${type}: unknown field ${unknown.map((key) => `"${key}"`).join(", ")} (allowed: ${[...allowed].join(", ")})`
14867
+ completed,
14868
+ total,
14869
+ failedIndex: failure.path.index,
14870
+ failedPath: failure.path,
14871
+ error: failure.error
15150
14872
  };
15151
14873
  }
15152
- const parsed = caseScriptActionSchemaByType[type].safeParse({ type, ...fields });
15153
- if (!parsed.success) {
15154
- return { ok: false, message: `${type}: ${zodMessage(parsed.error)}` };
14874
+ return { ok: true, completed, total };
14875
+ }
14876
+
14877
+ // ../runner-client/src/script-format.ts
14878
+ function shellSingleQuote2(value) {
14879
+ return `'${value.replace(/'/g, `'\\''`)}'`;
14880
+ }
14881
+ function actionToShellLine(action) {
14882
+ if (action.type === "tap") {
14883
+ const parts = ["yoqa", "action", "tap"];
14884
+ if (action.label) {
14885
+ parts.push("--label", shellSingleQuote2(action.label));
14886
+ } else if (action.id) {
14887
+ parts.push("--id", shellSingleQuote2(action.id));
14888
+ } else {
14889
+ parts.push("--x", String(Math.round(action.x ?? 0)), "--y", String(Math.round(action.y ?? 0)));
14890
+ }
14891
+ if (action.double)
14892
+ parts.push("--double");
14893
+ if (action.durationMs != null)
14894
+ parts.push("--duration", String(Math.round(action.durationMs)));
14895
+ return parts.join(" ");
15155
14896
  }
15156
- return { ok: true, action: parsed.data };
14897
+ if (action.type === "swipe") {
14898
+ const parts = [
14899
+ "yoqa",
14900
+ "action",
14901
+ "swipe",
14902
+ "--x",
14903
+ String(Math.round(action.x)),
14904
+ "--y",
14905
+ String(Math.round(action.y)),
14906
+ "--x2",
14907
+ String(Math.round(action.x2)),
14908
+ "--y2",
14909
+ String(Math.round(action.y2))
14910
+ ];
14911
+ if (action.durationMs != null) {
14912
+ parts.push("--duration", String(Math.round(action.durationMs)));
14913
+ }
14914
+ return parts.join(" ");
14915
+ }
14916
+ if (action.type === "drag") {
14917
+ const parts = [
14918
+ "yoqa",
14919
+ "action",
14920
+ "drag",
14921
+ "--x",
14922
+ String(Math.round(action.x)),
14923
+ "--y",
14924
+ String(Math.round(action.y)),
14925
+ "--x2",
14926
+ String(Math.round(action.x2)),
14927
+ "--y2",
14928
+ String(Math.round(action.y2))
14929
+ ];
14930
+ if (action.durationMs != null) {
14931
+ parts.push("--duration", String(Math.round(action.durationMs)));
14932
+ }
14933
+ return parts.join(" ");
14934
+ }
14935
+ if (action.type === "type") {
14936
+ return `yoqa action input --text ${shellSingleQuote2(action.text)}`;
14937
+ }
14938
+ if (action.type === "assert") {
14939
+ const timeoutSeconds = action.timeoutMs != null ? Math.max(1, Math.round(action.timeoutMs / 1000)) : undefined;
14940
+ const parts = ["yoqa", "assert", action.assertion, "--text", shellSingleQuote2(action.text)];
14941
+ if (timeoutSeconds != null) {
14942
+ parts.push("--timeout", String(timeoutSeconds));
14943
+ }
14944
+ return parts.join(" ");
14945
+ }
14946
+ if (action.type === "alert") {
14947
+ return action.alertAction === "dismiss" ? "yoqa action alert --dismiss" : "yoqa action alert";
14948
+ }
14949
+ if (action.type === "activate-app" || action.type === "terminate-app" || action.type === "restart-app") {
14950
+ return `yoqa action ${action.type} --app-id ${shellSingleQuote2(action.appId)}`;
14951
+ }
14952
+ if (action.type === "background-app") {
14953
+ return action.seconds != null ? `yoqa action background-app --seconds ${action.seconds}` : "yoqa action background-app";
14954
+ }
14955
+ if (action.type === "open-url") {
14956
+ return `yoqa action open-url --url ${shellSingleQuote2(action.url)}`;
14957
+ }
14958
+ if (action.type === "key") {
14959
+ return action.modifiers?.length ? `yoqa action key ${action.key} --modifiers ${action.modifiers.join(",")}` : `yoqa action key ${action.key}`;
14960
+ }
14961
+ if (action.type === "branch") {
14962
+ const check = caseScriptActionCommand(action);
14963
+ const side = (actions) => actions.map((inner) => indentLines(actionToShellLine(inner)));
14964
+ return [
14965
+ `if ${BRANCH_HELPER} ${check}; then`,
14966
+ ...side(action.then),
14967
+ ...action.else?.length ? ["else", ...side(action.else)] : [],
14968
+ "fi"
14969
+ ].join(`
14970
+ `);
14971
+ }
14972
+ const seconds = Math.max(0.1, action.ms / 1000);
14973
+ return `sleep ${seconds}`;
15157
14974
  }
15158
- function documentErrors(doc, lineCounter2) {
15159
- return doc.errors.map((error) => ({
15160
- line: lineCounter2.linePos(error.pos[0]).line,
15161
- action: null,
15162
- message: error.message
15163
- }));
14975
+ var BRANCH_HELPER = "yoqa_branch";
14976
+ var BRANCH_HELPER_LINES = [
14977
+ "# A Branch's check: 0 runs `then`, 1 runs `else`, anything else stops the script.",
14978
+ `${BRANCH_HELPER}() {`,
14979
+ " local status=0",
14980
+ ' "$@" || status=$?',
14981
+ ' [ "$status" -le 1 ] || exit "$status"',
14982
+ ' return "$status"',
14983
+ "}",
14984
+ ""
14985
+ ];
14986
+ function indentLines(text) {
14987
+ return text.split(`
14988
+ `).map((line) => ` ${line}`).join(`
14989
+ `);
15164
14990
  }
15165
- function parseCaseScriptFile(text, options = {}) {
15166
- const lineCounter2 = new $LineCounter;
15167
- const doc = $parseDocument(text, { lineCounter: lineCounter2, prettyErrors: false });
15168
- if (doc.errors.length > 0) {
15169
- return { ok: false, errors: documentErrors(doc, lineCounter2) };
14991
+ function formatCaseScriptShell(script, meta = {}) {
14992
+ const titleParts = [
14993
+ meta.caseNumber != null ? `#${meta.caseNumber}` : null,
14994
+ meta.caseName?.trim() || null
14995
+ ].filter(Boolean);
14996
+ const title = titleParts.length > 0 ? titleParts.join(" ") : "exported script";
14997
+ const lines = [
14998
+ "#!/usr/bin/env bash",
14999
+ `# Yoqa exported script — ${title}`,
15000
+ "# Requires an active device session: yoqa devices connect <id>",
15001
+ "# Prefer the Case Script file: `yoqa script run <file.yoqa.yaml>`.",
15002
+ "set -euo pipefail",
15003
+ "",
15004
+ ...script.actions.some((action) => action.type === "branch") ? BRANCH_HELPER_LINES : [],
15005
+ ...script.actions.map(actionToShellLine),
15006
+ ""
15007
+ ];
15008
+ return lines.join(`
15009
+ `);
15010
+ }
15011
+ // ../runner-client/src/viewport.ts
15012
+ var VIEWPORT_PRESETS = {
15013
+ desktop: { name: "desktop", width: 1280, height: 800, deviceScaleFactor: 1, mobile: false },
15014
+ mobile: { name: "mobile", width: 390, height: 844, deviceScaleFactor: 3, mobile: true }
15015
+ };
15016
+ function formatViewportPreset(name) {
15017
+ const { width, height } = VIEWPORT_PRESETS[name];
15018
+ return `${name} (${width}×${height})`;
15019
+ }
15020
+
15021
+ // ../runner-client/src/run-report.ts
15022
+ function readStepCycle(action) {
15023
+ if (!action || typeof action !== "object" || !("cycle" in action))
15024
+ return null;
15025
+ const cycle = action.cycle;
15026
+ if (!cycle || typeof cycle !== "object")
15027
+ return null;
15028
+ const record = cycle;
15029
+ const asRecord = (value) => value && typeof value === "object" ? value : null;
15030
+ const verify = asRecord(record.verify);
15031
+ if (!verify || typeof verify.type !== "string" || !verify.type)
15032
+ return null;
15033
+ return {
15034
+ action: asRecord(record.action),
15035
+ decision: asRecord(record.decision),
15036
+ verify: {
15037
+ type: verify.type,
15038
+ reason: typeof verify.reason === "string" ? verify.reason : null,
15039
+ thoughts: typeof verify.thoughts === "string" ? verify.thoughts : null
15040
+ }
15041
+ };
15042
+ }
15043
+ function summarizeActionRecord(record) {
15044
+ const type = typeof record.type === "string" ? record.type : "step";
15045
+ if (type === "tap") {
15046
+ if (typeof record.label === "string" && record.label.trim())
15047
+ return `Tap: ${record.label}`;
15048
+ if (typeof record.id === "string" && record.id.trim())
15049
+ return `Tap #${record.id}`;
15050
+ return "Tap";
15170
15051
  }
15171
- const root = doc.contents;
15172
- if (!$isMap(root)) {
15173
- return {
15174
- ok: false,
15175
- errors: [
15176
- {
15177
- line: lineOf(root, lineCounter2) ?? 1,
15178
- action: null,
15179
- message: "expected a Case Script with `version: 1` and `actions:`"
15180
- }
15181
- ]
15182
- };
15052
+ if (type === "type")
15053
+ return `Type${typeof record.text === "string" ? `: ${record.text}` : ""}`;
15054
+ if (type === "wait")
15055
+ return "Wait";
15056
+ if (type === "alert") {
15057
+ return record.alertAction === "dismiss" ? "Dismiss alert" : "Accept alert";
15058
+ }
15059
+ if (type === "assert") {
15060
+ const assertion = typeof record.assertion === "string" ? record.assertion : "visible";
15061
+ const text = typeof record.text === "string" ? record.text : "";
15062
+ return text ? `Assert ${assertion}: ${text}` : `Assert ${assertion}`;
15183
15063
  }
15184
- const errors3 = [];
15185
- const top = root.toJS(doc);
15186
- for (const pair of root.items) {
15187
- const key = $isScalar(pair.key) ? pair.key.value : pair.key;
15188
- if (typeof key !== "string" || !TOP_LEVEL_KEYS.has(key)) {
15189
- errors3.push({
15190
- line: lineOf(pair.key, lineCounter2),
15191
- action: null,
15192
- message: `unknown key "${String(key)}" (allowed: ${[...TOP_LEVEL_KEYS].join(", ")})`
15193
- });
15194
- }
15064
+ if (type === "branch") {
15065
+ const assertion = record.assertion === "not-visible" ? "not-visible" : "visible";
15066
+ const id = typeof record.id === "string" ? record.id : undefined;
15067
+ const text = typeof record.text === "string" ? record.text : "";
15068
+ const outcome = typeof record.outcome === "string" ? ` → ${record.outcome}` : "";
15069
+ return `If ${describeScreenCheck({ assertion, text, id })}${outcome}`;
15195
15070
  }
15196
- const versionNode = root.get("version", true);
15197
- if (top.version !== 1) {
15198
- errors3.push({
15199
- line: lineOf(versionNode, lineCounter2) ?? 1,
15200
- action: null,
15201
- message: top.version === undefined ? "missing `version: 1`" : `unsupported version ${JSON.stringify(top.version)} (expected 1)`
15202
- });
15071
+ if (type === "verify")
15072
+ return "Verify";
15073
+ if (type === "done")
15074
+ return "Done";
15075
+ if (type === "fail")
15076
+ return "Failed";
15077
+ if (type === "swipe") {
15078
+ if (typeof record.direction === "string" && record.direction.trim()) {
15079
+ return `Swipe ${record.direction}`;
15080
+ }
15081
+ const x = typeof record.x === "number" ? record.x : null;
15082
+ const y = typeof record.y === "number" ? record.y : null;
15083
+ const x2 = typeof record.x2 === "number" ? record.x2 : null;
15084
+ const y2 = typeof record.y2 === "number" ? record.y2 : null;
15085
+ if (y != null && y2 != null && x != null && x2 != null) {
15086
+ const dy = y2 - y;
15087
+ const dx = x2 - x;
15088
+ if (Math.abs(dy) >= Math.abs(dx)) {
15089
+ if (dy < -50)
15090
+ return "Swipe up";
15091
+ if (dy > 50)
15092
+ return "Swipe down";
15093
+ } else {
15094
+ if (dx < -50)
15095
+ return "Swipe left";
15096
+ if (dx > 50)
15097
+ return "Swipe right";
15098
+ }
15099
+ }
15100
+ return "Swipe";
15203
15101
  }
15204
- const name = top.name;
15205
- if (name != null && typeof name !== "string") {
15206
- errors3.push({
15207
- line: lineOf(root.get("name", true), lineCounter2),
15208
- action: null,
15209
- message: "name must be text"
15210
- });
15102
+ if (type === "input") {
15103
+ return `Input${typeof record.text === "string" ? `: ${record.text}` : ""}`;
15211
15104
  }
15212
- const sourceRunId = top.sourceRunId;
15213
- if (sourceRunId != null && (typeof sourceRunId !== "string" || sourceRunId.length === 0)) {
15214
- errors3.push({
15215
- line: lineOf(root.get("sourceRunId", true), lineCounter2),
15216
- action: null,
15217
- message: "sourceRunId must be text"
15218
- });
15105
+ if (type === "drag")
15106
+ return "Drag";
15107
+ if (type === "activate-app")
15108
+ return "Activate app";
15109
+ if (type === "terminate-app")
15110
+ return "Terminate app";
15111
+ if (type === "restart-app")
15112
+ return "Restart app";
15113
+ if (type === "background-app")
15114
+ return "Background app";
15115
+ if (type === "key") {
15116
+ const modifiers = Array.isArray(record.modifiers) ? record.modifiers.filter((value) => typeof value === "string") : [];
15117
+ return typeof record.key === "string" ? `Press ${formatKeyChord(record.key, modifiers)}` : "Press key";
15219
15118
  }
15220
- const savedAt = top.savedAt;
15221
- const legacy = savedAt !== undefined;
15222
- if (legacy && (typeof savedAt !== "number" || !Number.isInteger(savedAt) || savedAt < 0)) {
15223
- errors3.push({
15224
- line: lineOf(root.get("savedAt", true), lineCounter2),
15225
- action: null,
15226
- message: "savedAt must be a non-negative whole number"
15227
- });
15119
+ if (type === "open-url") {
15120
+ return typeof record.url === "string" && record.url.trim() ? `Open URL: ${record.url}` : "Open URL";
15228
15121
  }
15229
- const actionsNode = root.get("actions", true);
15230
- const actions = [];
15231
- const actionLines = [];
15232
- if (actionsNode != null && !($isScalar(actionsNode) && actionsNode.value == null)) {
15233
- if (!$isSeq(actionsNode)) {
15234
- errors3.push({
15235
- line: lineOf(actionsNode, lineCounter2),
15236
- action: null,
15237
- message: "actions must be a list (each item starts with `- `)"
15238
- });
15239
- } else {
15240
- actionsNode.items.forEach((item, index) => {
15241
- const line = lineOf(item, lineCounter2);
15242
- const raw = $isNode(item) ? item.toJS(doc) : item;
15243
- const result = readAction(raw, legacy);
15244
- if (result.ok) {
15245
- actions.push(result.action);
15246
- actionLines.push(line ?? 0);
15247
- } else {
15248
- errors3.push({ line, action: index + 1, message: result.message });
15249
- }
15250
- });
15251
- }
15252
- } else if (!root.has("actions")) {
15253
- errors3.push({ line: null, action: null, message: "missing `actions:`" });
15122
+ return type.charAt(0).toUpperCase() + type.slice(1);
15123
+ }
15124
+ function actionSummary(action) {
15125
+ if (!action || typeof action !== "object")
15126
+ return "Step";
15127
+ const cycle = readStepCycle(action);
15128
+ if (cycle?.action)
15129
+ return summarizeActionRecord(cycle.action);
15130
+ if (cycle?.decision)
15131
+ return summarizeActionRecord(cycle.decision);
15132
+ return summarizeActionRecord(action);
15133
+ }
15134
+ function optionalTrimmedString(value) {
15135
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
15136
+ }
15137
+ function optionalFiniteNumber(value) {
15138
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
15139
+ }
15140
+ function formatStepCommand(action) {
15141
+ if (!action || typeof action !== "object")
15142
+ return null;
15143
+ const record = action;
15144
+ const type = typeof record.type === "string" ? record.type : null;
15145
+ if (type == null || type === "verify" || type === "done" || type === "fail")
15146
+ return null;
15147
+ if (type === "wait") {
15148
+ const ms = optionalFiniteNumber(record.ms) ?? 0;
15149
+ return formatSleepShellLine(ms / 1000);
15254
15150
  }
15255
- if (errors3.length === 0 && actions.length === 0 && !options.allowEmpty) {
15256
- errors3.push({
15257
- line: lineOf(actionsNode, lineCounter2),
15258
- action: null,
15259
- message: "actions: add at least one action"
15151
+ if (type === "assert" || type === "branch") {
15152
+ const text = optionalTrimmedString(record.text);
15153
+ const id = type === "branch" ? optionalTrimmedString(record.id) : undefined;
15154
+ if (!text && !id)
15155
+ return null;
15156
+ const assertion = record.assertion === "not-visible" ? "not-visible" : "visible";
15157
+ const timeoutMs = optionalFiniteNumber(record.timeoutMs);
15158
+ return formatAssertShellLine({
15159
+ assertion,
15160
+ ...id ? { id } : { text },
15161
+ ...timeoutMs != null ? { timeoutSeconds: timeoutMs / 1000 } : {}
15260
15162
  });
15261
15163
  }
15262
- if (errors3.length > 0) {
15263
- return {
15264
- ok: false,
15265
- errors: errors3.sort((a, b) => (a.line ?? Number.POSITIVE_INFINITY) - (b.line ?? Number.POSITIVE_INFINITY))
15164
+ let request = null;
15165
+ if (type === "tap") {
15166
+ request = { kind: "tap" };
15167
+ const label = optionalTrimmedString(record.label);
15168
+ const id = optionalTrimmedString(record.id);
15169
+ const x = optionalFiniteNumber(record.x);
15170
+ const y = optionalFiniteNumber(record.y);
15171
+ const durationMs = optionalFiniteNumber(record.durationMs);
15172
+ if (label)
15173
+ request.label = label;
15174
+ if (id)
15175
+ request.id = id;
15176
+ if (x != null)
15177
+ request.x = x;
15178
+ if (y != null)
15179
+ request.y = y;
15180
+ if (record.double === true)
15181
+ request.double = true;
15182
+ if (durationMs != null)
15183
+ request.durationMs = durationMs;
15184
+ } else if (type === "swipe" || type === "drag") {
15185
+ request = { kind: type };
15186
+ const x = optionalFiniteNumber(record.x);
15187
+ const y = optionalFiniteNumber(record.y);
15188
+ const x2 = optionalFiniteNumber(record.x2);
15189
+ const y2 = optionalFiniteNumber(record.y2);
15190
+ const durationMs = optionalFiniteNumber(record.durationMs);
15191
+ if (x != null)
15192
+ request.x = x;
15193
+ if (y != null)
15194
+ request.y = y;
15195
+ if (x2 != null)
15196
+ request.x2 = x2;
15197
+ if (y2 != null)
15198
+ request.y2 = y2;
15199
+ if (durationMs != null)
15200
+ request.durationMs = durationMs;
15201
+ } else if (type === "type" || type === "input") {
15202
+ request = { kind: "input" };
15203
+ const text = optionalTrimmedString(record.text);
15204
+ if (text)
15205
+ request.text = text;
15206
+ } else if (type === "alert") {
15207
+ request = {
15208
+ kind: "alert",
15209
+ alertAction: record.alertAction === "dismiss" ? "dismiss" : "accept"
15210
+ };
15211
+ } else if (type === "activate-app" || type === "terminate-app" || type === "restart-app") {
15212
+ request = { kind: type };
15213
+ const appId = optionalTrimmedString(record.appId);
15214
+ if (appId)
15215
+ request.appId = appId;
15216
+ } else if (type === "background-app") {
15217
+ request = { kind: "background-app" };
15218
+ const seconds = optionalFiniteNumber(record.seconds);
15219
+ if (seconds != null)
15220
+ request.seconds = seconds;
15221
+ } else if (type === "key") {
15222
+ const key = keyNameSchema.safeParse(record.key);
15223
+ const modifiers = exports_external.array(keyModifierSchema).safeParse(record.modifiers);
15224
+ if (!key.success)
15225
+ return null;
15226
+ request = {
15227
+ kind: "key",
15228
+ key: key.data,
15229
+ ...modifiers.success && modifiers.data.length > 0 ? { modifiers: modifiers.data } : {}
15266
15230
  };
15231
+ } else if (type === "open-url") {
15232
+ request = { kind: "open-url" };
15233
+ const url = optionalTrimmedString(record.url);
15234
+ if (url)
15235
+ request.url = url;
15236
+ }
15237
+ return request ? formatActionShellLine(request) : null;
15238
+ }
15239
+ function stepReasoning(step) {
15240
+ const action = step.action && typeof step.action === "object" ? step.action : null;
15241
+ const reasonFromAction = typeof action?.reason === "string" && action.reason.trim() ? action.reason.trim() : null;
15242
+ const thoughtsFromAction = typeof action?.thoughts === "string" && action.thoughts.trim() ? action.thoughts.trim() : null;
15243
+ const reason = reasonFromAction ?? (step.detail?.trim() ? step.detail.trim() : null);
15244
+ const thoughts = thoughtsFromAction && thoughtsFromAction !== reason ? thoughtsFromAction : null;
15245
+ return { reason, thoughts };
15246
+ }
15247
+ function toReportStatus(status) {
15248
+ if (status === "passed" || status === "errored" || status === "cancelled")
15249
+ return status;
15250
+ return null;
15251
+ }
15252
+ function formatStepPhases(phases) {
15253
+ const parts = [
15254
+ `capture ${Math.round(phases.captureMs)}ms`,
15255
+ `screen ${Math.round(phases.screenMs)}ms`,
15256
+ `image ${Math.round(phases.prepareMs)}ms`,
15257
+ `decide ${Math.round(phases.decideMs)}ms`,
15258
+ `action ${Math.round(phases.actionMs)}ms`,
15259
+ `settle ${Math.round(phases.settleMs)}ms`
15260
+ ];
15261
+ if (phases.decideRetries > 0) {
15262
+ parts.push(`retries ${phases.decideRetries}`);
15263
+ }
15264
+ const usage = phases.usage ? formatCallUsage(phases.usage) : null;
15265
+ if (usage)
15266
+ parts.push(usage);
15267
+ return parts.join(" · ");
15268
+ }
15269
+ function formatCallUsage(usage) {
15270
+ const fields = [
15271
+ usage.inputTokens != null ? `in ${usage.inputTokens}` : null,
15272
+ usage.cachedInputTokens != null ? `cached ${usage.cachedInputTokens}` : null,
15273
+ usage.cacheWriteTokens != null ? `cache write ${usage.cacheWriteTokens}` : null,
15274
+ usage.outputTokens != null ? `out ${usage.outputTokens}` : null
15275
+ ].filter((field) => field != null);
15276
+ if (fields.length === 0)
15277
+ return null;
15278
+ const cached = usage.cachedInputTokens ?? 0;
15279
+ const totalInput = (usage.inputTokens ?? 0) + cached + (usage.cacheWriteTokens ?? 0);
15280
+ const share = totalInput > 0 ? ` (${Math.round(cached / totalInput * 100)}% cached)` : "";
15281
+ return `usage ${fields.join(", ")}${share}`;
15282
+ }
15283
+ function mapCatalogStep(step, screenshotsByStepId) {
15284
+ const { reason, thoughts } = stepReasoning(step);
15285
+ return {
15286
+ id: step.id,
15287
+ index: step.idx + 1,
15288
+ summary: actionSummary(step.action),
15289
+ ok: step.ok,
15290
+ latencyMs: step.latencyMs,
15291
+ phases: step.phases ?? null,
15292
+ detail: step.detail,
15293
+ reason,
15294
+ thoughts,
15295
+ command: step.command ?? formatStepCommand(step.action),
15296
+ screenshotBase64: screenshotsByStepId[step.id] ?? null
15297
+ };
15298
+ }
15299
+ function buildRunReportFromCatalogRun(run, meta = {}, screenshotsByStepId = {}) {
15300
+ const status = toReportStatus(run.status);
15301
+ if (!status) {
15302
+ throw new Error(`Cannot export report for live run status: ${run.status}`);
15267
15303
  }
15304
+ const caseTitles = meta.caseTitles ?? {};
15305
+ const tests = run.tests.map((test) => ({
15306
+ id: test.id,
15307
+ title: caseTitles[test.caseId] ?? test.caseId,
15308
+ status: test.status,
15309
+ executionMode: test.executionMode ?? null,
15310
+ error: test.error,
15311
+ startedAt: test.startedAt,
15312
+ finishedAt: test.finishedAt,
15313
+ steps: (test.steps ?? []).map((step) => mapCatalogStep(step, screenshotsByStepId))
15314
+ }));
15315
+ const primaryTitle = tests[0]?.title ?? "Run";
15268
15316
  return {
15269
- ok: true,
15270
- name: typeof name === "string" && name.trim().length > 0 ? name.trim() : null,
15271
- actionLines,
15272
- script: {
15273
- version: 1,
15274
- ...typeof sourceRunId === "string" ? { sourceRunId } : {},
15275
- savedAt: typeof savedAt === "number" ? savedAt : options.savedAt ?? Date.now(),
15276
- actions
15277
- }
15317
+ id: run.id,
15318
+ source: "catalog",
15319
+ status,
15320
+ title: primaryTitle,
15321
+ appLabel: meta.appLabel ?? null,
15322
+ deviceLabel: meta.deviceLabel ?? null,
15323
+ platform: run.platform,
15324
+ executionMode: run.executionMode,
15325
+ screenMode: run.screenMode ?? null,
15326
+ lane: run.lane ?? null,
15327
+ laneWarning: run.laneWarning ?? null,
15328
+ browser: run.browser ?? null,
15329
+ viewport: run.viewport ?? null,
15330
+ error: run.error,
15331
+ createdAt: run.createdAt,
15332
+ startedAt: run.startedAt,
15333
+ finishedAt: run.finishedAt,
15334
+ tests
15278
15335
  };
15279
15336
  }
15280
- function formatCaseScriptFileErrors(errors3) {
15281
- return errors3.map((error) => {
15282
- const where = [
15283
- error.line != null ? `Line ${error.line}` : null,
15284
- error.action != null ? `action ${error.action}` : null
15285
- ].filter(Boolean).join(", ");
15286
- return where ? `${where}: ${error.message}` : error.message;
15287
- }).join(`
15288
- `);
15337
+ function suggestedRunReportBasename(doc) {
15338
+ const shortId = doc.id.replace(/[^a-zA-Z0-9]+/g, "").slice(0, 8) || "run";
15339
+ const raw = ["yoqa-run", shortId, doc.status].join("-").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
15340
+ return raw || "yoqa-run-report";
15289
15341
  }
15290
- var FLOW_OPTIONS = {
15291
- collectionStyle: "flow",
15292
- lineWidth: 0,
15293
- blockQuote: false,
15294
- flowCollectionPadding: false
15295
- };
15296
- function yamlScalar(value) {
15297
- if (typeof value === "string" && /[\n\r\t]/.test(value))
15298
- return JSON.stringify(value);
15299
- return $stringify([value], FLOW_OPTIONS).trim().slice(1, -1);
15342
+ function escapeHtml(value) {
15343
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15300
15344
  }
15301
- function orderedFields(action) {
15302
- const fields = Object.entries(action).filter(([key, value]) => key !== "type" && value !== undefined);
15303
- const meaningful = fields.filter(([key, value]) => !(action.type === "assert" && key === "assertion" && value === "visible") && !(action.type === "alert" && key === "alertAction" && value === "accept"));
15304
- const rank = (key) => {
15305
- const at = FIELD_ORDER.indexOf(key);
15306
- return at < 0 ? FIELD_ORDER.length : at;
15307
- };
15308
- return meaningful.sort(([a], [b]) => rank(a) - rank(b));
15345
+ function formatWhen(ms) {
15346
+ if (ms == null)
15347
+ return "—";
15348
+ return new Date(ms).toLocaleString();
15309
15349
  }
15310
- function formatCaseScriptFileAction(action) {
15311
- const fields = orderedFields(action);
15312
- const shortField = SHORT_FORM_FIELD[action.type];
15313
- if (action.type === "alert" && fields.length === 0) {
15314
- return `alert: ${action.alertAction}`;
15315
- }
15316
- const only = fields[0];
15317
- if (fields.length === 1 && only && only[0] === shortField) {
15318
- return `${action.type}: ${yamlScalar(only[1])}`;
15350
+ function formatDuration(startedAt, finishedAt) {
15351
+ if (startedAt == null || finishedAt == null)
15352
+ return "—";
15353
+ const ms = Math.max(0, finishedAt - startedAt);
15354
+ if (ms < 1000)
15355
+ return `${ms}ms`;
15356
+ const seconds = ms / 1000;
15357
+ if (seconds < 60)
15358
+ return `${seconds.toFixed(1)}s`;
15359
+ const minutes = Math.floor(seconds / 60);
15360
+ const rem = Math.round(seconds % 60);
15361
+ return `${minutes}m ${rem}s`;
15362
+ }
15363
+ function statusAccent(status) {
15364
+ if (status === "passed") {
15365
+ return { bg: "#dcfce7", fg: "#166534", label: "Passed" };
15319
15366
  }
15320
- if (fields.length === 0) {
15321
- return `${action.type}: {}`;
15367
+ if (status === "errored") {
15368
+ return { bg: "#fee2e2", fg: "#991b1b", label: "Failed" };
15322
15369
  }
15323
- const body = fields.map(([key, value]) => `${key}: ${yamlScalar(value)}`).join(", ");
15324
- return `${action.type}: { ${body} }`;
15370
+ return { bg: "#e5e7eb", fg: "#374151", label: "Cancelled" };
15325
15371
  }
15326
- function formatCaseScriptFileActionLine(action) {
15327
- return ` - ${formatCaseScriptFileAction(action)}`;
15372
+ function pngDataUri(base64) {
15373
+ return `data:image/png;base64,${base64}`;
15328
15374
  }
15329
- function formatCaseScriptFile(script, options = {}) {
15330
- const lines = ["version: 1"];
15331
- const name = options.name?.trim();
15332
- if (name)
15333
- lines.push(`name: ${yamlScalar(name)}`);
15334
- if (script.sourceRunId)
15335
- lines.push(`sourceRunId: ${yamlScalar(script.sourceRunId)}`);
15336
- lines.push("actions:");
15337
- for (const action of script.actions) {
15338
- lines.push(formatCaseScriptFileActionLine(action));
15375
+ function formatRunReportHtml(doc) {
15376
+ const accent = statusAccent(doc.status);
15377
+ const screenRows = [
15378
+ ...doc.screenMode ? [["Screen", doc.screenMode]] : [],
15379
+ ...doc.lane ? [["Lane", doc.lane]] : [],
15380
+ ...doc.laneWarning ? [["Lane warning", doc.laneWarning]] : []
15381
+ ];
15382
+ const metaRows = [
15383
+ ["Status", accent.label],
15384
+ ["Source", doc.source === "catalog" ? "Catalog run" : "Manual Inspector"],
15385
+ ["App", doc.appLabel ?? "—"],
15386
+ ["Device", doc.deviceLabel ?? "—"],
15387
+ ["Platform", doc.platform ?? "—"],
15388
+ ...doc.browser ? [["Browser", doc.browser]] : [],
15389
+ ...doc.viewport ? [["Viewport", formatViewportPreset(doc.viewport)]] : [],
15390
+ ["Mode", doc.executionMode ?? "—"],
15391
+ ...screenRows,
15392
+ ["Started", formatWhen(doc.startedAt ?? doc.createdAt)],
15393
+ ["Finished", formatWhen(doc.finishedAt)],
15394
+ ["Duration", formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt)],
15395
+ ["Run ID", doc.id]
15396
+ ];
15397
+ const testsHtml = doc.tests.map((test, testIndex) => {
15398
+ const testAccent = statusAccent(test.status === "passed" || test.status === "errored" || test.status === "cancelled" ? test.status : doc.status);
15399
+ const stepsHtml = test.steps.map((step) => {
15400
+ const stepStatus = step.ok ? "Passed" : "Failed";
15401
+ const stepColor = step.ok ? "#166534" : "#991b1b";
15402
+ const reasonBlock = step.reason ? `<p class="reason">${escapeHtml(step.reason)}</p>` : "";
15403
+ const thoughtsBlock = step.thoughts ? `<details class="thoughts"><summary>AI thoughts</summary><p>${escapeHtml(step.thoughts)}</p></details>` : "";
15404
+ const detailBlock = step.detail && step.detail !== step.reason ? `<p class="detail">${escapeHtml(step.detail)}</p>` : "";
15405
+ const shot = step.screenshotBase64 ? `<img class="shot" alt="Step ${step.index} screenshot" src="${pngDataUri(step.screenshotBase64)}" />` : `<p class="muted">No screenshot</p>`;
15406
+ const latency = step.latencyMs != null ? `<span class="muted">${step.latencyMs}ms</span>` : "";
15407
+ const phases = step.phases ? `<p class="muted phases">Phases: ${escapeHtml(formatStepPhases(step.phases))}</p>` : "";
15408
+ const commandBlock = step.command ? `<pre class="command"><code>${escapeHtml(step.command)}</code></pre>` : "";
15409
+ return `
15410
+ <article class="step ${step.ok ? "ok" : "fail"}">
15411
+ <header>
15412
+ <span class="idx">${step.index}</span>
15413
+ <div>
15414
+ <h3>${escapeHtml(step.summary)}</h3>
15415
+ <p class="step-status" style="color:${stepColor}">${stepStatus} ${latency}</p>
15416
+ </div>
15417
+ </header>
15418
+ ${commandBlock}
15419
+ ${phases}
15420
+ ${reasonBlock}
15421
+ ${detailBlock}
15422
+ ${thoughtsBlock}
15423
+ ${shot}
15424
+ </article>`;
15425
+ }).join(`
15426
+ `);
15427
+ return `
15428
+ <section class="test">
15429
+ <h2>
15430
+ <span class="test-num">${testIndex + 1}</span>
15431
+ ${escapeHtml(test.title)}
15432
+ <span class="pill" style="background:${testAccent.bg};color:${testAccent.fg}">${escapeHtml(testAccent.label)}</span>
15433
+ </h2>
15434
+ ${test.error ? `<p class="error">${escapeHtml(test.error)}</p>` : ""}
15435
+ ${test.executionMode ? `<p class="muted">Mode: ${escapeHtml(test.executionMode)}</p>` : ""}
15436
+ <div class="steps">${stepsHtml || `<p class="muted">No steps recorded</p>`}</div>
15437
+ </section>`;
15438
+ }).join(`
15439
+ `);
15440
+ const metaHtml = metaRows.map(([label, value]) => `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`).join(`
15441
+ `);
15442
+ return `<!DOCTYPE html>
15443
+ <html lang="en">
15444
+ <head>
15445
+ <meta charset="utf-8" />
15446
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
15447
+ <title>Yoqa Report — ${escapeHtml(doc.title)} (${escapeHtml(accent.label)})</title>
15448
+ <style>
15449
+ :root { color-scheme: light; }
15450
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f8fafc; color: #0f172a; line-height: 1.5; }
15451
+ .wrap { max-width: 52rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
15452
+ .banner { border-radius: 1rem; padding: 1.25rem 1.5rem; background: ${accent.bg}; color: ${accent.fg}; margin-bottom: 1.5rem; }
15453
+ .banner h1 { margin: 0 0 0.35rem; font-size: 1.5rem; }
15454
+ .banner p { margin: 0; opacity: 0.9; }
15455
+ table.meta { width: 100%; border-collapse: collapse; background: #fff; border-radius: 0.75rem; overflow: hidden; box-shadow: 0 1px 2px rgb(0 0 0 / 0.06); margin-bottom: 2rem; }
15456
+ table.meta th, table.meta td { text-align: left; padding: 0.65rem 1rem; border-bottom: 1px solid #e2e8f0; font-size: 0.9rem; }
15457
+ table.meta th { width: 8rem; color: #64748b; font-weight: 600; }
15458
+ .error { background: #fee2e2; color: #991b1b; padding: 0.75rem 1rem; border-radius: 0.5rem; }
15459
+ .test { margin-bottom: 2.5rem; }
15460
+ .test h2 { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 1.15rem; }
15461
+ .test-num { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 999px; background: #0f172a; color: #fff; font-size: 0.8rem; }
15462
+ .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.15rem 0.65rem; font-size: 0.75rem; font-weight: 600; }
15463
+ .step { background: #fff; border: 1px solid #e2e8f0; border-radius: 0.75rem; padding: 1rem; margin: 0.75rem 0; }
15464
+ .step.fail { border-color: #fecaca; }
15465
+ .step header { display: flex; gap: 0.75rem; align-items: flex-start; }
15466
+ .step .idx { flex-shrink: 0; width: 1.75rem; height: 1.75rem; border-radius: 999px; background: #e2e8f0; display: inline-flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; }
15467
+ .step.ok .idx { background: #bbf7d0; color: #166534; }
15468
+ .step.fail .idx { background: #fecaca; color: #991b1b; }
15469
+ .step h3 { margin: 0; font-size: 1rem; }
15470
+ .step-status { margin: 0.15rem 0 0; font-size: 0.8rem; font-weight: 600; }
15471
+ .command { margin: 0.5rem 0 0; padding: 0.4rem 0.65rem; background: #f1f5f9; border-radius: 0.4rem; font-size: 0.8rem; overflow-x: auto; }
15472
+ .command code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
15473
+ .reason, .detail { margin: 0.5rem 0 0; color: #475569; font-size: 0.9rem; }
15474
+ .thoughts { margin-top: 0.5rem; font-size: 0.85rem; color: #64748b; }
15475
+ .thoughts p { margin: 0.35rem 0 0; white-space: pre-wrap; }
15476
+ .shot { display: block; max-width: 100%; width: min(22rem, 100%); margin-top: 0.75rem; border-radius: 0.75rem; border: 1px solid #e2e8f0; }
15477
+ .muted { color: #94a3b8; font-size: 0.85rem; }
15478
+ footer { margin-top: 2rem; color: #94a3b8; font-size: 0.8rem; }
15479
+ @media print {
15480
+ body { background: #fff; }
15481
+ .shot { max-height: 60vh; object-fit: contain; }
15339
15482
  }
15340
- return `${lines.join(`
15341
- `)}
15483
+ </style>
15484
+ </head>
15485
+ <body>
15486
+ <div class="wrap">
15487
+ <header class="banner">
15488
+ <h1>${escapeHtml(doc.title)}</h1>
15489
+ <p>${escapeHtml(accent.label)} · Yoqa end-to-end report</p>
15490
+ </header>
15491
+ ${doc.error ? `<p class="error">${escapeHtml(doc.error)}</p>` : ""}
15492
+ <table class="meta">${metaHtml}</table>
15493
+ ${testsHtml}
15494
+ <footer>Generated by Yoqa</footer>
15495
+ </div>
15496
+ </body>
15497
+ </html>
15342
15498
  `;
15343
15499
  }
15344
- // ../runner-client/src/case-script-replay.ts
15345
- var DEFAULT_ASSERT_TIMEOUT_MS = 5000;
15346
- function abortError() {
15347
- return Object.assign(new Error("Aborted"), { name: "AbortError" });
15500
+ function escapeMd(value) {
15501
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
15348
15502
  }
15349
- function realSleep(ms, signal) {
15350
- if (ms <= 0)
15351
- return Promise.resolve();
15352
- return new Promise((resolve, reject) => {
15353
- if (signal?.aborted) {
15354
- reject(abortError());
15503
+ function formatRunReportMarkdown(doc) {
15504
+ const accent = statusAccent(doc.status);
15505
+ const lines = [
15506
+ `# Yoqa Report — ${doc.title}`,
15507
+ "",
15508
+ `**Status:** ${accent.label}`,
15509
+ "",
15510
+ "| Field | Value |",
15511
+ "| --- | --- |",
15512
+ `| Source | ${doc.source === "catalog" ? "Catalog run" : "Manual Inspector"} |`,
15513
+ `| App | ${escapeMd(doc.appLabel ?? "—")} |`,
15514
+ `| Device | ${escapeMd(doc.deviceLabel ?? "—")} |`,
15515
+ `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
15516
+ ...doc.browser ? [`| Browser | ${escapeMd(doc.browser)} |`] : [],
15517
+ ...doc.viewport ? [`| Viewport | ${escapeMd(formatViewportPreset(doc.viewport))} |`] : [],
15518
+ `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
15519
+ ...doc.screenMode ? [`| Screen | ${escapeMd(doc.screenMode)} |`] : [],
15520
+ ...doc.lane ? [`| Lane | ${escapeMd(doc.lane)} |`] : [],
15521
+ ...doc.laneWarning ? [`| Lane warning | ${escapeMd(doc.laneWarning)} |`] : [],
15522
+ `| Started | ${escapeMd(formatWhen(doc.startedAt ?? doc.createdAt))} |`,
15523
+ `| Finished | ${escapeMd(formatWhen(doc.finishedAt))} |`,
15524
+ `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
15525
+ `| Run ID | \`${doc.id}\` |`,
15526
+ ""
15527
+ ];
15528
+ if (doc.error) {
15529
+ lines.push(`> **Error:** ${doc.error}`, "");
15530
+ }
15531
+ doc.tests.forEach((test, testIndex) => {
15532
+ const testLabel = test.status === "passed" ? "Passed" : test.status === "errored" ? "Failed" : test.status === "cancelled" ? "Cancelled" : test.status;
15533
+ lines.push(`## ${testIndex + 1}. ${test.title} (${testLabel})`, "");
15534
+ if (test.executionMode)
15535
+ lines.push(`Mode: \`${test.executionMode}\``, "");
15536
+ if (test.error)
15537
+ lines.push(`**Error:** ${test.error}`, "");
15538
+ if (test.steps.length === 0) {
15539
+ lines.push("_No steps recorded_", "");
15355
15540
  return;
15356
15541
  }
15357
- const timer = setTimeout(() => {
15358
- signal?.removeEventListener("abort", onAbort);
15359
- resolve();
15360
- }, ms);
15361
- const onAbort = () => {
15362
- clearTimeout(timer);
15363
- reject(abortError());
15364
- };
15365
- signal?.addEventListener("abort", onAbort, { once: true });
15366
- });
15367
- }
15368
- function caseScriptActionRequest(action) {
15369
- switch (action.type) {
15370
- case "tap": {
15371
- const request = { kind: "tap" };
15372
- if (action.label)
15373
- request.label = action.label;
15374
- if (action.id)
15375
- request.id = action.id;
15376
- if (action.x != null)
15377
- request.x = action.x;
15378
- if (action.y != null)
15379
- request.y = action.y;
15380
- if (action.double)
15381
- request.double = true;
15382
- if (action.durationMs != null)
15383
- request.durationMs = action.durationMs;
15384
- return request;
15542
+ for (const step of test.steps) {
15543
+ lines.push(`### Step ${step.index}: ${step.summary}`);
15544
+ lines.push("");
15545
+ lines.push(`- Result: **${step.ok ? "Passed" : "Failed"}**`);
15546
+ if (step.command)
15547
+ lines.push(`- Command: \`${step.command.replace(/`/g, "'")}\``);
15548
+ if (step.latencyMs != null)
15549
+ lines.push(`- Latency: ${step.latencyMs}ms`);
15550
+ if (step.phases)
15551
+ lines.push(`- Phases: ${formatStepPhases(step.phases)}`);
15552
+ if (step.reason)
15553
+ lines.push(`- Reason: ${step.reason}`);
15554
+ if (step.detail && step.detail !== step.reason)
15555
+ lines.push(`- Detail: ${step.detail}`);
15556
+ if (step.thoughts) {
15557
+ lines.push("", "<details><summary>AI thoughts</summary>", "", step.thoughts, "", "</details>");
15558
+ }
15559
+ if (step.screenshotBase64) {
15560
+ lines.push("", `![Step ${step.index} screenshot](${pngDataUri(step.screenshotBase64)})`);
15561
+ } else {
15562
+ lines.push("", "_No screenshot_");
15563
+ }
15564
+ lines.push("");
15385
15565
  }
15386
- case "swipe":
15387
- case "drag":
15388
- return {
15389
- kind: action.type,
15390
- x: action.x,
15391
- y: action.y,
15392
- x2: action.x2,
15393
- y2: action.y2,
15394
- ...action.durationMs != null ? { durationMs: action.durationMs } : {}
15395
- };
15396
- case "type":
15397
- return { kind: "input", text: action.text };
15398
- case "alert":
15399
- return { kind: "alert", alertAction: action.alertAction };
15400
- case "activate-app":
15401
- case "terminate-app":
15402
- case "restart-app":
15403
- return { kind: action.type, appId: action.appId };
15404
- case "background-app":
15405
- return {
15406
- kind: "background-app",
15407
- ...action.seconds != null ? { seconds: action.seconds } : {}
15408
- };
15409
- case "open-url":
15410
- return { kind: "open-url", url: action.url };
15411
- case "wait":
15412
- case "assert":
15413
- return null;
15414
- }
15566
+ });
15567
+ lines.push("---", "", "_Generated by Yoqa_", "");
15568
+ return lines.join(`
15569
+ `);
15415
15570
  }
15416
- function caseScriptActionCommand(action) {
15417
- if (action.type === "wait") {
15418
- const seconds = action.ms / 1000;
15419
- return `sleep ${Number.isInteger(seconds) ? seconds : Number(seconds.toFixed(3))}`;
15571
+ function formatRunReportGithubSummary(doc) {
15572
+ const accent = statusAccent(doc.status);
15573
+ const passed = doc.tests.filter((test) => test.status === "passed").length;
15574
+ const failed = doc.tests.filter((test) => test.status === "errored").length;
15575
+ const cancelled = doc.tests.filter((test) => test.status === "cancelled").length;
15576
+ const failedSteps = doc.tests.flatMap((test) => test.steps.filter((step) => !step.ok).map((step) => ({ test, step })));
15577
+ const lines = [
15578
+ `## Yoqa Report — ${doc.title}`,
15579
+ "",
15580
+ `**${accent.label}** · ${doc.tests.length} test${doc.tests.length === 1 ? "" : "s"} · ${passed} passed · ${failed} failed · ${cancelled} cancelled`,
15581
+ "",
15582
+ "| Field | Value |",
15583
+ "| --- | --- |",
15584
+ `| App | ${escapeMd(doc.appLabel ?? "—")} |`,
15585
+ `| Device | ${escapeMd(doc.deviceLabel ?? "—")} |`,
15586
+ `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
15587
+ ...doc.browser ? [`| Browser | ${escapeMd(doc.browser)} |`] : [],
15588
+ ...doc.viewport ? [`| Viewport | ${escapeMd(formatViewportPreset(doc.viewport))} |`] : [],
15589
+ `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
15590
+ ...doc.screenMode ? [`| Screen | ${escapeMd(doc.screenMode)} |`] : [],
15591
+ ...doc.lane ? [`| Lane | ${escapeMd(doc.lane)} |`] : [],
15592
+ ...doc.laneWarning ? [`| Lane warning | ${escapeMd(doc.laneWarning)} |`] : [],
15593
+ `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
15594
+ `| Run ID | \`${doc.id}\` |`,
15595
+ ""
15596
+ ];
15597
+ if (doc.error) {
15598
+ lines.push(`> **Error:** ${doc.error}`, "");
15420
15599
  }
15421
- if (action.type === "assert") {
15422
- const timeout = action.timeoutMs != null ? ` --timeout ${Math.max(1, Math.round(action.timeoutMs / 1000))}` : "";
15423
- return `yoqa assert ${action.assertion} --text '${action.text.replace(/'/g, `'\\''`)}'${timeout}`;
15600
+ if (doc.tests.length > 0) {
15601
+ lines.push("### Tests", "", "| # | Test | Status | Steps |", "| --- | --- | --- | --- |");
15602
+ doc.tests.forEach((test, testIndex) => {
15603
+ const testLabel = test.status === "passed" ? "Passed" : test.status === "errored" ? "Failed" : test.status === "cancelled" ? "Cancelled" : test.status;
15604
+ lines.push(`| ${testIndex + 1} | ${escapeMd(test.title)} | ${testLabel} | ${test.steps.length} |`);
15605
+ });
15606
+ lines.push("");
15424
15607
  }
15425
- const request = caseScriptActionRequest(action);
15426
- return request ? formatActionShellLine(request) : action.type;
15427
- }
15428
- async function runCaseScript(client, script, options = {}) {
15429
- const sleep = options.sleep ?? realSleep;
15430
- const now = options.now ?? Date.now;
15431
- const pollMs = options.assertPollMs ?? 400;
15432
- const { actions } = script;
15433
- const total = actions.length;
15434
- const skipFrom = async (from) => {
15435
- for (let index = from;index < total; index++) {
15436
- const action = actions[index];
15437
- if (action)
15438
- await options.onStep?.({ index, total, action, status: "skipped" });
15439
- }
15440
- };
15441
- const assertText = async (action) => {
15442
- const timeoutMs = action.timeoutMs ?? options.defaultAssertTimeoutMs ?? DEFAULT_ASSERT_TIMEOUT_MS;
15443
- const deadline = now() + timeoutMs;
15444
- const seconds = Math.round(timeoutMs / 1000);
15445
- for (;; ) {
15446
- if (options.signal?.aborted)
15447
- throw abortError();
15448
- let readError = null;
15449
- try {
15450
- const screen = await client.getScreen();
15451
- const found = screenHasText(screen.elements, action.text);
15452
- if (action.assertion === "visible" ? found : !found)
15453
- return;
15454
- } catch (error) {
15455
- readError = error;
15456
- }
15457
- if (now() >= deadline) {
15458
- if (readError) {
15459
- const message = readError instanceof Error ? readError.message : String(readError);
15460
- throw new Error(`Could not read the screen within ${seconds}s: ${message}`);
15461
- }
15462
- throw new Error(action.assertion === "visible" ? `Expected visible text not found within ${seconds}s: ${action.text}` : `Unexpected text still visible after ${seconds}s: ${action.text}`);
15608
+ if (failedSteps.length > 0) {
15609
+ lines.push("### Failed steps", "");
15610
+ for (const { test, step } of failedSteps) {
15611
+ lines.push(`- **${escapeMd(test.title)}** · step ${step.index}: ${escapeMd(step.summary)}`);
15612
+ if (step.command)
15613
+ lines.push(` - Command: \`${escapeMd(step.command)}\``);
15614
+ if (step.reason)
15615
+ lines.push(` - Reason: ${escapeMd(step.reason)}`);
15616
+ if (step.detail && step.detail !== step.reason) {
15617
+ lines.push(` - Detail: ${escapeMd(step.detail)}`);
15463
15618
  }
15464
- await sleep(pollMs, options.signal);
15465
- }
15466
- };
15467
- for (let index = 0;index < total; index++) {
15468
- const action = actions[index];
15469
- if (!action)
15470
- continue;
15471
- if (options.signal?.aborted) {
15472
- await skipFrom(index);
15473
- return { ok: false, completed: index, total, failedIndex: index, error: "Aborted" };
15474
- }
15475
- await options.onStep?.({ index, total, action, status: "running" });
15476
- try {
15477
- let response;
15478
- if (action.type === "wait") {
15479
- await sleep(action.ms, options.signal);
15480
- } else if (action.type === "assert") {
15481
- await assertText(action);
15482
- } else {
15483
- const request = caseScriptActionRequest(action);
15484
- if (request)
15485
- response = await client.performAction(request);
15486
- if (options.pauseAfterActionMs)
15487
- await sleep(options.pauseAfterActionMs, options.signal);
15488
- }
15489
- await options.onStep?.({
15490
- index,
15491
- total,
15492
- action,
15493
- status: "ok",
15494
- ...response ? { response } : {}
15495
- });
15496
- } catch (error) {
15497
- const aborted = options.signal?.aborted || error instanceof Error && error.name === "AbortError";
15498
- const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
15499
- await options.onStep?.({ index, total, action, status: "error", error: message });
15500
- await skipFrom(index + 1);
15501
- return { ok: false, completed: index, total, failedIndex: index, error: message };
15502
15619
  }
15620
+ lines.push("");
15503
15621
  }
15504
- return { ok: true, completed: total, total };
15622
+ lines.push("Download the `yoqa-report` artifact for the full HTML report with screenshots.", "");
15623
+ return lines.join(`
15624
+ `);
15505
15625
  }
15506
15626
  // ../runner-client/src/index.ts
15507
15627
  var DEFAULT_BASE_URL = "http://127.0.0.1:7420";
@@ -16392,7 +16512,7 @@ function recommendScreenDefault(vision, tree) {
16392
16512
  // package.json
16393
16513
  var package_default = {
16394
16514
  name: "@yoqa/cli",
16395
- version: "1.0.2",
16515
+ version: "1.0.4",
16396
16516
  description: "Yoqa CLI — drive local mobile QA from the terminal or CI (talks to the Yoqa runner over HTTP)",
16397
16517
  license: "UNLICENSED",
16398
16518
  type: "module",
@@ -16429,7 +16549,7 @@ var package_default = {
16429
16549
  health: "bun run src/main.ts health"
16430
16550
  },
16431
16551
  dependencies: {
16432
- "@yoqa/runner": "1.0.2"
16552
+ "@yoqa/runner": "1.0.4"
16433
16553
  },
16434
16554
  devDependencies: {
16435
16555
  "@types/node": "^22.15.30",
@@ -16919,6 +17039,41 @@ function commandChainIncludes(command, name) {
16919
17039
  return false;
16920
17040
  }
16921
17041
 
17042
+ // src/platform.ts
17043
+ var PLATFORM_DEVICES_DESCRIPTION = {
17044
+ ios: "List iOS devices and simulators",
17045
+ android: "List Android devices and emulators",
17046
+ web: "List installed web browsers (attach a Chromium you started by naming its port: chromium-cdp-<port>)"
17047
+ };
17048
+ function parsePlatform(value) {
17049
+ const parsed = devicePlatformSchema.safeParse(value);
17050
+ if (!parsed.success) {
17051
+ const names = devicePlatformSchema.options.map((option) => option.value);
17052
+ throw new Error(`--platform must be ${names.slice(0, -1).join(", ")} or ${names.at(-1)}`);
17053
+ }
17054
+ return parsed.data;
17055
+ }
17056
+ var WEB_DEVICE_ID = /^(web-(chrome|edge|brave|chromium)|chromium-cdp-\d+)$/;
17057
+ function isWebDeviceId(deviceId) {
17058
+ return WEB_DEVICE_ID.test(deviceId);
17059
+ }
17060
+ function connectPlatform(deviceId, value) {
17061
+ if (value === undefined && isWebDeviceId(deviceId))
17062
+ return "web";
17063
+ return parsePlatform(value);
17064
+ }
17065
+ function parseViewportPreset(value, platform) {
17066
+ if (value === undefined)
17067
+ return;
17068
+ if (platform !== "web") {
17069
+ throw new Error("--viewport is for web devices: a phone's screen is its own size");
17070
+ }
17071
+ const preset = VIEWPORT_PRESET_NAMES.find((name) => name === value.trim().toLowerCase());
17072
+ if (!preset)
17073
+ throw new Error(`--viewport must be ${VIEWPORT_PRESET_NAMES.join(" or ")}`);
17074
+ return preset;
17075
+ }
17076
+
16922
17077
  // src/report.ts
16923
17078
  import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
16924
17079
  import { dirname as dirname3 } from "node:path";
@@ -17207,6 +17362,10 @@ program2.name("yoqa").description("Local Yoqa CLI (talks to the local runner ove
17207
17362
  function client(baseUrl) {
17208
17363
  return createRunnerClient({ baseUrl });
17209
17364
  }
17365
+ function describeSession(body) {
17366
+ const viewport = body.viewport ? ` viewport ${formatViewportPreset(body.viewport)}` : "";
17367
+ return `${body.platform} ${body.deviceId} lane ${body.lane ?? "direct"}${viewport}`;
17368
+ }
17210
17369
  function fail(command, error) {
17211
17370
  const message = error instanceof Error ? error.message : String(error);
17212
17371
  console.error(`yoqa ${command} failed: ${message}`);
@@ -17343,8 +17502,8 @@ function printDevicesTable(devicesList) {
17343
17502
  }
17344
17503
  }
17345
17504
  }
17346
- for (const platform of ["ios", "android"]) {
17347
- devices.command(platform).description(`List ${platform === "ios" ? "iOS devices and simulators" : "Android devices and emulators"}`).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--all", "Include unavailable / shutdown devices", true).option("--booted-only", "Only show booted / online devices").action(async (options) => {
17505
+ for (const platform of Object.keys(PLATFORM_DEVICES_DESCRIPTION)) {
17506
+ devices.command(platform).description(PLATFORM_DEVICES_DESCRIPTION[platform]).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--all", "Include unavailable / shutdown devices", true).option("--booted-only", "Only show booted / online devices").action(async (options) => {
17348
17507
  try {
17349
17508
  const includeUnavailable = options.bootedOnly ? false : options.all !== false;
17350
17509
  const body = await client(options.baseUrl).listDevices(platform, {
@@ -17360,28 +17519,27 @@ for (const platform of ["ios", "android"]) {
17360
17519
  }
17361
17520
  });
17362
17521
  }
17363
- devices.command("connect").description("Open a Device Session on a device").argument("<deviceId>", "Device UDID / serial").requiredOption("--platform <platform>", "ios | android").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--bundle-id <id>", "iOS bundle id to launch").option("--app-package <id>", "Android application id to launch").option("--lane <lane>", "direct | auto (the only Lane is Direct)").option("--json", "Print raw JSON").action(async (deviceId, options) => {
17522
+ devices.command("connect").description("Open a Device Session on a device").argument("<deviceId>", "Device UDID / serial, a browser id (web-chrome), or chromium-cdp-<port> for a Chromium you started").option("--platform <platform>", "ios | android | web (web for web-* and chromium-cdp-<port>)").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--bundle-id <id>", "iOS bundle id to launch").option("--app-package <id>", "Android application id to launch").option("--lane <lane>", "direct | auto (the only Lane is Direct)").option("--viewport <preset>", "Web only: desktop (1280×800, the default) | mobile (390×844, touch and a mobile user agent)").option("--json", "Print raw JSON").action(async (deviceId, options) => {
17364
17523
  try {
17365
- const platform = options.platform;
17366
- if (platform !== "ios" && platform !== "android") {
17367
- throw new Error("--platform must be ios or android");
17368
- }
17524
+ const platform = connectPlatform(deviceId, options.platform);
17369
17525
  const lane = options.lane;
17370
17526
  if (lane != null && lane !== "direct" && lane !== "auto") {
17371
17527
  throw new Error("--lane must be direct or auto");
17372
17528
  }
17529
+ const viewport = parseViewportPreset(options.viewport, platform);
17373
17530
  const body = await client(options.baseUrl).connectDevice({
17374
17531
  deviceId,
17375
17532
  platform,
17376
17533
  bundleId: options.bundleId,
17377
17534
  appPackage: options.appPackage,
17378
- lane
17535
+ lane,
17536
+ ...viewport ? { viewport } : {}
17379
17537
  });
17380
17538
  if (options.json) {
17381
17539
  console.log(JSON.stringify(body, null, 2));
17382
17540
  return;
17383
17541
  }
17384
- console.log(`connected ${body.platform} ${body.deviceId} lane ${body.lane ?? "direct"}`);
17542
+ console.log(`connected ${describeSession(body)}`);
17385
17543
  if (body.laneWarning)
17386
17544
  console.log(`lane warning: ${body.laneWarning}`);
17387
17545
  } catch (error) {
@@ -17406,7 +17564,7 @@ devices.command("active").description("Show the currently connected device").opt
17406
17564
  console.log(JSON.stringify(body, null, 2));
17407
17565
  return;
17408
17566
  }
17409
- console.log(`${body.platform} ${body.deviceId} lane ${body.lane ?? "direct"}`);
17567
+ console.log(describeSession(body));
17410
17568
  if (body.laneWarning)
17411
17569
  console.log(`lane warning: ${body.laneWarning}`);
17412
17570
  } catch (error) {
@@ -17453,7 +17611,7 @@ program2.command("screenshot").description("Capture a screenshot from the active
17453
17611
  });
17454
17612
  var action = program2.command("action").description("Perform an action on the active device");
17455
17613
  function addActionOptions(cmd) {
17456
- return cmd.option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("-d, --description <text>", "Ground target by natural language").option("--label <text>", "Tap/focus by accessibility label (cleaned screen tree)").option("--id <text>", "Tap/focus by resource-id / accessibility id").option("--x <n>", "X in 0–1000", (v) => Number(v)).option("--y <n>", "Y in 0–1000", (v) => Number(v)).option("--x2 <n>", "End X in 0–1000", (v) => Number(v)).option("--y2 <n>", "End Y in 0–1000", (v) => Number(v)).option("--duration <ms>", "Gesture duration ms (long-press for tap)", (v) => Number(v)).option("--double", "Double-tap (tap only)").option("--text <text>", "Text to type").option("--app-id <id>", "Bundle id / application id").option("--url <url>", "URL to open").option("--seconds <n>", "Background seconds", (v) => Number(v)).option("--screenshot", "Wait for the screen to settle, then return the result screenshot (and a copy marking where the tap or swipe landed)").option("--settle <ms>", "With --screenshot: longest wait for the screen to settle (default 1500)", (v) => Number(v)).option("--full", "With --screenshot: return the raw Result screenshot instead of the Agent image").option("--scale <n>", "With --screenshot: Agent image scale 0.01–1 (default: long edge ~1000 px)", (v) => Number(v));
17614
+ return cmd.option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("-d, --description <text>", "Ground target by natural language").option("--label <text>", "Tap/focus by accessibility label (cleaned screen tree)").option("--id <text>", "Tap/focus by resource-id / accessibility id").option("--x <n>", "X in 0–1000", (v) => Number(v)).option("--y <n>", "Y in 0–1000", (v) => Number(v)).option("--x2 <n>", "End X in 0–1000", (v) => Number(v)).option("--y2 <n>", "End Y in 0–1000", (v) => Number(v)).option("--duration <ms>", "Gesture duration ms (long-press for tap)", (v) => Number(v)).option("--double", "Double-tap (tap only)").option("--text <text>", "Text to type").option("--app-id <id>", "Bundle id / application id, or the Web URL on web").option("--url <url>", "URL to open").option("--seconds <n>", "Background seconds", (v) => Number(v)).option("--screenshot", "Wait for the screen to settle, then return the result screenshot (and a copy marking where the tap or swipe landed)").option("--settle <ms>", "With --screenshot: longest wait for the screen to settle (default 1500)", (v) => Number(v)).option("--full", "With --screenshot: return the raw Result screenshot instead of the Agent image").option("--scale <n>", "With --screenshot: Agent image scale 0.01–1 (default: long edge ~1000 px)", (v) => Number(v));
17457
17615
  }
17458
17616
  function printResultScreenshot(shot) {
17459
17617
  console.log(`screenshot ${shot.path}`);
@@ -17538,6 +17696,32 @@ addActionOptions(action.command("alert").description("Accept or dismiss a system
17538
17696
  fail("action alert", error);
17539
17697
  }
17540
17698
  });
17699
+ addActionOptions(action.command("key").description(`Press a key: ${KEY_NAMES.join(", ")}`).argument("<key>", "Key name, in any case").option("--modifiers <list>", `Held while the key is pressed, comma-separated: ${KEY_MODIFIERS.join(", ")}`)).action(async (keyName, options) => {
17700
+ try {
17701
+ const key = parseKeyName(keyName);
17702
+ const modifiers = typeof options.modifiers === "string" ? parseKeyModifiers(options.modifiers) : [];
17703
+ const body = await client(String(options.baseUrl)).performAction({
17704
+ kind: "key",
17705
+ key,
17706
+ ...modifiers.length > 0 ? { modifiers } : {},
17707
+ screenshot: options.screenshot === true ? true : undefined,
17708
+ settleMs: options.settle,
17709
+ fullImage: options.full === true ? true : undefined,
17710
+ imageScale: options.scale
17711
+ });
17712
+ if (options.json) {
17713
+ console.log(JSON.stringify(body, null, 2));
17714
+ return;
17715
+ }
17716
+ console.log(`ok ${body.kind}`);
17717
+ if (body.screenshot)
17718
+ printResultScreenshot(body.screenshot);
17719
+ if (body.phases)
17720
+ printActionPhases(body.phases);
17721
+ } catch (error) {
17722
+ fail("action key", error);
17723
+ }
17724
+ });
17541
17725
  addActionOptions(action.command("batch").description("Run known Actions from a JSON file or stdin (one Settle and Result screenshot at the end). Description-grounded steps are rejected.").argument("[file]", "JSON file of { steps: Action[] } or an Action array. Omit to read stdin.")).action(async (file, options) => {
17542
17726
  try {
17543
17727
  const raw = file ? await readFile2(file, "utf8") : await new Promise((resolve, reject) => {
@@ -17741,29 +17925,43 @@ signing.command("check").description("Sign YoqaRunner for a cabled iPhone now; r
17741
17925
  fail("signing check", error);
17742
17926
  }
17743
17927
  });
17744
- var assertCmd = program2.command("assert").description("Assert text visibility on the active device");
17928
+ var assertCmd = program2.command("assert").description("Assert text or id visibility on the active device");
17745
17929
  for (const kind of ["visible", "not-visible"]) {
17746
- assertCmd.command(kind).description(kind === "visible" ? "Fail unless matching text appears in the cleaned screen tree" : "Fail if matching text is still present in the cleaned screen tree").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").requiredOption("-t, --text <text>", "Text / label substring to match").option("--timeout <seconds>", "Seconds to wait before failing", (v) => Number(v), 5).action(async (options) => {
17930
+ assertCmd.command(kind).description(kind === "visible" ? "Fail unless matching text or id appears in the cleaned screen tree" : "Fail if matching text or id is still present in the cleaned screen tree").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("-t, --text <text>", "Text / label substring to match").option("--id <id>", "Element id to match, as a tap by id does (instead of --text)").option("--timeout <seconds>", "Seconds to wait before failing", (v) => Number(v), 5).action(async (options) => {
17931
+ if (options.text == null === (options.id == null)) {
17932
+ const usage = options.text != null ? "pass --text or --id, not both" : "pass --text <text> or --id <id>";
17933
+ console.error(`yoqa assert ${kind} failed: ${usage}`);
17934
+ process.exitCode = ASSERT_EXIT_CODE.couldNotCheck;
17935
+ return;
17936
+ }
17937
+ const check = {
17938
+ assertion: kind,
17939
+ ...options.id != null ? { id: options.id } : { text: options.text },
17940
+ timeoutMs: options.timeout * 1000
17941
+ };
17747
17942
  try {
17748
- const line = formatAssertShellLine({
17749
- assertion: kind,
17750
- text: options.text,
17751
- timeoutSeconds: options.timeout
17943
+ const c = client(options.baseUrl);
17944
+ const { met } = await checkScreen({
17945
+ ...check,
17946
+ readScreen: () => c.getScreen(),
17947
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
17948
+ now: Date.now
17752
17949
  });
17753
- const result = await runYoqaShellScript(client(options.baseUrl), line);
17950
+ const error = met ? undefined : screenCheckMissMessage(check);
17951
+ if (!met)
17952
+ process.exitCode = ASSERT_EXIT_CODE.notMet;
17754
17953
  if (options.json) {
17755
- console.log(JSON.stringify(result, null, 2));
17756
- if (!result.ok)
17757
- process.exitCode = 1;
17954
+ console.log(JSON.stringify({ ok: met, completed: met ? 1 : 0, total: 1, error }, null, 2));
17758
17955
  return;
17759
17956
  }
17760
- if (!result.ok) {
17761
- fail(`assert ${kind}`, new Error(result.error ?? "assertion failed"));
17957
+ if (error) {
17958
+ console.error(`yoqa assert ${kind} failed: ${error}`);
17762
17959
  return;
17763
17960
  }
17764
17961
  console.log(`ok assert ${kind}`);
17765
17962
  } catch (error) {
17766
17963
  fail(`assert ${kind}`, error);
17964
+ process.exitCode = ASSERT_EXIT_CODE.couldNotCheck;
17767
17965
  }
17768
17966
  });
17769
17967
  }
@@ -17783,10 +17981,14 @@ appsCmd.command("list").option("--base-url <url>", "Runner base URL", runnerBase
17783
17981
  fail("apps list", error);
17784
17982
  }
17785
17983
  });
17786
- appsCmd.command("create").description("Create a local catalog app").requiredOption("--name <name>", "Display name").option("--prefix <prefix>", "CLI prefix (allocated from name when omitted)").option("--ios-bundle-id <id>", "iOS bundle id").option("--android-application-id <id>", "Android application id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (options) => {
17984
+ appsCmd.command("create").description("Create a local catalog app").requiredOption("--name <name>", "Display name").option("--prefix <prefix>", "CLI prefix (allocated from name when omitted)").option("--ios-bundle-id <id>", "iOS bundle id").option("--android-application-id <id>", "Android application id").option("--web-url <url>", "Web URL a Run on web opens (http or https)").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (options) => {
17787
17985
  try {
17788
17986
  const c = client(options.baseUrl);
17789
- let app = await c.createApp({ name: options.name, prefix: options.prefix });
17987
+ let app = await c.createApp({
17988
+ name: options.name,
17989
+ prefix: options.prefix,
17990
+ webUrl: options.webUrl
17991
+ });
17790
17992
  if (options.iosBundleId || options.androidApplicationId) {
17791
17993
  app = await c.updateApp(app.id, {
17792
17994
  iosBundleId: options.iosBundleId,
@@ -17817,11 +18019,12 @@ appsCmd.command("get").argument("<app>", "App prefix or id").option("--base-url
17817
18019
  console.log(`app_knowledge: ${app.knowledge || "(empty)"}`);
17818
18020
  console.log(`ios_bundle_id: ${app.iosBundleId || "(empty)"}`);
17819
18021
  console.log(`android_application_id: ${app.androidApplicationId || "(empty)"}`);
18022
+ console.log(`web_url: ${app.webUrl || "(empty)"}`);
17820
18023
  } catch (error) {
17821
18024
  fail("apps get", error);
17822
18025
  }
17823
18026
  });
17824
- appsCmd.command("update").argument("<app>", "App prefix or id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--name <name>", "Display name").option("--prefix <prefix>", "CLI prefix").option("--context <text>", "App context / shared rules").option("--knowledge <text>", "App knowledge notes injected into agent runs").option("--ios-bundle-id <id>", "iOS bundle id").option("--android-application-id <id>", "Android application id").option("--json", "Print raw JSON").action(async (appArg, options) => {
18027
+ appsCmd.command("update").argument("<app>", "App prefix or id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--name <name>", "Display name").option("--prefix <prefix>", "CLI prefix").option("--context <text>", "App context / shared rules").option("--knowledge <text>", "App knowledge notes injected into agent runs").option("--ios-bundle-id <id>", "iOS bundle id").option("--android-application-id <id>", "Android application id").option("--web-url <url>", "Web URL a Run on web opens (empty clears it)").option("--json", "Print raw JSON").action(async (appArg, options) => {
17825
18028
  try {
17826
18029
  const c = client(options.baseUrl);
17827
18030
  const resolved = await resolveAppId(c, appArg);
@@ -17831,7 +18034,8 @@ appsCmd.command("update").argument("<app>", "App prefix or id").option("--base-u
17831
18034
  context: options.context,
17832
18035
  knowledge: options.knowledge,
17833
18036
  iosBundleId: options.iosBundleId,
17834
- androidApplicationId: options.androidApplicationId
18037
+ androidApplicationId: options.androidApplicationId,
18038
+ webUrl: options.webUrl
17835
18039
  });
17836
18040
  if (options.json) {
17837
18041
  console.log(JSON.stringify(app, null, 2));
@@ -18155,7 +18359,7 @@ runsCmd.command("delete").argument("<runId>", "Run id").option("--base-url <url>
18155
18359
  fail("runs delete", error);
18156
18360
  }
18157
18361
  });
18158
- runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("--cases <numbers>", "Comma-separated case numbers, e.g. 1,2,5").option("--device <id>", "Device id (defaults to active session)").option("--platform <platform>", "ios | android (defaults to active session)").option("--build-id <id>", "Registered build id to install before run").option("--build-path <path>", "Absolute build path to register + install").option("--mode <mode>", "auto | script | agent (default: auto)").option("--screen-mode <mode>", "vision | tree — what the agent sees: screenshot only, or also the accessibility tree (default: vision)").option("--lane <lane>", "direct | auto").option("--record-video", "Record a screen video of every case in the run, even cases not set to record").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--wait", "Wait until the run finishes (passed / errored / cancelled)").option("--timeout <seconds>", "Wait timeout in seconds (default: 1800)", String(DEFAULT_RUN_WAIT_TIMEOUT_SECONDS)).option("--github-output", "Write run_id and status to $GITHUB_OUTPUT").option("--no-github-output", "Do not write GitHub step outputs").action(async (appArg, options) => {
18362
+ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("--cases <numbers>", "Comma-separated case numbers, e.g. 1,2,5").option("--device <id>", "Device id (defaults to active session)").option("--platform <platform>", "ios | android | web (web for web-* and chromium-cdp-<port>; defaults to active session)").option("--build-id <id>", "Registered build id to install before run").option("--build-path <path>", "Absolute build path to register + install").option("--mode <mode>", "auto | script | agent (default: auto)").option("--screen-mode <mode>", "vision | tree — what the agent sees: screenshot only, or also the accessibility tree (default: vision)").option("--lane <lane>", "direct | auto").option("--record-video", "Record a screen video of every case in the run, even cases not set to record").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--wait", "Wait until the run finishes (passed / errored / cancelled)").option("--timeout <seconds>", "Wait timeout in seconds (default: 1800)", String(DEFAULT_RUN_WAIT_TIMEOUT_SECONDS)).option("--github-output", "Write run_id and status to $GITHUB_OUTPUT").option("--no-github-output", "Do not write GitHub step outputs").action(async (appArg, options) => {
18159
18363
  try {
18160
18364
  const c = client(options.baseUrl);
18161
18365
  const resolved = await resolveAppId(c, appArg);
@@ -18171,18 +18375,16 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
18171
18375
  caseIds.push(found.id);
18172
18376
  }
18173
18377
  let deviceId = options.device;
18174
- let platform = options.platform;
18175
- if (!deviceId || !platform) {
18378
+ let platformOption = options.platform ?? (deviceId && isWebDeviceId(deviceId) ? "web" : undefined);
18379
+ if (!deviceId || !platformOption) {
18176
18380
  const active = await c.getActiveDevice();
18177
18381
  if (!active) {
18178
18382
  throw new Error("No --device/--platform and no active session. Connect first or pass both flags.");
18179
18383
  }
18180
18384
  deviceId = deviceId ?? active.deviceId;
18181
- platform = platform ?? active.platform;
18182
- }
18183
- if (platform !== "ios" && platform !== "android") {
18184
- throw new Error("--platform must be ios or android");
18385
+ platformOption = platformOption ?? active.platform;
18185
18386
  }
18387
+ const platform = parsePlatform(platformOption);
18186
18388
  const executionMode = options.mode === "script" || options.mode === "agent" || options.mode === "auto" ? options.mode : undefined;
18187
18389
  if (options.mode && !executionMode) {
18188
18390
  throw new Error("--mode must be auto, script, or agent");
@@ -18269,23 +18471,26 @@ scriptCmd.command("run").argument("<file>", "Path to a Case Script file (.yoqa.y
18269
18471
  }
18270
18472
  const result = await runCaseScript(c, script, {
18271
18473
  pauseAfterActionMs: 800,
18272
- onStep: ({ index, total, action: action2, status, error, response }) => {
18474
+ onStep: (event) => {
18475
+ const { position, total, action: action2, status, error, response, outcome } = event;
18273
18476
  if (status === "running")
18274
18477
  return;
18275
- const step = index + 1;
18478
+ const step = position + 1;
18276
18479
  if (options.json) {
18277
18480
  console.log(JSON.stringify({
18278
18481
  ok: status === "ok",
18279
18482
  status,
18280
18483
  type: action2.type,
18281
18484
  step,
18485
+ path: event.path,
18282
18486
  command: caseScriptActionCommand(action2),
18487
+ ...outcome ? { outcome } : {},
18283
18488
  ...error ? { error } : {},
18284
18489
  ...response ? { response } : {}
18285
18490
  }, null, 2));
18286
18491
  return;
18287
18492
  }
18288
- const line = formatCaseScriptFileAction(action2);
18493
+ const line = formatCaseScriptReplayStep(event);
18289
18494
  if (status === "ok")
18290
18495
  console.log(`ok ${line} (${step}/${total})`);
18291
18496
  else if (status === "error")
@@ -18295,10 +18500,11 @@ scriptCmd.command("run").argument("<file>", "Path to a Case Script file (.yoqa.y
18295
18500
  }
18296
18501
  });
18297
18502
  if (!result.ok) {
18298
- throw new Error(`action ${(result.failedIndex ?? 0) + 1}/${result.total} failed: ${result.error ?? "failed"}`);
18503
+ const where = result.failedPath ? formatCaseScriptStepPath(result.failedPath) : `action ${(result.failedIndex ?? 0) + 1}`;
18504
+ throw new Error(`${where} failed: ${result.error ?? "failed"}`);
18299
18505
  }
18300
18506
  if (!options.json) {
18301
- console.log(`done ${result.total} action${result.total === 1 ? "" : "s"}`);
18507
+ console.log(`done ${result.total} step${result.total === 1 ? "" : "s"}`);
18302
18508
  }
18303
18509
  } catch (error) {
18304
18510
  fail("script run", error);
@@ -18335,10 +18541,9 @@ scriptCmd.command("export").argument("<app>", "App prefix or id").argument("<num
18335
18541
  });
18336
18542
  program2.command("benchmark").description("Compare Yoqa (and Argent, when installed) tap-to-result latency on one device").requiredOption("--device <id>", "Device UDID / serial").requiredOption("--platform <platform>", "ios | android").option("--repeats <n>", "Tap-to-result repeats (default: suite repeats)", (v) => Number(v)).option("--suite <path>", "Versioned suite JSON (default: built-in v1)").option("--tools <list>", "Comma-separated: yoqa,argent (default: yoqa)", "yoqa").option("--lane <lane>", "Yoqa Lane: direct | auto", "auto").option("--arms <list>", "Yoqa Screen arms: vision,tree (default: suite arms)").option("--app <id>", "Catalog App id for Case arms (required when the suite has cases)").option("--out <path>", "Write the JSON report to this path").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print JSON instead of the table").action(async (options) => {
18337
18543
  try {
18338
- const platform = options.platform;
18339
- if (platform !== "ios" && platform !== "android") {
18340
- throw new Error("--platform must be ios or android");
18341
- }
18544
+ const platform = parsePlatform(options.platform);
18545
+ if (platform === "web")
18546
+ throw new Error("benchmark runs on ios or android only");
18342
18547
  const lane = options.lane === "direct" || options.lane === "auto" ? options.lane : undefined;
18343
18548
  if (options.lane && !lane)
18344
18549
  throw new Error("--lane must be direct or auto");