@aarwitz/tapp 0.17.9 → 0.17.10

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tapp",
3
3
  "description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
4
- "version": "0.17.9",
4
+ "version": "0.17.10",
5
5
  "author": {
6
6
  "name": "Aaron Horowitz",
7
7
  "url": "https://github.com/aarwitz"
@@ -24,7 +24,7 @@
24
24
  "command": "npx",
25
25
  "args": [
26
26
  "-y",
27
- "@aarwitz/tapp@0.17.9",
27
+ "@aarwitz/tapp@0.17.10",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
@@ -1082,11 +1082,23 @@ class ExplorerTests: XCTestCase {
1082
1082
 
1083
1083
  let testEmail = resolve("OCQA_TEST_EMAIL")
1084
1084
  let testPassword = resolve("OCQA_TEST_PASSWORD")
1085
- if resolve("OCQA_CREDENTIALS_EXPLICIT") == "1" {
1086
- // Presence only: never print, persist, or expose credential values. Report rebuilding
1087
- // needs this durable marker to distinguish "not supplied" from "supplied but unused".
1085
+ // Presence only: never print, persist, or expose credential values. Report rebuilding
1086
+ // needs this durable marker to distinguish "not supplied" from "supplied but unused".
1087
+ // Decided by what actually reached the run config, not by a separate flag the run config
1088
+ // never carried (feedback #3: every iOS run reported credentialsProvided=false).
1089
+ if resolve("OCQA_CREDENTIALS_EXPLICIT") == "1" || !testEmail.isEmpty || !testPassword.isEmpty {
1088
1090
  print("OCQA_STATE:credentials_supplied")
1089
1091
  }
1092
+ // Capture conditions shared by every screenshot in this run, so a baseline diff across a
1093
+ // different device/scale is visibly a layout comparison (feedback #5: iOS reports carried
1094
+ // captureContext: null while the changelog promised it).
1095
+ do {
1096
+ let env = ProcessInfo.processInfo.environment
1097
+ let device = env["SIMULATOR_DEVICE_NAME"] ?? env["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS Simulator"
1098
+ let bounds = app.windows.firstMatch.exists ? app.windows.firstMatch.frame : app.frame
1099
+ let scale = UIScreen.main.scale
1100
+ print("OCQA_CONTEXT:{\"device\":\"\(escapeJSON(device))\",\"viewport\":{\"width\":\(Int(bounds.width.rounded())),\"height\":\(Int(bounds.height.rounded()))},\"deviceScaleFactor\":\(scale)}")
1101
+ }
1090
1102
 
1091
1103
  // --- Explicit login replay (config-driven): a recorded type/tap/wait sequence for custom
1092
1104
  // login UIs the heuristic preamble below can't parse. When configured it takes precedence. ---
package/README.md CHANGED
@@ -199,6 +199,7 @@ one by hand:
199
199
 
200
200
  ```bash
201
201
  npx -y @aarwitz/tapp@latest flow example
202
+ npx -y @aarwitz/tapp@latest flow steps # the step vocabulary: target semantics and pass condition per step
202
203
  npx -y @aarwitz/tapp@latest flow validate .tapp/flows/smoke.yml
203
204
  npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
204
205
  ```
package/bin/tapp.js CHANGED
@@ -270,7 +270,7 @@ function safeCommandUsage(verb) {
270
270
  shot: "tapp shot [--out FILE]",
271
271
  apps: "tapp apps",
272
272
  build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
273
- flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
273
+ flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
274
274
  task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
275
275
  contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
276
276
  scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
@@ -1133,8 +1133,16 @@ switch (command) {
1133
1133
  console.log(`# Tapp Flow — deterministic, keyless replay\nname: sign-in-smoke\nplatform: web\nurl: https://example.test/login\nsteps:\n - login:\n email: $TEST_EMAIL\n password: $TEST_PASSWORD\n - wait_for: Dashboard\n - assert_screen: Dashboard\n`);
1134
1134
  break;
1135
1135
  }
1136
+ if (verb === "steps") {
1137
+ const { FLOW_ACTIONS } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
1138
+ if (flags.json === true) { console.log(JSON.stringify(FLOW_ACTIONS, null, 2)); break; }
1139
+ console.log("Flow step vocabulary — identical on ios, android, and web. Anything else fails `tapp flow validate`.\n");
1140
+ for (const a of FLOW_ACTIONS) console.log(` ${a.action.padEnd(14)} target: ${a.target}\n ${"".padEnd(14)} passes: ${a.passes}\n`);
1141
+ console.log("Targets are labels / accessibility ids / visible text, never coordinates. `assert_screen` checks the detected screen TITLE; use `assert_exists` for \"this text is on screen\".");
1142
+ break;
1143
+ }
1136
1144
  if (!["run", "validate"].includes(verb) || !flowPath) {
1137
- console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
1145
+ console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
1138
1146
  process.exit(2);
1139
1147
  }
1140
1148
  const absolute = path.resolve(flowPath);
@@ -1142,7 +1150,7 @@ switch (command) {
1142
1150
  console.error(`❌ Flow not found: ${absolute}`);
1143
1151
  process.exit(2);
1144
1152
  }
1145
- const { loadFlowFile } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
1153
+ const { loadFlowFile, validateFlowSteps } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
1146
1154
  let flow;
1147
1155
  try { flow = loadFlowFile(absolute); } catch (error) {
1148
1156
  console.error(`❌ Invalid Flow: ${error.message}`);
@@ -1157,6 +1165,13 @@ switch (command) {
1157
1165
  console.error(`❌ Unsupported Flow platform: ${platform}`);
1158
1166
  process.exit(2);
1159
1167
  }
1168
+ // Static step checks run before validate AND run: a Flow outside the vocabulary (or a
1169
+ // coordinate tap) can never replay, so it must not reach a simulator (feedback #2).
1170
+ const stepErrors = validateFlowSteps(flow, platform);
1171
+ if (stepErrors.length) {
1172
+ console.error(`❌ Invalid ${platform} Flow — ${flow.name || path.basename(absolute)}:\n${stepErrors.map((e) => ` • ${e}`).join("\n")}\n Vocabulary: tapp flow steps`);
1173
+ process.exit(2);
1174
+ }
1160
1175
  if (verb === "validate") {
1161
1176
  const taskCount = Array.isArray(flow.taskPlan) ? flow.taskPlan.length : 0;
1162
1177
  console.log(`✅ Valid ${platform} Flow — ${flow.name} (${flow.steps.length} deterministic steps${taskCount ? ` compiled from ${taskCount} Task call${taskCount === 1 ? "" : "s"}` : ""})`);
package/docs/scenarios.md CHANGED
@@ -74,7 +74,7 @@ tapp ci --platform web --url http://127.0.0.1:4180 \
74
74
  GitHub Action:
75
75
 
76
76
  ```yaml
77
- - uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
77
+ - uses: aarwitz/tapp@v0.17.10 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -86,12 +86,19 @@ export function ghStatus(ghBin = process.env.TAPP_GH_BIN || "gh") {
86
86
  }
87
87
 
88
88
  export function submitFeedbackViaGh(issue, ghBin = process.env.TAPP_GH_BIN || "gh") {
89
- const r = spawnSync(ghBin, [
89
+ // Labels can only be set by accounts with triage rights on the repo. Anyone else (which is
90
+ // every real user) gets the issue filed without them; the footer already carries the kind
91
+ // and the maintainer applies labels on triage. Try with labels first, then without.
92
+ const attempt = (withLabels) => spawnSync(ghBin, [
90
93
  "issue", "create", "--repo", FEEDBACK_REPO,
91
- "--title", issue.title, "--body-file", "-", "--label", issue.labels.join(","),
94
+ "--title", issue.title, "--body-file", "-",
95
+ ...(withLabels ? ["--label", issue.labels.join(",")] : []),
92
96
  ], { encoding: "utf8", input: issue.body });
97
+ let r = attempt(true);
98
+ let labelsApplied = r.status === 0;
99
+ if (r.status !== 0 && /label/i.test(`${r.stderr || ""}${r.stdout || ""}`)) { r = attempt(false); labelsApplied = false; }
93
100
  const out = (r.stdout || "").trim();
94
101
  const err = (r.stderr || "").trim();
95
102
  const url = (out.match(/https:\/\/github\.com\/\S+/) || [])[0] || null;
96
- return { ok: r.status === 0 && Boolean(url), url, detail: r.status === 0 ? out : (err || out) };
103
+ return { ok: r.status === 0 && Boolean(url), url, labelsApplied, detail: r.status === 0 ? out : (err || out) };
97
104
  }
@@ -36,6 +36,60 @@ export function normalizeFlowStep(raw) {
36
36
  return { action: key.toLowerCase(), target: body === true ? "" : String(body ?? ""), value: body === true ? "" : String(body ?? ""), params: {}, ...(raw.__tappTask?.name ? { task: raw.__tappTask.name } : {}) };
37
37
  }
38
38
 
39
+ // The committed Flow step vocabulary. Every driver (XCUITest, Android, browser) implements exactly
40
+ // this table; `tapp flow steps` prints it and `tapp flow validate` rejects anything outside it, so
41
+ // a Flow that validates can actually replay (feedback #2: coordinate taps and `click:` used to
42
+ // validate and then fail at runtime).
43
+ export const FLOW_ACTIONS = Object.freeze([
44
+ { action: "tap", target: "a visible label / accessibility id", passes: "the control was found and tapped; coordinates are not accepted — use the session's tap-by-point to learn the label" },
45
+ { action: "type", target: "{field, value}", passes: "the field was found and now holds the value ($TEST_EMAIL/$TEST_PASSWORD substitute)" },
46
+ { action: "login", target: "{email, password} (defaults to $TEST_EMAIL/$TEST_PASSWORD)", passes: "credentials were entered and submitted and the login form went away" },
47
+ { action: "swipe", target: "up | down | left | right", passes: "the gesture was performed" },
48
+ { action: "back", target: "(none)", passes: "the platform back navigation was performed" },
49
+ { action: "wait", target: "milliseconds (fixed pause; prefer wait_for)", passes: "always" },
50
+ { action: "wait_for", target: "label / text (+ timeoutMs)", passes: "the element appeared before the timeout" },
51
+ { action: "assert_screen", target: "the detected SCREEN TITLE (navigation bar / heading), not arbitrary text", passes: "the current screen's title equals the target" },
52
+ { action: "assert_exists", target: "label / text", passes: "an element with that text or id is present" },
53
+ { action: "assert_absent", target: "label / text", passes: "no element with that text or id is present" },
54
+ { action: "assert_text", target: "{of, contains}", passes: "the element's text contains the substring" },
55
+ { action: "assert_ai", target: "a natural-language expectation", passes: "the vision judge agrees (needs ANTHROPIC_API_KEY; advisory)" },
56
+ ]);
57
+ const FLOW_ACTION_NAMES = new Set(FLOW_ACTIONS.map((a) => a.action));
58
+ const ALIASES = { click: "tap", press: "tap", fill: "type", input: "type", sleep: "wait", wait_for_text: "wait_for", assert_visible: "assert_exists", expect: "assert_exists" };
59
+
60
+ // Static checks a Flow must pass before any runtime is launched. Returns human-readable errors;
61
+ // an empty array means every step is in the vocabulary and shaped so a driver can execute it.
62
+ export function validateFlowSteps(flow, platform = inferFlowPlatform(flow)) {
63
+ const errors = [];
64
+ const steps = Array.isArray(flow?.steps) ? flow.steps : [];
65
+ steps.forEach((raw, i) => {
66
+ const step = normalizeFlowStep(raw);
67
+ const n = i + 1;
68
+ if (step.action === "noop") { errors.push(`step ${n}: empty step`); return; }
69
+ if (!FLOW_ACTION_NAMES.has(step.action)) {
70
+ const alias = ALIASES[step.action];
71
+ errors.push(`step ${n}: unknown action '${step.action}'${alias ? ` — did you mean '${alias}'? (web flows use tap:, not click:)` : ""}; run \`tapp flow steps\` for the vocabulary`);
72
+ return;
73
+ }
74
+ if (step.action === "tap" && /^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$/.test(step.target)) {
75
+ errors.push(`step ${n}: tap target '${step.target.trim()}' is a coordinate; Flow taps are label-only on ${platform} (use tapp_session_act tap {x,y} to learn the label, then record it)`);
76
+ }
77
+ if (["tap", "wait_for", "assert_screen", "assert_exists", "assert_absent"].includes(step.action) && !step.target.trim()) {
78
+ errors.push(`step ${n}: ${step.action} needs a target`);
79
+ }
80
+ if (step.action === "type" && (!step.target.trim() || !("value" in (step.params || {})))) {
81
+ errors.push(`step ${n}: type needs {field, value}`);
82
+ }
83
+ if (step.action === "assert_text" && (!step.target.trim() || !step.value)) {
84
+ errors.push(`step ${n}: assert_text needs {of, contains}`);
85
+ }
86
+ if (step.action === "swipe" && step.target && !["up", "down", "left", "right"].includes(step.target.trim().toLowerCase())) {
87
+ errors.push(`step ${n}: swipe direction must be up|down|left|right`);
88
+ }
89
+ });
90
+ return errors;
91
+ }
92
+
39
93
  export function flowVariables(flow, overrides = {}) {
40
94
  return {
41
95
  TEST_EMAIL: process.env.OCQA_TEST_EMAIL || "test@example.com",
@@ -907,10 +907,39 @@ function recordStep(cmd, result) {
907
907
  if (newScreen) activeSession.lastScreen = newScreen;
908
908
  }
909
909
 
910
+ // Argument shapes tapp_session_act accepts, per action. A malformed call is answered here with
911
+ // the accepted shape and never reaches the driver, so it can neither time out nor disturb the
912
+ // session (feedback #7: `{action:"wait", seconds:3}` used to wait on an empty target).
913
+ const SESSION_ACT_ARGS = Object.freeze({
914
+ tap: "{id: <label|accessibility id>} or {x, y} (points)",
915
+ type: "{id|label: <field>, text: <value>}",
916
+ wait: "{text|id: <label to wait for>, timeoutMs?: <default 5000, max 60000>} — there is no fixed sleep; wait for something",
917
+ login: "{email?, password?} (defaults to the session's credentials)",
918
+ swipe: "{direction: up|down|left|right}",
919
+ back: "{}",
920
+ tree: "{verbose?: true}",
921
+ screenshot: "{label?}",
922
+ });
923
+ export function sessionActUsageError(cmd = {}) {
924
+ const action = String(cmd.action || "");
925
+ if (!SESSION_ACT_ARGS[action]) return `Unknown action '${action || "(none)"}'. Accepted: ${Object.keys(SESSION_ACT_ARGS).join(", ")}.`;
926
+ const has = (k) => cmd[k] !== undefined && cmd[k] !== null && String(cmd[k]).trim() !== "";
927
+ const bad = (why) => `${why}. ${action} takes ${SESSION_ACT_ARGS[action]}.`;
928
+ if (action === "wait" && !has("text") && !has("id")) return bad(`wait needs a target${cmd.seconds !== undefined || cmd.ms !== undefined ? " (seconds/ms are not arguments)" : ""}`);
929
+ if (action === "wait" && cmd.timeoutMs !== undefined && !(Number.isFinite(Number(cmd.timeoutMs)) && Number(cmd.timeoutMs) > 0)) return bad("timeoutMs must be a positive number of milliseconds");
930
+ if (action === "tap" && !has("id") && !has("label") && !(Number.isFinite(cmd.x) && Number.isFinite(cmd.y))) return bad("tap needs an id/label or both x and y");
931
+ if (action === "type" && !has("id") && !has("label")) return bad("type needs the field's id/label");
932
+ if (action === "type" && cmd.text === undefined) return bad("type needs text");
933
+ if (action === "swipe" && cmd.direction !== undefined && !["up", "down", "left", "right"].includes(String(cmd.direction))) return bad("direction must be up|down|left|right");
934
+ return null;
935
+ }
936
+
910
937
  async function sessionAct(cmd) {
911
938
  const startedAt = Date.now();
912
939
  const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
913
940
  if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
941
+ const usage = sessionActUsageError(cmd);
942
+ if (usage) return done({ status: "usage", detail: usage, ...treeSnapshot(), recordedSteps: activeSession.recording.length });
914
943
  let coordinateResolvedTarget = "";
915
944
  if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
916
945
  coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
@@ -298,6 +298,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
298
298
  // 40-action campaign. Drivers signal the real cause in COMPLETE.stop; captures from drivers
299
299
  // that predate the field keep the old inference.
300
300
  const driverStop = base.complete && typeof base.complete === "object" ? base.complete.stop : null;
301
+ const nativeOutcome = base.complete && typeof base.complete === "object" && typeof base.complete.outcome === "string" ? base.complete.outcome : null;
301
302
  const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
302
303
  : timeBudgetExhausted ? "time-budget-exhausted"
303
304
  : !coverageFloorMet ? "coverage-floor-not-met"
@@ -306,6 +307,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
306
307
  // Any other driver-signalled cause (navigation-trap, app-crashed, stuck-no-progress …)
307
308
  // passes through verbatim: "completed" is ONLY the exhausted action budget.
308
309
  : driverStop && driverStop !== "action-budget" && driverStop !== "time-budget" ? String(driverStop)
310
+ // XCUITest predates COMPLETE.stop and signals the cause through COMPLETE.outcome instead
311
+ // (feedback #5: a run whose marker said limited_surface must not read as "completed").
312
+ : nativeOutcome === "limited_surface" ? "limited-surface"
313
+ : nativeOutcome === "timeout" ? "time-budget-exhausted"
314
+ : nativeOutcome && nativeOutcome.startsWith("crash") ? "app-crashed"
309
315
  : "completed";
310
316
 
311
317
  const headline = timeBudgetExhausted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.9",
3
+ "version": "0.17.10",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
6
6
  "license": "MIT",
@@ -63,6 +63,29 @@ def raw_json(path):
63
63
  print(json.dumps(load_flow(path) or {}))
64
64
 
65
65
 
66
+ ABORT_PATTERNS = [
67
+ r"Failed to synthesize event: [^\n]*",
68
+ r"Neither element nor any descendant has keyboard focus[^\n]*",
69
+ r"Test Case '[^']*' failed \([^)]*\)",
70
+ r"Error Domain=[^\n]*",
71
+ r"Unable to (?:find|launch|boot)[^\n]*",
72
+ r"App state is [^\n]*",
73
+ r"Timed out [^\n]*",
74
+ r"Testing failed:[^\n]*",
75
+ r"error: [^\n]*",
76
+ r"\*\* TEST (?:EXECUTE )?FAILED \*\*",
77
+ ]
78
+
79
+
80
+ def harness_abort_reason(log):
81
+ """First XCTest/xcodebuild line that explains why a run ended before step 1."""
82
+ for pat in ABORT_PATTERNS:
83
+ m = re.search(pat, log)
84
+ if m:
85
+ return m.group(0).strip()[:300]
86
+ return None
87
+
88
+
66
89
  def report(path, as_json=False):
67
90
  log = open(path, encoding="utf-8", errors="replace").read()
68
91
  steps = []
@@ -97,8 +120,19 @@ def report(path, as_json=False):
97
120
  executed = (result or {}).get("executed", len(steps))
98
121
  passed_steps = sum(1 for s in steps if s.get("status") == "pass")
99
122
 
123
+ # A run that died before step 1 used to print `0 passed · 0 failed · 0/0 executed` and
124
+ # nothing else; the XCTest reason lived only in flow.log (feedback #1). Surface it as the
125
+ # failure of the first step so the scoreboard says why instead of looking like a crash.
126
+ abort_reason = None
127
+ if not steps and not passed:
128
+ abort_reason = harness_abort_reason(log)
129
+ steps.append({"index": 1, "action": "harness", "target": "", "status": "fail",
130
+ "detail": abort_reason or "the harness exited before the first step; see flow.log"})
131
+ failed = max(failed, 1)
132
+
100
133
  if as_json:
101
- print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed, "steps": steps}))
134
+ print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed,
135
+ **({"abortReason": abort_reason} if abort_reason else {}), "steps": steps}))
102
136
  return 0 if passed else 1
103
137
 
104
138
  icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
@@ -184,7 +184,8 @@ run_harness_test() {
184
184
  "OCQA_MAX_ACTIONS": "$max_actions",
185
185
  "OCQA_TIMEOUT_SECONDS": "$timeout_secs",
186
186
  "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
187
- "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
187
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}",
188
+ "OCQA_CREDENTIALS_EXPLICIT": "${OCQA_CREDENTIALS_EXPLICIT:-}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
188
189
  }
189
190
  CONF
190
191
 
@@ -101,6 +101,8 @@ ask the user rather than pretending the explored surface was complete.
101
101
  Flow YAML belongs under `.tapp/flows/` and can replay without a model or API key:
102
102
 
103
103
  ```bash
104
+ npx -y @aarwitz/tapp@latest flow steps # vocabulary: assert_screen = screen TITLE, assert_exists = text present; taps are label-only
105
+ npx -y @aarwitz/tapp@latest flow validate .tapp/flows/smoke.yml
104
106
  npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
105
107
  npx -y @aarwitz/tapp@latest ci
106
108
  ```