@1aboveio/skills 1.0.16 → 1.0.18

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 (102) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +178 -150
  3. package/README.md +1 -1
  4. package/package.json +5 -4
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-development/SKILL.md +14 -10
  7. package/skills/app-dev/android-development/references/diagnosis.md +4 -6
  8. package/skills/app-dev/android-development/references/feature-work.md +3 -2
  9. package/skills/app-dev/android-development/references/gotchas.md +7 -0
  10. package/skills/app-dev/android-development/references/init.md +5 -2
  11. package/skills/app-dev/android-development/references/setup.md +9 -5
  12. package/skills/app-dev/android-development/references/ui-journeys.md +12 -55
  13. package/skills/app-dev/android-development/scripts/failure_capture.py +23 -84
  14. package/skills/app-dev/android-development/scripts/init.mjs +2 -1
  15. package/skills/app-dev/android-development/scripts/maestro_failure.py +43 -387
  16. package/skills/app-dev/android-development/scripts/test_maestro_failure.py +113 -424
  17. package/skills/app-dev/app-debug/SKILL.md +1 -1
  18. package/skills/app-dev/better-auth/SKILL.md +1 -1
  19. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  20. package/skills/app-dev/data-quality/SKILL.md +1 -1
  21. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  22. package/skills/app-dev/maestro/LICENSE +3 -0
  23. package/skills/app-dev/maestro/SKILL.md +112 -0
  24. package/skills/app-dev/maestro/references/android-connection.md +54 -0
  25. package/skills/app-dev/maestro/references/authoring.md +113 -0
  26. package/skills/app-dev/maestro/references/diagnosis.md +50 -0
  27. package/skills/app-dev/maestro/references/evidence.md +74 -0
  28. package/skills/app-dev/maestro/references/journeys.md +100 -0
  29. package/skills/app-dev/maestro/references/migration.md +35 -0
  30. package/skills/app-dev/maestro/release-check.mjs +95 -0
  31. package/skills/app-dev/maestro/scripts/failure_record.py +110 -0
  32. package/skills/app-dev/maestro/scripts/maestro_failure.py +481 -0
  33. package/skills/app-dev/maestro/scripts/test_maestro_failure.py +710 -0
  34. package/skills/app-dev/monorepo/SKILL.md +1 -1
  35. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  36. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  37. package/skills/app-dev/pyspark/SKILL.md +1 -1
  38. package/skills/app-dev/python-backend/SKILL.md +1 -1
  39. package/skills/app-dev/shadcn/SKILL.md +1 -1
  40. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  41. package/skills/cicd/cloud-build/SKILL.md +1 -1
  42. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  43. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  44. package/skills/cicd/docker/SKILL.md +1 -1
  45. package/skills/cicd/github-actions/SKILL.md +1 -1
  46. package/skills/cicd/github-flow/SKILL.md +1 -1
  47. package/skills/cicd/google-cloud/SKILL.md +1 -1
  48. package/skills/cicd/mergify/SKILL.md +1 -1
  49. package/skills/cicd/podman/SKILL.md +1 -1
  50. package/skills/content/article-review/SKILL.md +1 -1
  51. package/skills/content/article-to-image/SKILL.md +1 -1
  52. package/skills/content/flowchart-svg/SKILL.md +1 -1
  53. package/skills/content/image-gen/SKILL.md +1 -1
  54. package/skills/content/mermaid/SKILL.md +1 -1
  55. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  56. package/skills/content/wechat-publisher/SKILL.md +1 -1
  57. package/skills/content/wechat-search/SKILL.md +1 -1
  58. package/skills/content/xai-search/SKILL.md +1 -1
  59. package/skills/core/distill-writing/SKILL.md +1 -1
  60. package/skills/core/doc-to-md/SKILL.md +1 -1
  61. package/skills/core/editorial-review/SKILL.md +1 -1
  62. package/skills/core/house-style/SKILL.md +1 -1
  63. package/skills/core/plain-language/SKILL.md +1 -1
  64. package/skills/core/polish-cn/SKILL.md +1 -1
  65. package/skills/core/reg-parser/SKILL.md +1 -1
  66. package/skills/core/terminology/SKILL.md +1 -1
  67. package/skills/core/write-doc/SKILL.md +1 -1
  68. package/skills/core/write-report/SKILL.md +1 -1
  69. package/skills/engineering/better-review/SKILL.md +1 -1
  70. package/skills/engineering/e2e-test/SKILL.md +1 -1
  71. package/skills/engineering/engineering/SKILL.md +1 -1
  72. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  73. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  74. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  75. package/skills/engineering/golden-path/SKILL.md +1 -1
  76. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  77. package/skills/engineering/harness-runtime/agent-catalog.mjs +46 -12
  78. package/skills/engineering/harness-runtime/contracts.md +1 -1
  79. package/skills/engineering/resolve-issues/SKILL.md +11 -9
  80. package/skills/engineering/resolve-issues/references/exploration.md +60 -18
  81. package/skills/engineering/resolve-issues/references/implementation.md +1 -1
  82. package/skills/engineering/resolve-issues/references/preflight.md +47 -22
  83. package/skills/engineering/resolve-issues/references/review.md +2 -1
  84. package/skills/engineering/resolve-issues/references/shared-modules.md +58 -4
  85. package/skills/engineering/resolve-issues/references/task-plan.md +30 -10
  86. package/skills/engineering/resolve-issues/scripts/findings-reuse.mjs +1 -1
  87. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +7 -7
  88. package/skills/engineering/resolve-issues/scripts/plan-sync.mjs +1 -1
  89. package/skills/engineering/resolve-issues/scripts/size-check.mjs +6 -5
  90. package/skills/engineering/resolve-release/SKILL.md +1 -1
  91. package/skills/engineering/rush-issues/SKILL.md +1 -1
  92. package/skills/engineering/rush-release/SKILL.md +1 -1
  93. package/skills/engineering/test-quality/SKILL.md +1 -1
  94. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  95. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  96. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  97. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  98. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  99. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  100. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
  101. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  102. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
