mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix: clean up maestro test reporter output (#935)
* fix: clean up maestro test reporter output * chore: enable expo build disk cache * refactor: simplify replay progress detail formatting * fix: surface replay runner recovery hints * fix: prioritize ios runner recovery hint * fix: avoid trailing punctuation in runner state hint * fix: keep internal cleanup scripts out of runner hints * chore: remove redundant maestro test app open flag * fix: make test app maestro flow self-contained * fix: simplify maestro test duration output * fix: refine maestro test summary output * fix: dim maestro live progress counters * test: clear test app state before maestro flow * test: update maestro reporter progress expectations * chore: remove maestro app open flag handling * fix: apply maestro reporter cleanup to default reporter
This commit is contained in:
committed by
GitHub
parent
8cc6bb8b92
commit
6ae0612ebc
@@ -127,18 +127,15 @@ server from its launcher.
|
||||
The Maestro prototype suite lives in `examples/test-app/maestro` and runs through
|
||||
`agent-device replay --maestro`:
|
||||
|
||||
```bash
|
||||
pnpm test-app:maestro:ios -- --open "Agent Device Tester"
|
||||
pnpm test-app:maestro:android -- --open "Agent Device Tester"
|
||||
```
|
||||
|
||||
When the development build is already open and connected to Metro, omit
|
||||
`--open` and run the suite against the existing session:
|
||||
|
||||
```bash
|
||||
pnpm test-app:maestro:ios
|
||||
pnpm test-app:maestro:android
|
||||
```
|
||||
|
||||
The Maestro flow includes `launchApp`, so the suite launches the app inside each
|
||||
test attempt. Start Metro first when the installed development build needs the
|
||||
local bundle.
|
||||
|
||||
The suite intentionally covers the compat layer syntax used by public Maestro suites:
|
||||
`runFlow` file/inline blocks, `when.platform`, config hooks, deterministic `repeat.times`,
|
||||
flow `env`, selectors, input, assertions, and swipe.
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"version": "1.0.0",
|
||||
"orientation": "default",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"buildCacheProvider": {
|
||||
"plugin": "expo-build-disk-cache"
|
||||
},
|
||||
"plugins": ["expo-router"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
|
||||
@@ -2,8 +2,10 @@ appId: com.callstack.agentdevicelab
|
||||
env:
|
||||
CHECKOUT_NAME: Ada Lovelace
|
||||
CHECKOUT_EMAIL: ada@example.com
|
||||
PICKUP_TAPS: "2"
|
||||
PICKUP_TAPS: '2'
|
||||
onFlowStart:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible: Agent Device Tester
|
||||
onFlowComplete:
|
||||
- assertVisible: Delivery choices
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
---
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: home-open-form
|
||||
- tapOn:
|
||||
id: home-open-form
|
||||
- assertVisible: Checkout form
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"expo-build-disk-cache": "^0.7.4",
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+119
-1
@@ -59,6 +59,9 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ~19.2.2
|
||||
version: 19.2.14
|
||||
expo-build-disk-cache:
|
||||
specifier: ^0.7.4
|
||||
version: 0.7.4(@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(@expo/config@56.0.9(typescript@6.0.3))(typescript@6.0.3)
|
||||
typescript:
|
||||
specifier: ~6.0.3
|
||||
version: 6.0.3
|
||||
@@ -1216,6 +1219,10 @@ packages:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@6.3.0:
|
||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1314,6 +1321,15 @@ packages:
|
||||
core-js-compat@3.49.0:
|
||||
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
|
||||
|
||||
cosmiconfig@9.0.2:
|
||||
resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.9.5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1405,6 +1421,13 @@ packages:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
error-stack-parser@2.1.4:
|
||||
resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==}
|
||||
|
||||
@@ -1438,6 +1461,13 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-build-disk-cache@0.7.4:
|
||||
resolution: {integrity: sha512-mU1NaPC2kAwUZ0bVjbuRHQ+FBKnlFvNwu6BR1ZKPRV9c7McvL7Ir1V5B08VvzTDtEMpzYjhyITPSU6loO9Bsew==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
'@expo/cli': '*'
|
||||
'@expo/config': '*'
|
||||
|
||||
expo-constants@56.0.18:
|
||||
resolution: {integrity: sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==}
|
||||
peerDependencies:
|
||||
@@ -1735,6 +1765,10 @@ packages:
|
||||
engines: {node: '>=16.x'}
|
||||
hasBin: true
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1745,6 +1779,9 @@ packages:
|
||||
invariant@2.2.4:
|
||||
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
|
||||
@@ -1806,6 +1843,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1900,6 +1940,9 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
@@ -2058,6 +2101,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.15:
|
||||
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
negotiator@0.6.3:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2115,6 +2163,14 @@ packages:
|
||||
resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-json@5.2.0:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-png@2.1.0:
|
||||
resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2343,6 +2399,10 @@ packages:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-from@5.0.0:
|
||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2727,6 +2787,9 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adobe/css-tools@4.5.0': {}
|
||||
@@ -4233,6 +4296,8 @@ snapshots:
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@6.3.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001787: {}
|
||||
@@ -4346,6 +4411,15 @@ snapshots:
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
|
||||
cosmiconfig@9.0.2(typescript@6.0.3):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -4402,6 +4476,12 @@ snapshots:
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
|
||||
error-stack-parser@2.1.4:
|
||||
dependencies:
|
||||
stackframe: 1.3.4
|
||||
@@ -4429,6 +4509,16 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
expo-build-disk-cache@0.7.4(@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(@expo/config@56.0.9(typescript@6.0.3))(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
|
||||
'@expo/config': 56.0.9(typescript@6.0.3)
|
||||
cosmiconfig: 9.0.2(typescript@6.0.3)
|
||||
env-paths: 2.2.1
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3)):
|
||||
dependencies:
|
||||
'@expo/env': 2.3.0
|
||||
@@ -4755,6 +4845,11 @@ snapshots:
|
||||
dependencies:
|
||||
queue: 6.0.2
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
resolve-from: 4.0.0
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
@@ -4763,6 +4858,8 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
@@ -4820,6 +4917,8 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
kleur@3.0.3: {}
|
||||
@@ -4884,6 +4983,8 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
@@ -5129,6 +5230,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@3.3.15: {}
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
negotiator@0.6.4: {}
|
||||
@@ -5182,6 +5285,17 @@ snapshots:
|
||||
strip-ansi: 5.2.0
|
||||
wcwidth: 1.0.1
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
error-ex: 1.3.4
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-png@2.1.0:
|
||||
dependencies:
|
||||
pngjs: 3.4.0
|
||||
@@ -5213,7 +5327,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.12:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
nanoid: 3.3.15
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -5447,6 +5561,8 @@ snapshots:
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
|
||||
resolve-workspace-root@2.0.1: {}
|
||||
@@ -5757,3 +5873,5 @@ snapshots:
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
@@ -11,7 +11,6 @@ const options = {
|
||||
platform: 'ios',
|
||||
session: 'test-app-maestro',
|
||||
flowDir: path.join(repoRoot, 'examples', 'test-app', 'maestro'),
|
||||
openTarget: '',
|
||||
close: false,
|
||||
passthrough: [],
|
||||
};
|
||||
@@ -37,11 +36,6 @@ for (let index = 2; index < process.argv.length; index += 1) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--open' && process.argv[index + 1]) {
|
||||
options.openTarget = process.argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--close') {
|
||||
options.close = true;
|
||||
continue;
|
||||
@@ -67,14 +61,9 @@ function runAgentDevice(args) {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.openTarget) {
|
||||
runAgentDevice(['open', options.openTarget, '--platform', options.platform, ...options.passthrough]);
|
||||
runAgentDevice(['wait', 'Agent Device Tester', '30000', '--platform', options.platform, ...options.passthrough]);
|
||||
}
|
||||
|
||||
runAgentDevice([
|
||||
'test',
|
||||
options.flowDir,
|
||||
...flows,
|
||||
'--maestro',
|
||||
'--platform',
|
||||
options.platform,
|
||||
|
||||
@@ -142,10 +142,11 @@ test('test command prints suite summary and exits non-zero on failures', async (
|
||||
assert.doesNotMatch(result.stderr, /Running replay suite\.\.\./);
|
||||
assert.doesNotMatch(result.stdout, /✓ 01-pass\.ad \(0\.01s\)/);
|
||||
assert.doesNotMatch(result.stdout, /⨯ "Checkout failure" in 02-fail\.ad/);
|
||||
assert.match(result.stdout, /Failures:\n Checkout failure\n file: 02-fail\.ad/);
|
||||
assert.match(result.stdout, /Replay failed at step 1 \(open Demo\): boom/);
|
||||
assert.match(result.stdout, /artifacts: \/tmp\/test-artifacts\/02-fail/);
|
||||
assert.doesNotMatch(result.stdout, /SKIP \/tmp\/03-skip\.ad/);
|
||||
assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 0\.025s/);
|
||||
assert.match(result.stdout, /Test summary: 1 passed \(3\), 1 failed in 0\.025s/);
|
||||
});
|
||||
|
||||
test('test command --verbose prints all test statuses', async () => {
|
||||
@@ -158,7 +159,44 @@ test('test command --verbose prints all test statuses', async () => {
|
||||
assert.doesNotMatch(result.stderr, /Running replay suite\.\.\./);
|
||||
assert.doesNotMatch(result.stdout, /✓ 01-pass\.ad \(0\.01s\)/);
|
||||
assert.doesNotMatch(result.stdout, /SKIP 03-skip\.ad/);
|
||||
assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 0\.025s/);
|
||||
assert.match(result.stdout, /Test summary: 1 passed \(3\), 1 failed in 0\.025s/);
|
||||
});
|
||||
|
||||
test('test command colors suite summary segments when color is enabled', async () => {
|
||||
const result = await runCliCapture(
|
||||
['test', './suite'],
|
||||
async () => ({
|
||||
ok: true,
|
||||
data: {
|
||||
total: 1,
|
||||
executed: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
notRun: 0,
|
||||
durationMs: 25,
|
||||
failures: [],
|
||||
tests: [
|
||||
{
|
||||
file: '/tmp/01-pass.ad',
|
||||
session: 'default:test:suite:1',
|
||||
status: 'passed',
|
||||
durationMs: 25,
|
||||
attempts: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ env: { FORCE_COLOR: '1', NO_COLOR: undefined } },
|
||||
);
|
||||
|
||||
assert.equal(result.code, null);
|
||||
assert.ok(
|
||||
result.stdout.includes(
|
||||
'Test summary: \u001B[32m1 passed\u001B[39m \u001B[2m(1)\u001B[22m in \u001B[33m0.025s\u001B[39m',
|
||||
),
|
||||
);
|
||||
assert.doesNotMatch(result.stdout, /0 failed/);
|
||||
});
|
||||
|
||||
test('test command --verbose omits step telemetry for passing tests without debug mode', async () => {
|
||||
@@ -388,13 +426,13 @@ test('test command reports flaky passed-on-retry cases in the default summary',
|
||||
assert.doesNotMatch(result.stdout, /FLAKY/);
|
||||
assert.doesNotMatch(
|
||||
result.stdout,
|
||||
/^✓ "Authentication flow" in auth-flow\.yml \(passed attempt 17\.5s, total 112\.2s\)$/m,
|
||||
/^✓ Authentication flow \(passed attempt 17\.5s, total 112\.2s\)$/m,
|
||||
);
|
||||
assert.match(result.stdout, /Test summary: 1 passed, 0 failed, 1 flaky in 0\.025s/);
|
||||
assert.match(result.stdout, /Test summary: 1 passed \(1\), 1 flaky in 0\.025s/);
|
||||
assert.match(result.stdout, /Flaky tests:/);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/✓ "Authentication flow" in auth-flow\.yml after 2 attempts \(passed attempt 17\.5s, total 112\.2s\)/,
|
||||
/✓ Authentication flow after 2 attempts \(passed attempt 17\.5s, total 112\.2s\)/,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatReplayTestProgressEvent } from '../cli-test-progress.ts';
|
||||
import {
|
||||
createReplayTestProgressRenderer,
|
||||
formatReplayTestProgressEvent,
|
||||
} from '../cli-test-progress.ts';
|
||||
import type { RequestProgressEvent } from '../daemon/request-progress.ts';
|
||||
|
||||
function withStreamTty<T>(stream: NodeJS.WriteStream, isTTY: boolean, run: () => T): T {
|
||||
@@ -70,7 +73,7 @@ test('formatReplayTestProgressEvent renders pass, retry, fail, and skip cases',
|
||||
maxAttempts: 2,
|
||||
durationMs: 12_345,
|
||||
},
|
||||
expected: /^✓ 01-login\.ad \(12\.3s\)$/,
|
||||
expected: /^✓ 01-login\.ad 12\.3s$/,
|
||||
},
|
||||
{
|
||||
event: {
|
||||
@@ -91,6 +94,7 @@ test('formatReplayTestProgressEvent renders pass, retry, fail, and skip cases',
|
||||
event: {
|
||||
type: 'replay-test',
|
||||
file: '/tmp/03-payment.ad',
|
||||
title: 'Payment flow',
|
||||
status: 'fail',
|
||||
index: 3,
|
||||
total: 3,
|
||||
@@ -98,11 +102,29 @@ test('formatReplayTestProgressEvent renders pass, retry, fail, and skip cases',
|
||||
maxAttempts: 2,
|
||||
durationMs: 9_876,
|
||||
message: 'assertVisible failed',
|
||||
hint: 'Stop the owning daemon and retry',
|
||||
session: 'maestro-test:test:suite:3:attempt-2',
|
||||
artifactsDir: '/tmp/replay-suite/payment',
|
||||
},
|
||||
expected:
|
||||
/^⨯ 03-payment\.ad \(9\.88s\)\n failed at: assertVisible failed\n session: maestro-test:test:suite:3:attempt-2\n artifacts: \/tmp\/replay-suite\/payment$/,
|
||||
/^⨯ Payment flow 9\.88s\n file: 03-payment\.ad\n failed at: assertVisible failed\n hint: Stop the owning daemon and retry\n session: maestro-test:test:suite:3:attempt-2\n artifacts: \/tmp\/replay-suite\/payment$/,
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: 'replay-test',
|
||||
file: '/tmp/05-sharded.ad',
|
||||
title: 'Sharded flow',
|
||||
status: 'pass',
|
||||
index: 5,
|
||||
total: 6,
|
||||
attempt: 1,
|
||||
durationMs: 100,
|
||||
shardIndex: 0,
|
||||
shardCount: 2,
|
||||
deviceId: 'emulator-5554',
|
||||
deviceName: 'Pixel 8',
|
||||
},
|
||||
expected: /^✓ Sharded flow \[1\/2 Pixel 8\] 0\.1s$/,
|
||||
},
|
||||
{
|
||||
event: {
|
||||
@@ -142,7 +164,37 @@ test('formatReplayTestProgressEvent colors stderr progress rows when stdout is p
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(line, '\u001B[32m✓\u001B[39m 01-pass.ad (0.01s)');
|
||||
assert.equal(line, '\u001B[32m✓\u001B[39m 01-pass.ad \u001B[33m0.01s\u001B[39m');
|
||||
} finally {
|
||||
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
|
||||
else delete process.env.FORCE_COLOR;
|
||||
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
|
||||
else delete process.env.NO_COLOR;
|
||||
}
|
||||
});
|
||||
|
||||
test('createReplayTestProgressRenderer dims live step progress when color is enabled', () => {
|
||||
const originalForceColor = process.env.FORCE_COLOR;
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
process.env.FORCE_COLOR = '1';
|
||||
delete process.env.NO_COLOR;
|
||||
try {
|
||||
const renderer = createReplayTestProgressRenderer({ liveProgress: true });
|
||||
const rendered = renderer.render({
|
||||
type: 'replay-test',
|
||||
file: '/tmp/checkout.yaml',
|
||||
title: 'Checkout flow',
|
||||
status: 'progress',
|
||||
index: 1,
|
||||
total: 1,
|
||||
stepIndex: 3,
|
||||
stepTotal: 20,
|
||||
});
|
||||
|
||||
assert.deepEqual(rendered, {
|
||||
text: '\r\u001B[2K⊙ Checkout flow\u001B[2m [3/20]\u001B[22m',
|
||||
newline: false,
|
||||
});
|
||||
} finally {
|
||||
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
|
||||
else delete process.env.FORCE_COLOR;
|
||||
@@ -175,7 +227,7 @@ test('formatReplayTestProgressEvent colors completed result markers when color i
|
||||
attempt: 1,
|
||||
durationMs: 10,
|
||||
}),
|
||||
'\u001B[32m✓\u001B[39m 01-pass.ad (0.01s)',
|
||||
'\u001B[32m✓\u001B[39m 01-pass.ad \u001B[33m0.01s\u001B[39m',
|
||||
);
|
||||
assert.equal(
|
||||
formatReplayTestProgressEvent({
|
||||
@@ -188,7 +240,7 @@ test('formatReplayTestProgressEvent colors completed result markers when color i
|
||||
attempt: 2,
|
||||
durationMs: 30,
|
||||
}),
|
||||
'\u001B[33m✓\u001B[39m "Retry flow" in 02-flaky.yml (0.03s)',
|
||||
'\u001B[33m✓\u001B[39m Retry flow \u001B[33m0.03s\u001B[39m',
|
||||
);
|
||||
const failedLine = formatReplayTestProgressEvent({
|
||||
type: 'replay-test',
|
||||
@@ -201,7 +253,7 @@ test('formatReplayTestProgressEvent colors completed result markers when color i
|
||||
durationMs: 5,
|
||||
message: 'boom',
|
||||
});
|
||||
assert.ok(failedLine?.startsWith('\u001B[31m⨯\u001B[39m "Checkout failure" in 03-fail.ad'));
|
||||
assert.ok(failedLine?.startsWith('\u001B[31m⨯\u001B[39m Checkout failure'));
|
||||
} finally {
|
||||
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
|
||||
else delete process.env.FORCE_COLOR;
|
||||
|
||||
@@ -123,7 +123,7 @@ test('readDaemonSocketProgressResponse parses split progress lines before respon
|
||||
assert.deepEqual(await responsePromise, { ok: true, data: { via: 'socket-progress' } });
|
||||
assert.equal(socket.encoding, 'utf8');
|
||||
assert.equal(socket.ended, true);
|
||||
assert.match(stderr, /✓ "Login flow" in 01-login\.ad \(1\.23s\)/);
|
||||
assert.match(stderr, /✓ Login flow 1\.23s/);
|
||||
} finally {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
}
|
||||
@@ -237,11 +237,9 @@ test('readDaemonSocketProgressResponse rewrites live progress and clears it for
|
||||
socket.emit('data', `${progress(3)}\n${progress(4)}\n${pass}\n${responseLine}\n`);
|
||||
|
||||
assert.deepEqual(await responsePromise, { ok: true, data: { via: 'socket-progress' } });
|
||||
assert.ok(stderr.includes('\r\u001B[2K⊙ "Tab View - Co..." in tab-view-coverflow.yml [3/10]'));
|
||||
assert.ok(stderr.includes('\r\u001B[2K⊙ "Tab View - Co..." in tab-view-coverflow.yml [4/10]'));
|
||||
assert.ok(
|
||||
stderr.includes('\r\u001B[2K✓ "Tab View - Coverflow" in tab-view-coverflow.yml (17.8s)\n'),
|
||||
);
|
||||
assert.ok(stderr.includes('\r\u001B[2K⊙ Tab View - Coverflow [3/10]'));
|
||||
assert.ok(stderr.includes('\r\u001B[2K⊙ Tab View - Coverflow [4/10]'));
|
||||
assert.ok(stderr.includes('\r\u001B[2K✓ Tab View - Coverflow 17.8s\n'));
|
||||
} finally {
|
||||
if (typeof originalCi === 'string') process.env.CI = originalCi;
|
||||
else delete process.env.CI;
|
||||
@@ -308,7 +306,7 @@ test('readDaemonSocketProgressResponse suppresses live progress outside interact
|
||||
socket.emit('data', `${progress}\n${pass}\n${responseLine}\n`);
|
||||
|
||||
assert.deepEqual(await responsePromise, { ok: true, data: { via: 'socket-progress' } });
|
||||
assert.equal(stderr, '✓ "Tab View - Coverflow" in tab-view-coverflow.yml (17.8s)\n');
|
||||
assert.equal(stderr, '✓ Tab View - Coverflow 17.8s\n');
|
||||
} finally {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
}
|
||||
|
||||
+81
-22
@@ -88,15 +88,15 @@ function formatReplayTestLiveProgressLine(
|
||||
): string {
|
||||
const title = event.title?.trim();
|
||||
const file = path.basename(event.file);
|
||||
const shardSuffix = formatReplayTestProgressShardSuffix(event);
|
||||
const stepIndex = event.stepIndex ?? 0;
|
||||
const stepTotal = event.stepTotal ?? 0;
|
||||
const suffix = `${shardSuffix} [${stepIndex}/${stepTotal}]`;
|
||||
const useColor = supportsColor(process.stderr);
|
||||
const shardSuffix = formatReplayTestProgressShardSuffix(event, { useColor });
|
||||
const stepSuffix = formatReplayTestLiveProgressStepSuffix(event, { useColor });
|
||||
const suffix = `${shardSuffix}${stepSuffix}`;
|
||||
const prefix = '⊙ ';
|
||||
if (!title) return trimToColumns(`${prefix}${file}${suffix}`, options.columns);
|
||||
|
||||
const titlePrefix = `${prefix}"`;
|
||||
const titleSuffix = `" in ${file}${suffix}`;
|
||||
const titlePrefix = prefix;
|
||||
const titleSuffix = suffix;
|
||||
const availableTitleColumns = Math.max(
|
||||
0,
|
||||
resolveColumns(options.columns) - titlePrefix.length - titleSuffix.length,
|
||||
@@ -105,35 +105,79 @@ function formatReplayTestLiveProgressLine(
|
||||
return trimToColumns(`${titlePrefix}${formattedTitle}${titleSuffix}`, options.columns);
|
||||
}
|
||||
|
||||
function formatReplayTestLiveProgressStepSuffix(
|
||||
event: ReplayTestCaseProgressEvent,
|
||||
options: { useColor?: boolean } = {},
|
||||
): string {
|
||||
const stepIndex = event.stepIndex ?? 0;
|
||||
const stepTotal = event.stepTotal ?? 0;
|
||||
const suffix = ` [${stepIndex}/${stepTotal}]`;
|
||||
return options.useColor ? colorizeProgressMarker(suffix, 'dim') : suffix;
|
||||
}
|
||||
|
||||
function addReplayTestCaseDetailLines(
|
||||
lines: string[],
|
||||
event: ReplayTestCaseProgressEvent,
|
||||
options: ReplayTestProgressFormatOptions,
|
||||
): void {
|
||||
if (options.verbose && event.status === 'fail') return;
|
||||
if (shouldSuppressReplayTestCaseDetailLines(event, options)) return;
|
||||
const fileLine = replayTestProgressFailureFileLine(event);
|
||||
const messageLine = replayTestProgressMessageLine(event);
|
||||
if (fileLine) lines.push(fileLine);
|
||||
if (messageLine) lines.push(messageLine);
|
||||
appendReplayTestProgressHintLine(lines, event);
|
||||
lines.push(...replayTestProgressFailureContextLines(event));
|
||||
}
|
||||
|
||||
function shouldSuppressReplayTestCaseDetailLines(
|
||||
event: ReplayTestCaseProgressEvent,
|
||||
options: ReplayTestProgressFormatOptions,
|
||||
): boolean {
|
||||
return options.verbose === true && event.status === 'fail';
|
||||
}
|
||||
|
||||
function replayTestProgressFailureFileLine(event: ReplayTestCaseProgressEvent): string | undefined {
|
||||
return event.status === 'fail' && event.title?.trim()
|
||||
? ` file: ${path.basename(event.file)}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function replayTestProgressMessageLine(event: ReplayTestCaseProgressEvent): string | undefined {
|
||||
const message = event.message?.replace(/\s+/g, ' ').trim();
|
||||
if (message) {
|
||||
lines.push(` ${event.status === 'fail' ? `failed at: ${message}` : message}`);
|
||||
}
|
||||
if (event.status === 'fail' && !event.retrying) {
|
||||
if (event.session) lines.push(` session: ${event.session}`);
|
||||
if (event.artifactsDir) lines.push(` artifacts: ${event.artifactsDir}`);
|
||||
}
|
||||
if (!message) return undefined;
|
||||
return ` ${event.status === 'fail' ? `failed at: ${message}` : message}`;
|
||||
}
|
||||
|
||||
function appendReplayTestProgressHintLine(
|
||||
lines: string[],
|
||||
event: ReplayTestCaseProgressEvent,
|
||||
): void {
|
||||
const hint = event.hint?.replace(/\s+/g, ' ').trim();
|
||||
if (event.status === 'fail' && hint) lines.push(` hint: ${hint}`);
|
||||
}
|
||||
|
||||
function replayTestProgressFailureContextLines(event: ReplayTestCaseProgressEvent): string[] {
|
||||
if (event.status !== 'fail' || event.retrying) return [];
|
||||
const lines: string[] = [];
|
||||
if (event.session) lines.push(` session: ${event.session}`);
|
||||
if (event.artifactsDir) lines.push(` artifacts: ${event.artifactsDir}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatReplayTestCaseSummaryLine(event: ReplayTestCaseProgressEvent): string {
|
||||
const useColor = supportsColor(process.stderr);
|
||||
const statusLabel = formatReplayTestProgressStatusLabel(event);
|
||||
const name = formatReplayTestProgressName(event);
|
||||
const shardSuffix = formatReplayTestProgressShardSuffix(event);
|
||||
const shardSuffix = formatReplayTestProgressShardSuffix(event, { useColor });
|
||||
const durationSuffix =
|
||||
event.durationMs !== undefined ? ` (${formatReplayProgressDuration(event)})` : '';
|
||||
event.durationMs !== undefined ? ` ${formatReplayProgressDuration(event, { useColor })}` : '';
|
||||
return `${statusLabel} ${name}${shardSuffix}${durationSuffix}`;
|
||||
}
|
||||
|
||||
function formatReplayTestProgressName(event: ReplayTestCaseProgressEvent): string {
|
||||
const title = event.title?.trim();
|
||||
const file = path.basename(event.file);
|
||||
return title ? `${JSON.stringify(title)} in ${file}` : file;
|
||||
return title ? title : file;
|
||||
}
|
||||
|
||||
function formatReplayTestProgressStatusLabel(event: ReplayTestCaseProgressEvent): string {
|
||||
@@ -151,15 +195,30 @@ function colorizeProgressMarker(text: string, format: Parameters<typeof colorize
|
||||
return colorize(text, format, { validateStream: false });
|
||||
}
|
||||
|
||||
function formatReplayTestProgressShardSuffix(event: ReplayTestCaseProgressEvent): string {
|
||||
function formatReplayTestProgressShardSuffix(
|
||||
event: ReplayTestCaseProgressEvent,
|
||||
options: { useColor?: boolean } = {},
|
||||
): string {
|
||||
if (typeof event.shardIndex !== 'number') return '';
|
||||
const shardCount = typeof event.shardCount === 'number' ? event.shardCount : '?';
|
||||
const device = typeof event.deviceId === 'string' ? ` ${event.deviceId}` : '';
|
||||
return ` [shard ${event.shardIndex + 1}/${shardCount}${device}]`;
|
||||
const device = replayTestProgressShardDeviceName(event);
|
||||
const suffix = ` [${event.shardIndex + 1}/${shardCount}${device ? ` ${device}` : ''}]`;
|
||||
return options.useColor ? colorizeProgressMarker(suffix, 'dim') : suffix;
|
||||
}
|
||||
|
||||
function formatReplayProgressDuration(event: ReplayTestCaseProgressEvent): string {
|
||||
return formatDurationSeconds(event.durationMs ?? 0);
|
||||
function replayTestProgressShardDeviceName(event: ReplayTestCaseProgressEvent): string | undefined {
|
||||
const name = event.deviceName?.trim();
|
||||
if (name) return name;
|
||||
const id = event.deviceId?.trim();
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function formatReplayProgressDuration(
|
||||
event: ReplayTestCaseProgressEvent,
|
||||
options: { useColor?: boolean } = {},
|
||||
): string {
|
||||
const duration = formatDurationSeconds(event.durationMs ?? 0);
|
||||
return options.useColor ? colorizeProgressMarker(duration, 'yellow') : duration;
|
||||
}
|
||||
|
||||
function isReplayTestCompletionProgressEvent(event: ReplayTestCaseProgressEvent): boolean {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
replayErrorHintLine,
|
||||
replayErrorLogLine,
|
||||
replayTestDisplayNameWithFile,
|
||||
replayTestFailureFileLine,
|
||||
type FailedReplayTestResult,
|
||||
type PassedReplayTestResult,
|
||||
} from './format.ts';
|
||||
@@ -37,9 +38,29 @@ function renderReplayTestSummary(
|
||||
|
||||
function formatReplayTestSummaryLine(data: ReplaySuiteResult, flakyCount: number): string {
|
||||
const durationMs = typeof data.durationMs === 'number' ? data.durationMs : undefined;
|
||||
const useColor = supportsColor();
|
||||
const passed = formatReplaySummaryPassed(data.passed, { useColor });
|
||||
const total = formatReplaySummaryTotal(data.total, { useColor });
|
||||
const failedSuffix = data.failed > 0 ? `, ${data.failed} failed` : '';
|
||||
const flakySuffix = flakyCount > 0 ? `, ${flakyCount} flaky` : '';
|
||||
const durationSuffix = durationMs !== undefined ? ` in ${formatDurationSeconds(durationMs)}` : '';
|
||||
return `Test summary: ${data.passed} passed, ${data.failed} failed${flakySuffix}${durationSuffix}`;
|
||||
const durationSuffix =
|
||||
durationMs !== undefined ? ` in ${formatReplayDuration(durationMs, { useColor })}` : '';
|
||||
return `Test summary: ${passed} ${total}${failedSuffix}${flakySuffix}${durationSuffix}`;
|
||||
}
|
||||
|
||||
function formatReplaySummaryPassed(passed: number, options: { useColor?: boolean } = {}): string {
|
||||
const text = `${passed} passed`;
|
||||
return options.useColor ? colorize(text, 'green') : text;
|
||||
}
|
||||
|
||||
function formatReplaySummaryTotal(total: number, options: { useColor?: boolean } = {}): string {
|
||||
const text = `(${total})`;
|
||||
return options.useColor ? colorize(text, 'dim') : text;
|
||||
}
|
||||
|
||||
function formatReplayDuration(durationMs: number, options: { useColor?: boolean } = {}): string {
|
||||
const duration = formatDurationSeconds(durationMs);
|
||||
return options.useColor ? colorize(duration, 'yellow') : duration;
|
||||
}
|
||||
|
||||
function replayFlakyStatusIcon(): string {
|
||||
@@ -97,6 +118,8 @@ function renderReplayFailureBody(
|
||||
context: ReplayTestReporterContext,
|
||||
indent: string,
|
||||
): void {
|
||||
const fileLine = replayTestFailureFileLine(result);
|
||||
if (fileLine) context.writeStdout(`${indent}${fileLine}\n`);
|
||||
context.writeStdout(`${indent}${result.error?.message ?? 'Unknown test failure'}\n`);
|
||||
for (const line of replayFailureConsoleLines(result)) {
|
||||
context.writeStdout(`${indent}${line}\n`);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function isFlakyReplayTestResult(
|
||||
export function replayTestDisplayNameWithFile(result: ReplaySuiteTestResult): string {
|
||||
const title = replayTestTitle(result);
|
||||
const filename = path.basename(result.file);
|
||||
const base = title && title.length > 0 ? `${JSON.stringify(title)} in ${filename}` : filename;
|
||||
const base = title && title.length > 0 ? title : filename;
|
||||
return `${base}${formatReplayTestShardSuffix(result)}`;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ export function replayArtifactsLine(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function replayTestFailureFileLine(result: FailedReplayTestResult): string | undefined {
|
||||
return replayTestTitle(result) ? `file: ${path.basename(result.file)}` : undefined;
|
||||
}
|
||||
|
||||
export function replayErrorHintLine(error: ReplayTestError): string | undefined {
|
||||
return error.hint ? `hint: ${error.hint}` : undefined;
|
||||
}
|
||||
@@ -96,6 +100,10 @@ export function appendReplayTestShardMetadata(
|
||||
lines,
|
||||
typeof result.deviceId === 'string' ? `deviceId: ${result.deviceId}` : undefined,
|
||||
);
|
||||
appendOptionalLine(
|
||||
lines,
|
||||
typeof result.deviceName === 'string' ? `deviceName: ${result.deviceName}` : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function replayTestWarningLines(result: ReplaySuiteTestResult): string[] {
|
||||
@@ -132,6 +140,13 @@ function replayTestTitle(result: ReplaySuiteTestResult): string | undefined {
|
||||
export function formatReplayTestShardSuffix(result: ReplaySuiteTestResult): string {
|
||||
if (!('shardIndex' in result) || typeof result.shardIndex !== 'number') return '';
|
||||
const shardCount = typeof result.shardCount === 'number' ? result.shardCount : '?';
|
||||
const device = typeof result.deviceId === 'string' ? ` ${result.deviceId}` : '';
|
||||
return ` [shard ${result.shardIndex + 1}/${shardCount}${device}]`;
|
||||
const device = replayTestShardDeviceName(result);
|
||||
return ` [${result.shardIndex + 1}/${shardCount}${device ? ` ${device}` : ''}]`;
|
||||
}
|
||||
|
||||
function replayTestShardDeviceName(result: ReplaySuiteTestResult): string | undefined {
|
||||
const name = 'deviceName' in result ? result.deviceName?.trim() : undefined;
|
||||
if (name) return name;
|
||||
const id = 'deviceId' in result ? result.deviceId?.trim() : undefined;
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const prepareCliSchema = {
|
||||
usageOverride: 'prepare ios-runner --platform ios|macos [--timeout <ms>]',
|
||||
listUsageOverride: 'prepare',
|
||||
helpDescription:
|
||||
'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.',
|
||||
'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, stop the prepare daemon before replay/test so it does not keep the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.',
|
||||
summary:
|
||||
'Pre-warm platform helpers, especially the iOS/macOS XCTest runner before Apple automation',
|
||||
positionalArgs: ['ios-runner'],
|
||||
|
||||
@@ -740,7 +740,9 @@ test('parseMaestroReplayFlow parses the test-app Maestro suite fixture', () => {
|
||||
assert.deepEqual(
|
||||
parsed.actions.map((entry) => entry.command),
|
||||
[
|
||||
'open',
|
||||
'__maestroAssertVisible',
|
||||
'__maestroScrollUntilVisible',
|
||||
'__maestroTapOn',
|
||||
'__maestroAssertVisible',
|
||||
'__maestroTapOn',
|
||||
@@ -760,4 +762,5 @@ test('parseMaestroReplayFlow parses the test-app Maestro suite fixture', () => {
|
||||
'__maestroAssertVisible',
|
||||
],
|
||||
);
|
||||
assert.equal(parsed.actions[0]?.flags.clearAppState, true);
|
||||
});
|
||||
|
||||
@@ -509,6 +509,12 @@ test('test --shard-all runs each runnable entry on each selected device', async
|
||||
'emulator-5556',
|
||||
'emulator-5556',
|
||||
]);
|
||||
expect(tests.map((entry) => entry.deviceName)).toEqual([
|
||||
'Pixel 8',
|
||||
'Pixel 8',
|
||||
'Pixel 8 Pro',
|
||||
'Pixel 8 Pro',
|
||||
]);
|
||||
expect(tests.map((entry) => entry.artifactsDir)).toEqual([
|
||||
expect.stringContaining(`${path.sep}shard-1${path.sep}01-login`),
|
||||
expect.stringContaining(`${path.sep}shard-1${path.sep}02-pay`),
|
||||
@@ -567,6 +573,9 @@ test('test --shard-split distributes runnable entries by modulo and keeps skips
|
||||
expect(tests.filter((entry) => entry.status === 'passed').map((entry) => entry.deviceId)).toEqual(
|
||||
['emulator-5554', 'emulator-5554', 'emulator-5556'],
|
||||
);
|
||||
expect(
|
||||
tests.filter((entry) => entry.status === 'passed').map((entry) => entry.deviceName),
|
||||
).toEqual(['Pixel 8', 'Pixel 8', 'Pixel 8 Pro']);
|
||||
expect(invoked.map((req) => req.flags?.serial).sort()).toEqual([
|
||||
'emulator-5554',
|
||||
'emulator-5554',
|
||||
|
||||
@@ -243,6 +243,7 @@ function emitReplayTestRetryProgress(
|
||||
durationMs: attempt.durationMs,
|
||||
retrying: true,
|
||||
message: attempt.response.error.message,
|
||||
hint: attempt.response.error.hint,
|
||||
session: attempt.sessionName,
|
||||
artifactsDir: context.testArtifactsDir,
|
||||
...replayTestProgressShardMetadata(params.shard),
|
||||
@@ -322,6 +323,7 @@ function buildReplayTestFailedResult(
|
||||
session: outcome.finalSessionName,
|
||||
artifactsDir: context.testArtifactsDir,
|
||||
message: error.message,
|
||||
hint: error.hint,
|
||||
...replayTestProgressShardMetadata(shard),
|
||||
});
|
||||
return {
|
||||
@@ -379,18 +381,19 @@ function readReplayResponseSnapshotDiagnostics(response: DaemonResponse | undefi
|
||||
|
||||
function replayTestShardResultMetadata(
|
||||
shard: ReplayTestShardContext | undefined,
|
||||
): Pick<ReplaySuiteTestFailed, 'shardIndex' | 'shardCount' | 'deviceId'> {
|
||||
): Pick<ReplaySuiteTestFailed, 'shardIndex' | 'shardCount' | 'deviceId' | 'deviceName'> {
|
||||
return replayTestProgressShardMetadata(shard);
|
||||
}
|
||||
|
||||
function replayTestProgressShardMetadata(
|
||||
shard: ReplayTestShardContext | undefined,
|
||||
): Pick<ReplaySuiteTestFailed, 'shardIndex' | 'shardCount' | 'deviceId'> {
|
||||
): Pick<ReplaySuiteTestFailed, 'shardIndex' | 'shardCount' | 'deviceId' | 'deviceName'> {
|
||||
return shard
|
||||
? {
|
||||
shardIndex: shard.shardIndex,
|
||||
shardCount: shard.shardCount,
|
||||
deviceId: shard.device.id,
|
||||
deviceName: shard.device.name,
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ function buildUnexpectedShardFailure(
|
||||
shardIndex: shard.shardIndex,
|
||||
shardCount: shard.shardCount,
|
||||
deviceId: shard.device.id,
|
||||
deviceName: shard.device.name,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,13 @@ export type ReplayTestProgressEvent = {
|
||||
durationMs?: number;
|
||||
retrying?: boolean;
|
||||
message?: string;
|
||||
hint?: string;
|
||||
session?: string;
|
||||
artifactsDir?: string;
|
||||
shardIndex?: number;
|
||||
shardCount?: number;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
};
|
||||
|
||||
export type CommandProgressEvent = {
|
||||
|
||||
@@ -76,6 +76,7 @@ export type ReplaySuiteTestPassed = {
|
||||
shardIndex?: number;
|
||||
shardCount?: number;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
|
||||
};
|
||||
|
||||
@@ -91,6 +92,7 @@ export type ReplaySuiteTestFailed = {
|
||||
shardIndex?: number;
|
||||
shardCount?: number;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
|
||||
};
|
||||
|
||||
|
||||
@@ -772,10 +772,26 @@ test('runner session startup rejects live foreign runner lease', async () => {
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/Do not run prepare ios-runner/,
|
||||
);
|
||||
assert.match(
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/^If it is stuck, stop the owning agent-device daemon for AGENT_DEVICE_STATE_DIR='\/tmp\/agent-device-owner' and retry/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/pnpm|clean:daemon/,
|
||||
);
|
||||
assert.match(
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner\./,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
String((thrown as { details?: Record<string, unknown> }).details?.hint),
|
||||
/Current daemon state dir/,
|
||||
);
|
||||
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
|
||||
assert.equal(
|
||||
mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'),
|
||||
@@ -825,10 +841,19 @@ test('runner session busy error includes logical lease context after admission',
|
||||
deviceKey: device.id,
|
||||
});
|
||||
assert.match(String(thrown.details?.hint), /five-minute inactivity lease expires/);
|
||||
assert.match(
|
||||
String(thrown.details?.hint),
|
||||
/^If it is stuck, stop the owning agent-device daemon for AGENT_DEVICE_STATE_DIR='\/tmp\/agent-device-owner' and retry/,
|
||||
);
|
||||
assert.doesNotMatch(String(thrown.details?.hint), /pnpm|clean:daemon/);
|
||||
assert.match(
|
||||
String(thrown.details?.hint),
|
||||
/Runner owner: PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
String(thrown.details?.hint),
|
||||
/AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner\./,
|
||||
);
|
||||
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -188,26 +188,44 @@ function buildBusyRunnerLeaseHint(
|
||||
lease: RunnerLease,
|
||||
logicalLeaseContext?: RunnerLogicalLeaseContext,
|
||||
): string {
|
||||
const owner = `PID ${lease.ownerPid}`;
|
||||
const stateDir = lease.ownerStateDir ? ` with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}` : '';
|
||||
const currentStateDir = readCurrentStateDir();
|
||||
const current =
|
||||
currentStateDir && currentStateDir !== lease.ownerStateDir
|
||||
? ` Current daemon state dir is ${currentStateDir}.`
|
||||
: '';
|
||||
const owner = buildRunnerOwnerHint(lease);
|
||||
const cleanup = buildBusyRunnerLeaseCleanupHint(lease);
|
||||
if (logicalLeaseContext) {
|
||||
return [
|
||||
`The device is busy because another active device lease owns it, or the runner is owned by another daemon/process after lease admission. Runner owner: ${owner}${stateDir}.${current}`,
|
||||
cleanup,
|
||||
owner,
|
||||
'The device is busy because another active device lease owns it, or the runner is owned by another daemon/process after lease admission.',
|
||||
'Retry after the owning session closes or after the five-minute inactivity lease expires.',
|
||||
'If this persists after expiry, inspect the runner owner details and clean the stale daemon state on the machine with simulator access.',
|
||||
].join(' ');
|
||||
}
|
||||
return [
|
||||
`Runner owner details: ${owner}${stateDir}.${current} Retry after the owning runner finishes.`,
|
||||
'Do not run prepare ios-runner from another daemon/client to recover this; a live foreign runner lease cannot be released by the remote client.',
|
||||
cleanup,
|
||||
owner,
|
||||
'If the runner is still active, wait for it to finish. Do not run prepare ios-runner from another daemon/client to recover this.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildRunnerOwnerHint(lease: RunnerLease): string {
|
||||
const owner = `Runner owner: PID ${lease.ownerPid}`;
|
||||
if (lease.ownerStateDir) return `${owner} with AGENT_DEVICE_STATE_DIR=${lease.ownerStateDir}`;
|
||||
return `${owner}.`;
|
||||
}
|
||||
|
||||
function buildBusyRunnerLeaseCleanupHint(lease: RunnerLease): string {
|
||||
if (lease.ownerStateDir) {
|
||||
return `If it is stuck, stop the owning agent-device daemon for ${formatEnvAssignment('AGENT_DEVICE_STATE_DIR', lease.ownerStateDir)} and retry.`;
|
||||
}
|
||||
return 'If it is stuck, stop the owning agent-device daemon and retry.';
|
||||
}
|
||||
|
||||
function formatEnvAssignment(name: string, value: string): string {
|
||||
return `${name}=${shellQuote(value)}`;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
export async function cleanupOwnedRunnerLease(
|
||||
deviceId: string,
|
||||
cleanup: RunnerLeaseCleanupAdapter,
|
||||
|
||||
@@ -1391,7 +1391,8 @@ test('usageForCommand documents prepare ios-runner', () => {
|
||||
assert.match(help, /--timeout <ms>/);
|
||||
assert.match(help, /XCTest runner/);
|
||||
assert.match(help, /separate daemon/);
|
||||
assert.match(help, /clean:daemon after prepare/);
|
||||
assert.match(help, /stop the prepare daemon before replay\/test/);
|
||||
assert.doesNotMatch(help, /clean:daemon|pnpm/);
|
||||
assert.match(
|
||||
help,
|
||||
/not a recovery step for "runner already owned by another agent-device daemon"/,
|
||||
|
||||
@@ -558,8 +558,8 @@ test('sendToDaemon prints replay test progress before the socket response', asyn
|
||||
});
|
||||
|
||||
assert.deepEqual(response, { ok: true, data: { via: 'socket' } });
|
||||
assert.match(stderr, /✓ "Login flow" in 01-login\.ad \(1\.23s\)/);
|
||||
assert.equal(stderr.match(/✓ "Login flow" in 01-login\.ad \(1\.23s\)/g)?.length, 1);
|
||||
assert.match(stderr, /✓ Login flow 1\.23s/);
|
||||
assert.equal(stderr.match(/✓ Login flow 1\.23s/g)?.length, 1);
|
||||
assert.match(stderr, /steps \(attempt 2\):/);
|
||||
assert.match(stderr, /open "Demo" \(line 3, 0\.25s\)/);
|
||||
assert.match(stderr, /assertVisible "text=\\"Home\\"" "3000" \(line 4, 0\.75s\)/);
|
||||
@@ -663,7 +663,7 @@ test('sendToDaemon prints replay test progress before the HTTP NDJSON response',
|
||||
assert.deepEqual(response, { ok: true, data: { via: 'http-progress' } });
|
||||
});
|
||||
assert.deepEqual(seenPaths, ['GET /agent-device/health', 'POST /agent-device/rpc']);
|
||||
assert.match(stderr, /✓ "Payments flow" in 02-payments\.ad \(2\.50s\)/);
|
||||
assert.match(stderr, /✓ Payments flow 2\.50s/);
|
||||
} finally {
|
||||
(http as unknown as { request: typeof http.request }).request = originalHttpRequest;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
|
||||
@@ -155,7 +155,7 @@ Bootstrap:
|
||||
agent-device prepare ios-runner --platform ios --timeout 240000
|
||||
If app id is unknown, plan devices, apps, then open <discovered-app-id>. Discovery is not enough when the task asks to open/start the app.
|
||||
Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install.
|
||||
In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. It is not a recovery step for "runner already owned by another agent-device daemon"; stop or clean the owning daemon on the Mac with simulator access instead. If the replay/test step starts a separate daemon, run clean:daemon after prepare so the prepared runner does not keep a live lease owned by the prepare daemon.
|
||||
In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. If the replay/test step starts a separate daemon, stop the prepare daemon before replay/test so the prepared runner does not keep a live lease owned by that daemon.
|
||||
CI may cache ~/.agent-device/ios-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues.
|
||||
Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user