@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.
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +178 -150
- package/README.md +1 -1
- package/package.json +5 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +14 -10
- package/skills/app-dev/android-development/references/diagnosis.md +4 -6
- package/skills/app-dev/android-development/references/feature-work.md +3 -2
- package/skills/app-dev/android-development/references/gotchas.md +7 -0
- package/skills/app-dev/android-development/references/init.md +5 -2
- package/skills/app-dev/android-development/references/setup.md +9 -5
- package/skills/app-dev/android-development/references/ui-journeys.md +12 -55
- package/skills/app-dev/android-development/scripts/failure_capture.py +23 -84
- package/skills/app-dev/android-development/scripts/init.mjs +2 -1
- package/skills/app-dev/android-development/scripts/maestro_failure.py +43 -387
- package/skills/app-dev/android-development/scripts/test_maestro_failure.py +113 -424
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/LICENSE +3 -0
- package/skills/app-dev/maestro/SKILL.md +112 -0
- package/skills/app-dev/maestro/references/android-connection.md +54 -0
- package/skills/app-dev/maestro/references/authoring.md +113 -0
- package/skills/app-dev/maestro/references/diagnosis.md +50 -0
- package/skills/app-dev/maestro/references/evidence.md +74 -0
- package/skills/app-dev/maestro/references/journeys.md +100 -0
- package/skills/app-dev/maestro/references/migration.md +35 -0
- package/skills/app-dev/maestro/release-check.mjs +95 -0
- package/skills/app-dev/maestro/scripts/failure_record.py +110 -0
- package/skills/app-dev/maestro/scripts/maestro_failure.py +481 -0
- package/skills/app-dev/maestro/scripts/test_maestro_failure.py +710 -0
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/agent-catalog.mjs +46 -12
- package/skills/engineering/harness-runtime/contracts.md +1 -1
- package/skills/engineering/resolve-issues/SKILL.md +11 -9
- package/skills/engineering/resolve-issues/references/exploration.md +60 -18
- package/skills/engineering/resolve-issues/references/implementation.md +1 -1
- package/skills/engineering/resolve-issues/references/preflight.md +47 -22
- package/skills/engineering/resolve-issues/references/review.md +2 -1
- package/skills/engineering/resolve-issues/references/shared-modules.md +58 -4
- package/skills/engineering/resolve-issues/references/task-plan.md +30 -10
- package/skills/engineering/resolve-issues/scripts/findings-reuse.mjs +1 -1
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +7 -7
- package/skills/engineering/resolve-issues/scripts/plan-sync.mjs +1 -1
- package/skills/engineering/resolve-issues/scripts/size-check.mjs +6 -5
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- 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())
|