@@ -0,0 +1,110 @@
1
+ """The device failure record: shared shape, read and write, no device needed.
2
+
3
+ Every layer that records a failed device case writes the same ``failure.json`` and
4
+ the same one-shot ``NATIVE_FAILURE {...}`` line:
5
+
6
+ case, step, error, screenshot, uiDump, logcatTail, evidenceDir, cleanupErrors
7
+
8
+ This module owns that shape, its constants and the file access. The Android capture
9
+ helper (``android-development/scripts/failure_capture.py``), the ``device-run.sh``
10
+ fallback and the framework adapters described in that skill's ``references/
11
+ diagnosis.md`` share it, and the Maestro converter in this folder writes the record
12
+ through it, so the shape cannot drift between the layers. Nothing here needs a
13
+ device, adb or an SDK.
14
+
15
+ ``screenshot``, ``uiDump`` and ``logcatTail`` are file names relative to
16
+ ``evidenceDir`` and are ``null`` when that capture failed; the reason goes to
17
+ ``cleanupErrors`` and never replaces the original ``error``. A record is written
18
+ once: the first failure is kept, and a later failure or a cleanup problem never
19
+ overwrites it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import tempfile
27
+ from dataclasses import dataclass, field
28
+
29
+ RECORD_NAME = "failure.json"
30
+ NATIVE_FAILURE_PREFIX = "NATIVE_FAILURE "
31
+
32
+ RECORD_KEYS = (
33
+ "case",
34
+ "step",
35
+ "error",
36
+ "screenshot",
37
+ "uiDump",
38
+ "logcatTail",
39
+ "evidenceDir",
40
+ "cleanupErrors",
41
+ )
42
+ NATIVE_FAILURE_KEYS = ("case", "step", "error", "evidenceDir")
43
+
44
+
45
+ @dataclass
46
+ class Failure:
47
+ """One failed device case; also the payload of `failure.json`."""
48
+
49
+ case: str = None
50
+ step: str = None
51
+ error: str = ""
52
+ screenshot: str = None
53
+ uiDump: str = None
54
+ logcatTail: str = None
55
+ evidenceDir: str = None
56
+ cleanupErrors: list = field(default_factory=list)
57
+
58
+ def as_dict(self):
59
+ return {key: getattr(self, key) for key in RECORD_KEYS}
60
+
61
+ def native_failure_line(self):
62
+ compact = {key: getattr(self, key) for key in NATIVE_FAILURE_KEYS}
63
+ return NATIVE_FAILURE_PREFIX + json.dumps(compact, separators=(",", ":"))
64
+
65
+ @classmethod
66
+ def from_dict(cls, payload):
67
+ values = {key: payload[key] for key in RECORD_KEYS if key in payload}
68
+ errors = values.get("cleanupErrors")
69
+ values["cleanupErrors"] = list(errors) if isinstance(errors, list) else []
70
+ return cls(**values)
71
+
72
+
73
+ def read_record(evidence_dir):
74
+ """The record already in `evidence_dir`, or None when there is no usable one."""
75
+ path = os.path.join(evidence_dir, RECORD_NAME)
76
+ try:
77
+ with open(path, "r", encoding="utf-8") as stream:
78
+ payload = json.load(stream)
79
+ except (OSError, ValueError):
80
+ return None
81
+ if not isinstance(payload, dict) or "error" not in payload:
82
+ return None
83
+ return Failure.from_dict(payload)
84
+
85
+
86
+ def write_record(failure, evidence_dir):
87
+ """Store `failure` unless a record already exists; return the record on disk.
88
+
89
+ A valid earlier record wins, so a cleanup error or a second failing step cannot
90
+ replace the first failure. An unreadable file is not a record and is replaced,
91
+ with the replacement noted on the returned record's `cleanupErrors`.
92
+ """
93
+ os.makedirs(evidence_dir, exist_ok=True)
94
+ existing = read_record(evidence_dir)
95
+ if existing is not None:
96
+ return existing
97
+ path = os.path.join(evidence_dir, RECORD_NAME)
98
+ if os.path.exists(path):
99
+ failure.cleanupErrors.append("%s: replaced an unreadable record" % RECORD_NAME)
100
+ payload = json.dumps(failure.as_dict(), indent=2) + "\n"
101
+ handle, temporary = tempfile.mkstemp(dir=evidence_dir, prefix=".failure-", suffix=".json")
102
+ try:
103
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
104
+ stream.write(payload)
105
+ os.replace(temporary, path)
106
+ except BaseException:
107
+ if os.path.exists(temporary):
108
+ os.unlink(temporary)
109
+ raise
110
+ return failure
@@ -0,0 +1,481 @@
1
+ """Convert a Maestro test-output session or Flow folder into the device failure record.
2
+
3
+ Maestro's ``--test-output-dir`` session holds one self-contained folder per Flow
4
+ (`test reports and artifacts <https://docs.maestro.dev/maestro-flows/workspace-management/test-reports-and-artifacts>`_):
5
+
6
+ <output>/2026-07-31_173617/ # session folder
7
+ ├── maestro.log
8
+ └── checkout_flow/ # one folder per Flow
9
+ ├── manifest.json
10
+ ├── commands.json # one entry per executed step
11
+ ├── screenshots/ # step-<NNN>-<type>.png, failing step only
12
+ ├── screen-hierarchy/ # step-<NNN>-<type>.json
13
+ └── logs/device-logcat.txt
14
+
15
+ ``--maestro-dir`` takes the session folder or one Flow folder. The converter picks the
16
+ first failed step, writes ``failure.json`` through the shared record model in
17
+ ``failure_record.py`` (beside this file) and prints the one ``NATIVE_FAILURE {...}`` line,
18
+ so the record shape cannot drift from the other layers. Nothing here needs a device, adb,
19
+ an SDK or the Android skill:
20
+
21
+ python3 maestro_failure.py --maestro-dir "$LOG_DIR/2026-07-31_173617" --case checkout
22
+
23
+ A passing Flow writes nothing. An existing ``failure.json`` is never replaced, so the first
24
+ failure keeps its evidence. ``screenshot``, ``uiDump`` and ``logcatTail`` are paths relative
25
+ to ``evidenceDir``: the Flow folder itself, unless ``--evidence-dir`` names another run
26
+ folder, in which case the artifacts are copied there. A missing artifact is ``null`` and the
27
+ reason goes to ``cleanupErrors``; it never replaces ``error``. An artifact path that leaves
28
+ the Flow folder or the evidence folder is refused the same way, so a recorded path cannot
29
+ read or overwrite a file outside them.
30
+
31
+ A readable ``commands.json`` has to be a list of step objects whose ``status`` is one of the
32
+ six Maestro writes; anything else is a conversion error and exits 2 rather than being
33
+ dropped, because a dropped entry turns a failed run into an apparent pass. A Flow whose
34
+ ``commands.json`` cannot be read at all is reported as an incomplete conversion, while a
35
+ readable failing Flow beside it still writes its record.
36
+
37
+ Field mapping, with what could not be confirmed from the docs
38
+ -------------------------------------------------------------
39
+
40
+ Confirmed from Maestro's source (``TestOutputWriter.saveCommands`` and
41
+ ``CommandDebugMetadata``), because the docs name the fields but show no example:
42
+
43
+ - ``commands.json`` is an array of ``{"command": <command object>, "metadata": {...}}``.
44
+ - ``metadata.status`` is one of PENDING, RUNNING, COMPLETED, FAILED, WARNED, SKIPPED;
45
+ ``metadata.timestamp`` is epoch milliseconds; ``sequenceNumber`` starts at 0.
46
+ - ``metadata.error`` is the exception serialized as ``{"message", "debugMessage"}``; keys
47
+ are omitted when empty.
48
+ - ``metadata.artifacts`` is a list of ``{"type": "SCREENSHOT"|"SCREEN_HIERARCHY"|...,
49
+ "path": "<flow-folder-relative>"}``.
50
+
51
+ Confirmed from the docs: ``screenshots/``, ``screen-hierarchy/``, ``logs/maestro.log``,
52
+ ``logs/device-logcat.txt`` (Android), ``manifest.json``, and that only the failing step is
53
+ captured outside ``--analyze``.
54
+
55
+ Assumed, and not confirmed from the docs:
56
+
57
+ - The *step description*: ``commands.json`` stores the command object, not a rendered
58
+ description (Maestro's ``originalDescription`` is not serialized). ``step`` is the
59
+ command's own ``label`` when it has one, otherwise a mechanical reading of the command
60
+ type plus its first target (``text``/``textRegex``/``id``/``point``/``appId``/``link``/
61
+ ``path``). It is deliberately not a re-implementation of Maestro's phrasing.
62
+ - Artifact file names ``step-<NNN>-<type>`` with ``NNN`` = ``sequenceNumber`` + 1. This was
63
+ observed in Maestro's own tests, not in the docs, so it is only the fallback when
64
+ ``metadata.artifacts`` does not name the file.
65
+ - ``logs/device-simulator.log`` as the iOS fallback for the device log; the Android target
66
+ is ``logs/device-logcat.txt``.
67
+ - Which Flow failed first in a multi-Flow session: the earliest failing step's
68
+ ``timestamp`` decides, with the folder name as the tie-break.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import argparse
74
+ import json
75
+ import os
76
+ import re
77
+ import shutil
78
+ import sys
79
+
80
+ from failure_record import Failure, read_record, write_record
81
+
82
+ COMMANDS_JSON = "commands.json"
83
+ SCREENSHOT_DIR = "screenshots"
84
+ HIERARCHY_DIR = "screen-hierarchy"
85
+ DEVICE_LOGS = ("logs/device-logcat.txt", "logs/device-simulator.log")
86
+ FAILED_STATUS = "FAILED"
87
+ # The statuses CommandDebugMetadata writes; anything else is not a step this can read.
88
+ STATUSES = ("PENDING", "RUNNING", "COMPLETED", "FAILED", "WARNED", "SKIPPED")
89
+ NO_ERROR_MESSAGE = "Maestro marked the step FAILED and gave no error message"
90
+ TARGET_KEYS = ("text", "textRegex", "id", "point", "appId", "link", "path")
91
+ # The record attribute, the Maestro artifact kind, and the reason when it is absent.
92
+ RECORD_ARTIFACTS = (
93
+ ("screenshot", "SCREENSHOT", "no failing-step screenshot in the Maestro output"),
94
+ ("uiDump", "SCREEN_HIERARCHY", "no screen hierarchy in the Maestro output"),
95
+ ("logcatTail", "LOGCAT", "no device log in the Maestro output"),
96
+ )
97
+
98
+ _CAMEL_WORD = re.compile(r"(?<=[a-z0-9])([A-Z])")
99
+
100
+
101
+ class MaestroOutputError(RuntimeError):
102
+ """The given folder holds no usable Maestro commands.json."""
103
+
104
+
105
+ def _text(value):
106
+ if value is None:
107
+ return ""
108
+ return str(value).strip()
109
+
110
+
111
+ def load_commands(flow_dir):
112
+ """The executed steps of `flow_dir`, or None when its commands.json cannot be read.
113
+
114
+ A readable file is a list of step objects in the nested or the documented flat shape.
115
+ An entry that is not an object, or whose status is not one of `STATUSES`, raises
116
+ `MaestroOutputError`: silently dropping it would report a failed run as a pass.
117
+ """
118
+ path = os.path.join(flow_dir, COMMANDS_JSON)
119
+ try:
120
+ with open(path, "r", encoding="utf-8") as stream:
121
+ payload = json.load(stream)
122
+ except (OSError, ValueError):
123
+ return None
124
+ if not isinstance(payload, list):
125
+ return None
126
+ for index, entry in enumerate(payload):
127
+ if not isinstance(entry, dict):
128
+ raise MaestroOutputError("%s entry %d is not an object" % (path, index))
129
+ _, metadata = _pieces(entry)
130
+ if _status(metadata) not in STATUSES:
131
+ raise MaestroOutputError(
132
+ "%s entry %d has unsupported status %r" % (path, index, metadata.get("status"))
133
+ )
134
+ return payload
135
+
136
+
137
+ def _pieces(entry):
138
+ """(command, metadata) for one entry, in the current or the documented flat shape."""
139
+ metadata = entry.get("metadata")
140
+ if isinstance(metadata, dict):
141
+ return entry.get("command"), metadata
142
+ return entry.get("command"), entry
143
+
144
+
145
+ def _status(metadata):
146
+ return _text(metadata.get("status")).upper()
147
+
148
+
149
+ def failed_steps(entries):
150
+ """(command, metadata) for each step Maestro marked FAILED, in execution order."""
151
+ steps = []
152
+ for entry in entries:
153
+ command, metadata = _pieces(entry)
154
+ if _status(metadata) == FAILED_STATUS:
155
+ steps.append((command, metadata))
156
+ return steps
157
+
158
+
159
+ def _timestamp(metadata):
160
+ """The step's epoch-millisecond timestamp, or infinity when Maestro did not record one."""
161
+ value = metadata.get("timestamp")
162
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
163
+ return float("inf")
164
+ return float(value)
165
+
166
+
167
+ def flow_dirs(root, max_depth=3):
168
+ """Folders holding a commands.json, `root` included, at most `max_depth` levels down."""
169
+ if os.path.isfile(os.path.join(root, COMMANDS_JSON)):
170
+ return [root]
171
+ found = []
172
+ base_depth = os.path.abspath(root).rstrip(os.sep).count(os.sep)
173
+ for current, directories, files in os.walk(root):
174
+ depth = os.path.abspath(current).rstrip(os.sep).count(os.sep) - base_depth
175
+ directories.sort()
176
+ if COMMANDS_JSON in files:
177
+ found.append(current)
178
+ # A Flow folder's own subfolders hold artifacts, never another Flow.
179
+ directories[:] = []
180
+ elif depth >= max_depth:
181
+ directories[:] = []
182
+ return found
183
+
184
+
185
+ def first_failure(root, incomplete=None):
186
+ """(flow dir, command, metadata) for the earliest failing step under `root`, or None.
187
+
188
+ `incomplete` is an optional list collecting one note per Flow whose commands.json
189
+ cannot be read, so a caller can report the conversion as partial. Every unreadable
190
+ Flow is noted, even when another Flow supplies the failure.
191
+ """
192
+ directories = flow_dirs(root)
193
+ if not directories:
194
+ raise MaestroOutputError("no %s under %s" % (COMMANDS_JSON, root))
195
+ unreadable = []
196
+ unreadable_notes = []
197
+ failures = []
198
+ for directory in directories:
199
+ detail = None
200
+ try:
201
+ entries = load_commands(directory)
202
+ except MaestroOutputError as problem:
203
+ entries = None
204
+ detail = str(problem)
205
+ if entries is None:
206
+ unreadable.append(directory)
207
+ note = "unreadable %s in %s" % (COMMANDS_JSON, directory)
208
+ unreadable_notes.append(note if detail is None else "%s: %s" % (note, detail))
209
+ continue
210
+ steps = failed_steps(entries)
211
+ if steps:
212
+ command, metadata = steps[0]
213
+ failures.append((_timestamp(metadata), directory, command, metadata))
214
+ if unreadable and incomplete is not None:
215
+ incomplete.extend(unreadable_notes)
216
+ if not failures:
217
+ if unreadable:
218
+ raise MaestroOutputError(
219
+ "no readable %s: %s" % (COMMANDS_JSON, "; ".join(unreadable_notes))
220
+ )
221
+ return None
222
+ failures.sort(key=lambda failure: (failure[0], failure[1]))
223
+ _, directory, command, metadata = failures[0]
224
+ return directory, command, metadata
225
+
226
+
227
+ def humanize(command_type):
228
+ """`tapOnElement` -> `Tap on element`, `assertScreenshotCommand` -> `Assert screenshot`."""
229
+ name = command_type[: -len("Command")] if command_type.endswith("Command") else command_type
230
+ spaced = _CAMEL_WORD.sub(r" \1", name).lower()
231
+ return spaced[:1].upper() + spaced[1:]
232
+
233
+
234
+ def _target(value, depth=5):
235
+ """The first selector or target text inside a command, for a readable step."""
236
+ if depth <= 0:
237
+ return None
238
+ if isinstance(value, dict):
239
+ for key in TARGET_KEYS:
240
+ candidate = value.get(key)
241
+ if isinstance(candidate, bool):
242
+ continue
243
+ if isinstance(candidate, (str, int, float)) and _text(candidate):
244
+ return candidate
245
+ children = list(value.values())
246
+ elif isinstance(value, list):
247
+ children = value
248
+ else:
249
+ return None
250
+ for child in children:
251
+ found = _target(child, depth - 1)
252
+ if found is not None:
253
+ return found
254
+ return None
255
+
256
+
257
+ def describe_command(command):
258
+ """The step recorded for the failing command, or None when Maestro stored none."""
259
+ if isinstance(command, str):
260
+ return _text(command) or None
261
+ if not isinstance(command, dict):
262
+ return None
263
+ for command_type, value in command.items():
264
+ if value is None:
265
+ continue
266
+ label = value.get("label") if isinstance(value, dict) else None
267
+ if _text(label):
268
+ return _text(label)
269
+ reading = humanize(command_type)
270
+ target = _target(value)
271
+ return reading if target is None else "%s %s" % (reading, json.dumps(target, ensure_ascii=False))
272
+ return None
273
+
274
+
275
+ def error_text(metadata):
276
+ """The step's error message, keeping Maestro's debug detail when it adds anything."""
277
+ error = metadata.get("error")
278
+ if isinstance(error, dict):
279
+ message = _text(error.get("message"))
280
+ detail = _text(error.get("debugMessage"))
281
+ if message and detail and detail not in message:
282
+ return "%s: %s" % (message, detail)
283
+ return message or detail or NO_ERROR_MESSAGE
284
+ text = _text(error)
285
+ return text or NO_ERROR_MESSAGE
286
+
287
+
288
+ def _listed_artifacts(metadata):
289
+ """{kind: relative path} from the step's artifacts list, in either shape."""
290
+ artifacts = metadata.get("artifacts")
291
+ if not isinstance(artifacts, list):
292
+ return {}
293
+ found = {}
294
+ for artifact in artifacts:
295
+ kind = ""
296
+ if isinstance(artifact, dict):
297
+ kind = _text(artifact.get("type")).upper()
298
+ path = artifact.get("path")
299
+ else:
300
+ path = artifact
301
+ if not isinstance(path, str) or not path:
302
+ continue
303
+ if not kind:
304
+ # The documented flat shape names the file without a kind.
305
+ lowered = path.lower()
306
+ if lowered.endswith(".png"):
307
+ kind = "SCREENSHOT"
308
+ elif lowered.endswith(".json") and "hierarch" in lowered:
309
+ kind = "SCREEN_HIERARCHY"
310
+ if kind:
311
+ found.setdefault(kind, path)
312
+ return found
313
+
314
+
315
+ def _step_named_artifacts(flow_dir, metadata):
316
+ """Fallback: the files Maestro names after the step, step-<NNN>-<type>.(png|json)."""
317
+ number = metadata.get("sequenceNumber")
318
+ prefix = "step-%03d-" % (number + 1) if isinstance(number, int) and not isinstance(number, bool) else "step-"
319
+ found = {}
320
+ for kind, directory, suffix in (
321
+ ("SCREENSHOT", SCREENSHOT_DIR, ".png"),
322
+ ("SCREEN_HIERARCHY", HIERARCHY_DIR, ".json"),
323
+ ):
324
+ try:
325
+ names = sorted(os.listdir(os.path.join(flow_dir, directory)))
326
+ except OSError:
327
+ continue
328
+ for name in names:
329
+ if name.startswith(prefix) and name.endswith(suffix):
330
+ found[kind] = "%s/%s" % (directory, name)
331
+ break
332
+ return found
333
+
334
+
335
+ def step_artifacts(flow_dir, metadata):
336
+ """{kind: relative path} for the failing step, as `commands.json` names it.
337
+
338
+ A path Maestro listed is kept even when the file is absent, so the record names the
339
+ artifact it expected instead of a generic gap. The step-named fallback only fills a
340
+ kind Maestro did not list, and only with a file that is actually there.
341
+ """
342
+ found = {}
343
+ listed = _listed_artifacts(metadata)
344
+ named = _step_named_artifacts(flow_dir, metadata)
345
+ for kind in ("SCREENSHOT", "SCREEN_HIERARCHY"):
346
+ relative = listed.get(kind) or named.get(kind)
347
+ if relative:
348
+ found[kind] = relative
349
+ for relative in DEVICE_LOGS:
350
+ if os.path.isfile(os.path.join(flow_dir, relative)):
351
+ found["LOGCAT"] = relative
352
+ break
353
+ return found
354
+
355
+
356
+ def _contained(base_dir, relative):
357
+ """`relative` resolved under `base_dir`, or None when it can leave that folder.
358
+
359
+ An absolute path, an escaping `..` path, or an existing symlink that resolves outside the
360
+ folder is refused, so the resolved path is what the caller then reads or writes.
361
+ """
362
+ if not relative or "\x00" in relative:
363
+ return None
364
+ if os.path.isabs(relative) or os.path.splitdrive(relative)[0]:
365
+ return None
366
+ base = os.path.realpath(base_dir)
367
+ resolved = os.path.realpath(os.path.join(base, relative))
368
+ if resolved != base and not resolved.startswith(base + os.sep):
369
+ return None
370
+ return resolved
371
+
372
+
373
+ def _store(flow_dir, evidence_dir, relative, failure, attribute):
374
+ """Copy `relative` into `evidence_dir` when they differ; record the failure otherwise."""
375
+ source = _contained(flow_dir, relative)
376
+ if source is None:
377
+ failure.cleanupErrors.append("%s: %s is outside the Maestro output" % (attribute, relative))
378
+ return None
379
+ if not os.path.isfile(source):
380
+ failure.cleanupErrors.append("%s: %s is not in the Maestro output" % (attribute, relative))
381
+ return None
382
+ if os.path.abspath(evidence_dir) == os.path.abspath(flow_dir):
383
+ return relative
384
+ destination = _contained(evidence_dir, relative)
385
+ if destination is None:
386
+ failure.cleanupErrors.append(
387
+ "%s: copy of %s would leave the evidence folder" % (attribute, relative)
388
+ )
389
+ return None
390
+ try:
391
+ os.makedirs(os.path.dirname(destination) or evidence_dir, exist_ok=True)
392
+ shutil.copyfile(source, destination)
393
+ except OSError as problem:
394
+ failure.cleanupErrors.append("%s: copy of %s failed: %s" % (attribute, relative, problem))
395
+ return None
396
+ return relative
397
+
398
+
399
+ def build_failure(flow_dir, command, metadata, case, evidence_dir):
400
+ """The `Failure` for one failed Maestro step, with the evidence found in `flow_dir`."""
401
+ failure = Failure(
402
+ case=case or os.path.basename(os.path.abspath(flow_dir)),
403
+ step=describe_command(command),
404
+ error=error_text(metadata),
405
+ evidenceDir=evidence_dir,
406
+ )
407
+ found = step_artifacts(flow_dir, metadata)
408
+ for attribute, kind, reason in RECORD_ARTIFACTS:
409
+ relative = found.get(kind)
410
+ if relative is None:
411
+ failure.cleanupErrors.append("%s: %s" % (attribute, reason))
412
+ continue
413
+ setattr(failure, attribute, _store(flow_dir, evidence_dir, relative, failure, attribute))
414
+ return failure
415
+
416
+
417
+ def convert(maestro_dir, case=None, evidence_dir=None, stream=None, incomplete=None):
418
+ """Write the failure record for the first failed Flow under `maestro_dir`.
419
+
420
+ Returns the record written, or the earlier record kept in `evidence_dir`, or None when
421
+ no Flow failed. Raises `MaestroOutputError` when no readable `commands.json` is there.
422
+ Flows whose commands.json cannot be read are noted on `incomplete` and on the written
423
+ record's `cleanupErrors`; the failure itself is still recorded unchanged.
424
+ """
425
+ stream = stream if stream is not None else sys.stdout
426
+ notes = incomplete if incomplete is not None else []
427
+ selected = first_failure(maestro_dir, notes)
428
+ if selected is None:
429
+ return None
430
+ flow_dir, command, metadata = selected
431
+ evidence = os.path.abspath(evidence_dir or flow_dir)
432
+ kept = read_record(evidence)
433
+ if kept is not None:
434
+ # The first failure owns this evidence folder; a later conversion only repeats it.
435
+ print(kept.native_failure_line(), file=stream, flush=True)
436
+ return kept
437
+ failure = build_failure(flow_dir, command, metadata, case, evidence)
438
+ failure.cleanupErrors.extend(notes)
439
+ failure = write_record(failure, evidence)
440
+ print(failure.native_failure_line(), file=stream, flush=True)
441
+ return failure
442
+
443
+
444
+ def main(argv=None):
445
+ parser = argparse.ArgumentParser(
446
+ description="Convert a Maestro test-output session or Flow folder into the device failure record."
447
+ )
448
+ parser.add_argument(
449
+ "--maestro-dir",
450
+ required=True,
451
+ help="Maestro --test-output-dir session folder, or one Flow folder holding commands.json",
452
+ )
453
+ parser.add_argument("--case", help="case name for the record; defaults to the Flow folder name")
454
+ parser.add_argument(
455
+ "--evidence-dir",
456
+ help="run evidence folder that holds failure.json; defaults to the Flow folder",
457
+ )
458
+ options = parser.parse_args(argv)
459
+ if not os.path.isdir(options.maestro_dir):
460
+ print("maestro folder not found: %s" % options.maestro_dir, file=sys.stderr)
461
+ return 2
462
+ incomplete = []
463
+ try:
464
+ convert(
465
+ options.maestro_dir,
466
+ case=options.case,
467
+ evidence_dir=options.evidence_dir,
468
+ incomplete=incomplete,
469
+ )
470
+ except MaestroOutputError as problem:
471
+ print("not converted: %s" % problem, file=sys.stderr)
472
+ return 2
473
+ if incomplete:
474
+ for note in incomplete:
475
+ print("incomplete conversion: %s" % note, file=sys.stderr)
476
+ return 2
477
+ return 0
478
+
479
+
480
+ if __name__ == "__main__":
481
+ sys.exit(main())