peachsim 0.1.921 → 0.1.923
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/README.md +11 -5
- package/cli/commands/cpu-profile.ts +25 -0
- package/cli/commands/flow.ts +7 -2
- package/cli/commands/maestro-generate.ts +292 -98
- package/cli/commands/maestro.ts +51 -29
- package/cli/drivers/playwright-provisioning.ts +29 -0
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/bundle-channel.cjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/expo-swift-bundle.cjs +1 -1
- package/dist-lib/expo-swift-metadata.cjs +1 -1
- package/dist-lib/expo-swift-metadata.mjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/native-app-identity.cjs +1 -1
- package/dist-lib/native-app-identity.mjs +1 -1
- package/dist-lib/native-firebase-metadata.cjs +1 -1
- package/dist-lib/native-firebase-metadata.mjs +1 -1
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +21 -6
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +1 -1
- package/dist-lib/worklet-executable.cjs +1 -1
- package/dist-lib/worklet-executable.mjs +1 -1
- package/package.json +1 -1
- package/skills/peach-debug/SKILL.md +4 -3
- package/skills/peach-debug/references/commands.md +1 -1
- package/skills/peach-perf/SKILL.md +3 -13
- package/skills/peach-setup/SKILL.md +5 -4
- package/skills/peach-test/SKILL.md +14 -7
- package/src/screenshots/compose.ts +2 -9
- package/src/screenshots/frame-compose.ts +2 -5
- package/src/screenshots/orchestrate.ts +3 -17
package/README.md
CHANGED
|
@@ -519,12 +519,17 @@ Maestro is Peach's single YAML test surface. Point it at your existing
|
|
|
519
519
|
`.maestro/` directory, generate a test, or author one from live actions:
|
|
520
520
|
|
|
521
521
|
```sh
|
|
522
|
-
peach maestro # discover .maestro/
|
|
522
|
+
peach maestro # discover .maestro/ workspace
|
|
523
523
|
peach maestro test .maestro/ # explicit
|
|
524
524
|
peach maestro generate "verify login"
|
|
525
525
|
peach maestro --list-compat # see the verb support matrix
|
|
526
526
|
```
|
|
527
527
|
|
|
528
|
+
Directory discovery selects root YAML flows by default, as Maestro does.
|
|
529
|
+
For nested suites, pass their folder, such as `.maestro/flows`, or add
|
|
530
|
+
`config.yaml` with `flows: ["flows/**"]`. Keep shared subflows outside those
|
|
531
|
+
globs; `runFlow` can still load them.
|
|
532
|
+
|
|
528
533
|
Tap settling compares consecutive full-screen pixels within Maestro's iOS
|
|
529
534
|
three-second budget, while also draining screen transitions, native motion and
|
|
530
535
|
image loads. Periodic caret paints do not require a continuous stillness window,
|
|
@@ -567,14 +572,15 @@ therefore prove that they are painting without weakening the boot-card guard.
|
|
|
567
572
|
withFrame: true
|
|
568
573
|
```
|
|
569
574
|
|
|
570
|
-
For plan-driven app-store exports, use `peach screenshot appstore
|
|
575
|
+
For plan-driven app-store exports, use `peach screenshot appstore`. The CLI
|
|
576
|
+
provisions its browser dependency. Paths resolve relative to the plan file:
|
|
571
577
|
|
|
572
578
|
```yaml
|
|
573
579
|
app: 8081
|
|
574
580
|
device: iphone-16
|
|
575
581
|
|
|
576
582
|
capture:
|
|
577
|
-
flow:
|
|
583
|
+
flow: ../.maestro/capture-app-store-screenshots.yaml
|
|
578
584
|
mode: raw+framed
|
|
579
585
|
|
|
580
586
|
compose:
|
|
@@ -602,8 +608,8 @@ instead of prepending `--screenshots <rawDir>`:
|
|
|
602
608
|
|
|
603
609
|
```yaml
|
|
604
610
|
capture:
|
|
605
|
-
flow:
|
|
606
|
-
from:
|
|
611
|
+
flow: ../.maestro/capture-app-store-screenshots.yaml
|
|
612
|
+
from: ../apps/app-store-screenshots/public/screenshots/apple/iphone/en
|
|
607
613
|
pathMode: flow
|
|
608
614
|
```
|
|
609
615
|
|
|
@@ -407,6 +407,31 @@ export async function runCpuProfile(
|
|
|
407
407
|
args: string[],
|
|
408
408
|
opts: ProfileOptions,
|
|
409
409
|
): Promise<number> {
|
|
410
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
411
|
+
console.log(`
|
|
412
|
+
peach perf cpu — capture CPU profiles for the page and every worker
|
|
413
|
+
|
|
414
|
+
usage:
|
|
415
|
+
peach perf cpu [options]
|
|
416
|
+
|
|
417
|
+
options:
|
|
418
|
+
--cdp-port <number> Chrome debugging port (default: PEACH_CDP_PORT or 9222)
|
|
419
|
+
--match <substring> page URL filter (default: /rn/)
|
|
420
|
+
--duration <seconds> capture duration (default: 5)
|
|
421
|
+
--sample-interval <ms> CPU sampling interval (default: 0.1)
|
|
422
|
+
--output, -o <path> profile path (default: /tmp/peach.cpuprofile)
|
|
423
|
+
--interaction-barrier wait for a workload's measured interaction
|
|
424
|
+
--interaction-barrier-timeout <seconds> barrier deadline (default: 90)
|
|
425
|
+
|
|
426
|
+
examples:
|
|
427
|
+
peach open 8081 --new --driver playwright --cdp-port 9222
|
|
428
|
+
peach perf cpu --cdp-port 9222 --match 8081 --duration 5
|
|
429
|
+
|
|
430
|
+
Writes a page profile, worker profiles, and a manifest alongside the output.
|
|
431
|
+
`)
|
|
432
|
+
return 0
|
|
433
|
+
}
|
|
434
|
+
|
|
410
435
|
const duration = Number(valueOf(args, '--duration') ?? '5')
|
|
411
436
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
412
437
|
console.error(' --duration must be a positive number (seconds)')
|
package/cli/commands/flow.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { applyPeachConfigToUrl, type PeachConfig } from '../../src/config'
|
|
|
11
11
|
import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
|
|
12
12
|
import {
|
|
13
13
|
SootSimBridgeFlowRunner,
|
|
14
|
+
type MaestroStep,
|
|
14
15
|
type SootSimFlowProfileResult,
|
|
15
16
|
type SootSimFlowTraceStep,
|
|
16
17
|
} from '../bridge-flow-runner'
|
|
@@ -217,7 +218,10 @@ export async function runFlowPlayback(
|
|
|
217
218
|
// beforeSteps runs once the flow is on its target and about to run its first
|
|
218
219
|
// step. opening a frontmatter `app:` navigates the sim, which wipes in-page
|
|
219
220
|
// state, so a caller capturing the run itself (film) starts here.
|
|
220
|
-
hooks: {
|
|
221
|
+
hooks: {
|
|
222
|
+
beforeSteps?: () => Promise<void>
|
|
223
|
+
runSteps?: (driver: SootSimBridgeFlowRunner, steps: MaestroStep[]) => Promise<void>
|
|
224
|
+
} = {},
|
|
221
225
|
): Promise<number> {
|
|
222
226
|
lastPreviewUploadResult = null
|
|
223
227
|
lastPreviewUploadError = null
|
|
@@ -782,7 +786,8 @@ flow extension:
|
|
|
782
786
|
}
|
|
783
787
|
}
|
|
784
788
|
await hooks.beforeSteps?.()
|
|
785
|
-
await
|
|
789
|
+
if (hooks.runSteps) await hooks.runSteps(driver, steps)
|
|
790
|
+
else await driver.runFlow(steps, { skipPlan: dryRun })
|
|
786
791
|
if (profile) {
|
|
787
792
|
profileResult = await driver.stopProfile()
|
|
788
793
|
}
|
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
// plain-language Maestro authoring for `peach maestro generate`.
|
|
2
2
|
|
|
3
|
-
import { mkdtempSync, writeFileSync } from 'fs'
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { tmpdir } from 'os'
|
|
5
5
|
import { join } from 'path'
|
|
6
6
|
import yaml from 'yaml'
|
|
7
7
|
import { isRecord } from '../../src/is-record'
|
|
8
8
|
import { authHeaderValue, resolveCliAuth } from '../auth'
|
|
9
9
|
import { registerRun, stepSummaryFromTrace } from '../run-registry'
|
|
10
|
-
import { createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
|
|
11
10
|
import {
|
|
12
11
|
findUnexpectedFlowArgs,
|
|
13
12
|
FLOW_ARG_VALUE_FLAGS,
|
|
14
|
-
FLOW_BRIDGE_ARG_OPTIONS,
|
|
15
13
|
getLastFlowPreviewUploadResult,
|
|
16
14
|
getLastFlowTraceSteps,
|
|
17
15
|
runFlowPlayback,
|
|
18
16
|
type UnexpectedFlowArg,
|
|
19
17
|
} from './flow'
|
|
20
|
-
import { inspectDescribe } from './inspect/core'
|
|
21
18
|
import { resolveDefaultUploadOrigin } from './upload'
|
|
19
|
+
import type { MaestroStep, SootSimBridgeFlowRunner } from '../bridge-flow-runner'
|
|
22
20
|
|
|
23
21
|
const DEFAULT_TEST_MODEL = process.env.PEACH_TEST_MODEL || 'deepseek-v4-flash'
|
|
22
|
+
const MAX_GENERATION_STEPS = 30
|
|
24
23
|
const DESCRIBE_CONTEXT_LIMIT = 18_000
|
|
25
24
|
const LLM_TIMEOUT_MS = 180_000
|
|
26
25
|
|
|
@@ -37,12 +36,8 @@ function flagValueAny(args: string[], names: string[]): string | undefined {
|
|
|
37
36
|
return undefined
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// itself; everything else belongs to the runner and is forwarded verbatim. this
|
|
43
|
-
// file used to keep its own allowlist of what to forward, which silently
|
|
44
|
-
// dropped --proof, --record, --profile, --screenshots, --out and --base-url
|
|
45
|
-
// while `maestro test` honoured all six.
|
|
39
|
+
// generation runs inside playback's target and recording lifecycle. only these
|
|
40
|
+
// flags belong to generation; the runner receives every other flag verbatim.
|
|
46
41
|
const GENERATE_VALUE_FLAGS = ['--origin', '--preview-origin', '--public-origin']
|
|
47
42
|
|
|
48
43
|
const VALUE_FLAGS = new Set([...GENERATE_VALUE_FLAGS, ...FLOW_ARG_VALUE_FLAGS])
|
|
@@ -61,50 +56,15 @@ export function findPrompt(args: string[]): { prompt: string; index: number } |
|
|
|
61
56
|
return null
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function describeCurrentSim(args: string[], promptIndex: number): Promise<string> {
|
|
69
|
-
const bridgeArgs = stripPromptForBridge(args, promptIndex)
|
|
70
|
-
const parsedBridgeArgs = parseBridgeCliArgs(bridgeArgs, {
|
|
71
|
-
stripBooleanFlags: FLOW_BRIDGE_ARG_OPTIONS.stripBooleanFlags,
|
|
72
|
-
stripValueFlags: [
|
|
73
|
-
...GENERATE_VALUE_FLAGS,
|
|
74
|
-
...FLOW_BRIDGE_ARG_OPTIONS.stripValueFlags,
|
|
75
|
-
],
|
|
76
|
-
})
|
|
77
|
-
const bridge = createBridgeFromParsed(parsedBridgeArgs)
|
|
78
|
-
try {
|
|
79
|
-
const describe = await inspectDescribe(bridge, {
|
|
80
|
-
describe: true,
|
|
81
|
-
verbose: false,
|
|
82
|
-
filter: '',
|
|
83
|
-
compact: true,
|
|
84
|
-
hideXy: true,
|
|
85
|
-
})
|
|
86
|
-
const parts = [
|
|
87
|
-
typeof describe.nodeCount === 'number' ? `nodes: ${describe.nodeCount}` : '',
|
|
88
|
-
describe.tree || '',
|
|
89
|
-
].filter(Boolean)
|
|
90
|
-
const text = parts.join('\n')
|
|
91
|
-
return text.length > DESCRIBE_CONTEXT_LIMIT
|
|
92
|
-
? text.slice(0, DESCRIBE_CONTEXT_LIMIT) + '\n[tree truncated]'
|
|
93
|
-
: text
|
|
94
|
-
} finally {
|
|
95
|
-
bridge.close()
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const RNX_TEST_SYSTEM_PROMPT = `
|
|
100
|
-
You write Maestro-compatible peach YAML flows from a user's plain-language test goal.
|
|
59
|
+
const PEACH_TEST_SYSTEM_PROMPT = `
|
|
60
|
+
You author a Maestro-compatible peach flow one step at a time on a running simulator.
|
|
101
61
|
Return only JSON, no markdown, with this exact shape:
|
|
102
|
-
{"summary":"short
|
|
62
|
+
{"summary":"short result goal","step":{"tapOn":{"id":"observed-id"}},"done":false}
|
|
103
63
|
|
|
104
|
-
Use only these
|
|
64
|
+
Use only these commands, one per response:
|
|
105
65
|
- waitFor: { "text"?: string, "id"?: string, "timeout"?: number }
|
|
106
|
-
- assertVisible: string or { "text"?: string, "id"?: string }
|
|
107
|
-
- assertNotVisible: string or { "text"?: string, "id"?: string }
|
|
66
|
+
- assertVisible: string or { "text"?: string, "id"?: string, "index"?: number }
|
|
67
|
+
- assertNotVisible: string or { "text"?: string, "id"?: string, "index"?: number }
|
|
108
68
|
- tapOn: string or { "text"?: string, "id"?: string, "index"?: number }
|
|
109
69
|
- inputText: string
|
|
110
70
|
- pressKey: string
|
|
@@ -113,26 +73,38 @@ Use only these flow commands:
|
|
|
113
73
|
- waitForAnimationToEnd: true
|
|
114
74
|
- takeScreenshot: "label"
|
|
115
75
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
76
|
+
Every selector must use an exact id or text from the current observed elements.
|
|
77
|
+
Prefer ids. Accessible labels are text selectors. Use index for repeated labels.
|
|
78
|
+
Only assertNotVisible may refer to an element observed on an earlier screen.
|
|
79
|
+
Never guess selectors for the next screen. After each step the simulator is inspected again.
|
|
80
|
+
Use existing visible app data; do not invent credentials or test data.
|
|
81
|
+
Set done:true only with a final assertion that proves the user's goal, or step:null
|
|
82
|
+
when the preceding assertion already proved it. Keep the flow short.
|
|
119
83
|
`.trim()
|
|
120
84
|
|
|
121
|
-
function buildPromptRequest(
|
|
85
|
+
function buildPromptRequest(
|
|
86
|
+
prompt: string,
|
|
87
|
+
describe: string,
|
|
88
|
+
steps: MaestroStep[],
|
|
89
|
+
): string {
|
|
122
90
|
return `
|
|
123
91
|
User goal:
|
|
124
92
|
${prompt}
|
|
125
93
|
|
|
126
|
-
|
|
127
|
-
${
|
|
94
|
+
Successfully executed steps:
|
|
95
|
+
${yaml.stringify(steps)}
|
|
96
|
+
|
|
97
|
+
Current peach observed selectors (id, text and role):
|
|
98
|
+
${describe}
|
|
128
99
|
|
|
129
|
-
|
|
100
|
+
Choose only the next step, using a selector from these observations.
|
|
130
101
|
`.trim()
|
|
131
102
|
}
|
|
132
103
|
|
|
133
104
|
async function generateFlowJson(args: {
|
|
134
105
|
prompt: string
|
|
135
106
|
describe: string
|
|
107
|
+
steps: MaestroStep[]
|
|
136
108
|
origin: string
|
|
137
109
|
authHeader: string
|
|
138
110
|
}): Promise<string> {
|
|
@@ -145,16 +117,16 @@ async function generateFlowJson(args: {
|
|
|
145
117
|
body: JSON.stringify({
|
|
146
118
|
modelId: DEFAULT_TEST_MODEL,
|
|
147
119
|
context: {
|
|
148
|
-
systemPrompt:
|
|
120
|
+
systemPrompt: PEACH_TEST_SYSTEM_PROMPT,
|
|
149
121
|
messages: [
|
|
150
122
|
{
|
|
151
123
|
role: 'user',
|
|
152
|
-
content: buildPromptRequest(args.prompt, args.describe),
|
|
124
|
+
content: buildPromptRequest(args.prompt, args.describe, args.steps),
|
|
153
125
|
timestamp: Date.now(),
|
|
154
126
|
},
|
|
155
127
|
],
|
|
156
128
|
},
|
|
157
|
-
options: { maxTokens:
|
|
129
|
+
options: { maxTokens: 600, temperature: 0.2 },
|
|
158
130
|
threadId: null,
|
|
159
131
|
}),
|
|
160
132
|
signal: AbortSignal.timeout(LLM_TIMEOUT_MS),
|
|
@@ -173,15 +145,14 @@ async function generateFlowJson(args: {
|
|
|
173
145
|
const handleLine = (line: string) => {
|
|
174
146
|
const trimmed = line.trim()
|
|
175
147
|
if (!trimmed) return
|
|
176
|
-
const event = JSON.parse(trimmed)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
errorMessage?: string
|
|
180
|
-
}
|
|
181
|
-
if (event.type === 'text_delta' && event.delta) {
|
|
148
|
+
const event: unknown = JSON.parse(trimmed)
|
|
149
|
+
if (!isRecord(event)) throw new Error('llm returned an invalid stream event')
|
|
150
|
+
if (event.type === 'text_delta' && typeof event.delta === 'string') {
|
|
182
151
|
deltas.push(event.delta)
|
|
183
152
|
} else if (event.type === 'error') {
|
|
184
|
-
throw new Error(
|
|
153
|
+
throw new Error(
|
|
154
|
+
typeof event.errorMessage === 'string' ? event.errorMessage : 'llm error',
|
|
155
|
+
)
|
|
185
156
|
}
|
|
186
157
|
}
|
|
187
158
|
|
|
@@ -199,7 +170,7 @@ async function generateFlowJson(args: {
|
|
|
199
170
|
if (buffer.trim()) handleLine(buffer)
|
|
200
171
|
|
|
201
172
|
const text = deltas.join('').trim()
|
|
202
|
-
if (!text) throw new Error('llm returned an empty
|
|
173
|
+
if (!text) throw new Error('llm returned an empty next step')
|
|
203
174
|
return text
|
|
204
175
|
}
|
|
205
176
|
|
|
@@ -211,26 +182,217 @@ function extractJson(text: string): unknown {
|
|
|
211
182
|
return JSON.parse(candidate)
|
|
212
183
|
}
|
|
213
184
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
185
|
+
interface GeneratedObservation {
|
|
186
|
+
testID?: string
|
|
187
|
+
text?: string
|
|
188
|
+
label?: string
|
|
189
|
+
role?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type ObservedSelector = { id?: string; text?: string; index?: number }
|
|
193
|
+
|
|
194
|
+
function parseObservedSelector(
|
|
195
|
+
value: unknown,
|
|
196
|
+
elements: GeneratedObservation[],
|
|
197
|
+
allowTimeout = false,
|
|
198
|
+
): ObservedSelector & { timeout?: number } {
|
|
199
|
+
const raw = typeof value === 'string' ? { text: value } : value
|
|
200
|
+
if (!isRecord(raw)) throw new Error('generated selector must name observed text or id')
|
|
201
|
+
const allowed = allowTimeout ? ['id', 'text', 'timeout'] : ['id', 'text', 'index']
|
|
202
|
+
const unsupported = Object.keys(raw).filter((key) => !allowed.includes(key))
|
|
203
|
+
if (unsupported.length > 0) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`generated selector contains unsupported fields: ${unsupported.join(', ')}; use observed id or text`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
const id = raw.id
|
|
209
|
+
const text = raw.text
|
|
210
|
+
if (
|
|
211
|
+
(id !== undefined && typeof id !== 'string') ||
|
|
212
|
+
(text !== undefined && typeof text !== 'string') ||
|
|
213
|
+
(!id && !text)
|
|
214
|
+
)
|
|
215
|
+
throw new Error('generated selector must name observed text or id')
|
|
216
|
+
const matches = elements.filter(
|
|
217
|
+
(node) =>
|
|
218
|
+
(id === undefined || node.testID === id) &&
|
|
219
|
+
(text === undefined || node.text === text || node.label === text),
|
|
220
|
+
)
|
|
221
|
+
if (matches.length === 0) {
|
|
222
|
+
throw new Error(`generated selector was never observed: ${JSON.stringify(raw)}`)
|
|
223
|
+
}
|
|
224
|
+
const selector: ObservedSelector & { timeout?: number } = {}
|
|
225
|
+
// id selectors are regexes in Maestro, so quote the observed literal id.
|
|
226
|
+
if (typeof id === 'string') selector.id = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
227
|
+
if (typeof text === 'string') selector.text = text
|
|
228
|
+
if (raw.index !== undefined) {
|
|
229
|
+
if (
|
|
230
|
+
typeof raw.index !== 'number' ||
|
|
231
|
+
!Number.isInteger(raw.index) ||
|
|
232
|
+
raw.index < 0 ||
|
|
233
|
+
raw.index >= matches.length
|
|
234
|
+
) {
|
|
235
|
+
throw new Error('generated selector index is outside observed elements')
|
|
236
|
+
}
|
|
237
|
+
selector.index = raw.index
|
|
238
|
+
}
|
|
239
|
+
if (raw.timeout !== undefined) {
|
|
240
|
+
if (
|
|
241
|
+
typeof raw.timeout !== 'number' ||
|
|
242
|
+
!Number.isFinite(raw.timeout) ||
|
|
243
|
+
raw.timeout <= 0
|
|
244
|
+
) {
|
|
245
|
+
throw new Error('generated wait timeout must be positive')
|
|
246
|
+
}
|
|
247
|
+
selector.timeout = raw.timeout
|
|
248
|
+
}
|
|
249
|
+
return selector
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseGeneratedStep(
|
|
253
|
+
value: unknown,
|
|
254
|
+
current: GeneratedObservation[],
|
|
255
|
+
observed: GeneratedObservation[],
|
|
256
|
+
): MaestroStep {
|
|
257
|
+
if (!isRecord(value) || Object.keys(value).length !== 1) {
|
|
258
|
+
throw new Error('llm must return exactly one flow command')
|
|
259
|
+
}
|
|
260
|
+
const [command] = Object.keys(value)
|
|
261
|
+
const target = value[command]
|
|
262
|
+
switch (command) {
|
|
263
|
+
case 'tapOn':
|
|
264
|
+
return { tapOn: parseObservedSelector(target, current) }
|
|
265
|
+
case 'assertVisible':
|
|
266
|
+
return { assertVisible: parseObservedSelector(target, current) }
|
|
267
|
+
case 'assertNotVisible':
|
|
268
|
+
return { assertNotVisible: parseObservedSelector(target, observed) }
|
|
269
|
+
case 'waitFor':
|
|
270
|
+
return { waitFor: parseObservedSelector(target, current, true) }
|
|
271
|
+
case 'inputText':
|
|
272
|
+
case 'pressKey':
|
|
273
|
+
case 'takeScreenshot':
|
|
274
|
+
if (typeof target !== 'string') throw new Error(`${command} requires a string`)
|
|
275
|
+
return { [command]: target }
|
|
276
|
+
case 'hideKeyboard':
|
|
277
|
+
case 'waitForAnimationToEnd':
|
|
278
|
+
if (target !== true) throw new Error(`${command} requires true`)
|
|
279
|
+
return { [command]: true }
|
|
280
|
+
case 'swipe': {
|
|
281
|
+
if (
|
|
282
|
+
!isRecord(target) ||
|
|
283
|
+
Object.keys(target).some((key) => !['direction', 'duration'].includes(key))
|
|
284
|
+
) {
|
|
285
|
+
throw new Error('generated swipe requires a direction and optional duration')
|
|
286
|
+
}
|
|
287
|
+
const direction = target.direction
|
|
288
|
+
if (
|
|
289
|
+
typeof direction !== 'string' ||
|
|
290
|
+
!['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(direction)
|
|
291
|
+
) {
|
|
292
|
+
throw new Error('generated swipe direction is invalid')
|
|
293
|
+
}
|
|
294
|
+
const duration = target.duration
|
|
295
|
+
if (
|
|
296
|
+
duration !== undefined &&
|
|
297
|
+
(typeof duration !== 'number' || !Number.isFinite(duration) || duration <= 0)
|
|
298
|
+
) {
|
|
299
|
+
throw new Error('generated swipe duration must be positive')
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
swipe: { direction, ...(typeof duration === 'number' ? { duration } : {}) },
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
throw new Error(`unsupported generated flow command: ${command}`)
|
|
228
307
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// every proposal is checked against a new tree before execution or persistence.
|
|
311
|
+
// transport and execution failures leave the last grounded step in the flow file.
|
|
312
|
+
export async function generateObservedFlow(options: {
|
|
313
|
+
driver: Pick<SootSimBridgeFlowRunner, 'bridge' | 'simId' | 'runStep' | 'planFlow'>
|
|
314
|
+
prompt: string
|
|
315
|
+
origin: string
|
|
316
|
+
authHeader: string
|
|
317
|
+
flowPath: string
|
|
318
|
+
steps: MaestroStep[]
|
|
319
|
+
}): Promise<string> {
|
|
320
|
+
const { driver, steps, flowPath } = options
|
|
321
|
+
steps.length = 0
|
|
322
|
+
const observed: GeneratedObservation[] = []
|
|
323
|
+
let summary = 'peach generated test'
|
|
324
|
+
for (let index = 0; index < MAX_GENERATION_STEPS; index++) {
|
|
325
|
+
// listInspectable is the visible snapshot behind describe. read testID
|
|
326
|
+
// directly: the semantic tree also aliases nativeID as testID, while a
|
|
327
|
+
// Maestro id selector only matches the real testID.
|
|
328
|
+
const result: unknown = await driver.bridge.send({
|
|
329
|
+
type: 'call',
|
|
330
|
+
simId: driver.simId,
|
|
331
|
+
path: '__sootsimTest.listInspectable',
|
|
332
|
+
args: [{}],
|
|
333
|
+
})
|
|
334
|
+
if (!Array.isArray(result))
|
|
335
|
+
throw new Error('simulator returned no valid observed elements')
|
|
336
|
+
const current: GeneratedObservation[] = result.map((node: unknown) => {
|
|
337
|
+
if (!isRecord(node))
|
|
338
|
+
throw new Error('simulator returned an invalid observed element')
|
|
339
|
+
const entry: GeneratedObservation = {}
|
|
340
|
+
const fields = {
|
|
341
|
+
testID: node.testID,
|
|
342
|
+
text: node.text,
|
|
343
|
+
label: node.accessibilityLabel ?? node.label,
|
|
344
|
+
role: node.role,
|
|
345
|
+
}
|
|
346
|
+
for (const key of [
|
|
347
|
+
'testID',
|
|
348
|
+
'text',
|
|
349
|
+
'label',
|
|
350
|
+
'role',
|
|
351
|
+
] satisfies (keyof GeneratedObservation)[]) {
|
|
352
|
+
const value = fields[key]
|
|
353
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
354
|
+
throw new Error(`simulator returned an invalid observed ${key}`)
|
|
355
|
+
}
|
|
356
|
+
if (typeof value === 'string' && value) entry[key] = value
|
|
357
|
+
}
|
|
358
|
+
return entry
|
|
359
|
+
})
|
|
360
|
+
observed.push(...current)
|
|
361
|
+
const catalog: string[] = []
|
|
362
|
+
let contextSize = 2
|
|
363
|
+
for (const { testID, text, label, role } of current) {
|
|
364
|
+
const item = JSON.stringify({ id: testID, text: label ?? text, role })
|
|
365
|
+
if (contextSize + item.length + 1 > DESCRIBE_CONTEXT_LIMIT) break
|
|
366
|
+
catalog.push(item)
|
|
367
|
+
contextSize += item.length + 1
|
|
368
|
+
}
|
|
369
|
+
const describe = `[${catalog.join(',')}]`
|
|
370
|
+
const proposal = extractJson(await generateFlowJson({ ...options, describe }))
|
|
371
|
+
if (!isRecord(proposal) || typeof proposal.done !== 'boolean') {
|
|
372
|
+
throw new Error('llm must return a next step and a done boolean')
|
|
373
|
+
}
|
|
374
|
+
if (typeof proposal.summary === 'string' && proposal.summary.trim()) {
|
|
375
|
+
summary = proposal.summary.trim().slice(0, 500)
|
|
376
|
+
}
|
|
377
|
+
if (proposal.step === null && proposal.done) {
|
|
378
|
+
const last = steps.at(-1)
|
|
379
|
+
if (!last?.assertVisible && !last?.assertNotVisible)
|
|
380
|
+
throw new Error('generated flow finished without a goal assertion')
|
|
381
|
+
return summary
|
|
382
|
+
}
|
|
383
|
+
const step = parseGeneratedStep(proposal.step, current, observed)
|
|
384
|
+
if (proposal.done && !step.assertVisible && !step.assertNotVisible) {
|
|
385
|
+
throw new Error('generated flow must finish with a goal assertion')
|
|
386
|
+
}
|
|
387
|
+
steps.push(step)
|
|
388
|
+
writeFileSync(flowPath, yaml.stringify(steps))
|
|
389
|
+
await driver.planFlow(steps)
|
|
390
|
+
await driver.runStep(step, index)
|
|
391
|
+
if (proposal.done) return summary
|
|
392
|
+
}
|
|
393
|
+
throw new Error(
|
|
394
|
+
`generation exceeded ${MAX_GENERATION_STEPS} steps before proving the goal`,
|
|
395
|
+
)
|
|
234
396
|
}
|
|
235
397
|
|
|
236
398
|
export function buildFlowArgs(
|
|
@@ -323,19 +485,45 @@ export async function runMaestroGenerate(args: string[]): Promise<number> {
|
|
|
323
485
|
const origin = await resolveDefaultUploadOrigin(
|
|
324
486
|
flagValueAny(args, ['--origin', '--preview-origin']),
|
|
325
487
|
)
|
|
326
|
-
console.log(' inspecting current sim…')
|
|
327
|
-
const describe = await describeCurrentSim(args, promptIndex)
|
|
328
|
-
console.log(' generating Maestro flow…')
|
|
329
|
-
const generated = await generateFlowJson({ prompt, describe, origin, authHeader })
|
|
330
|
-
const flow = parseGeneratedFlow(generated)
|
|
331
488
|
const tempDir = mkdtempSync(join(tmpdir(), 'peach-maestro-generate-'))
|
|
332
489
|
const flowPath = join(tempDir, 'flow.yaml')
|
|
333
|
-
|
|
490
|
+
// the runner establishes the target, cold preview state, and recording first.
|
|
491
|
+
// this placeholder is replaced before any authored step executes.
|
|
492
|
+
writeFileSync(flowPath, yaml.stringify([{ waitForAnimationToEnd: true }]))
|
|
334
493
|
console.log(` flow: ${flowPath}`)
|
|
494
|
+
console.log(' generating Maestro flow from live observations…')
|
|
335
495
|
|
|
336
496
|
const startedAt = Date.now()
|
|
497
|
+
let summary = 'peach generated test'
|
|
498
|
+
let generationError: string | null = null
|
|
337
499
|
const flowArgs = buildFlowArgs(args, promptIndex, flowPath, origin)
|
|
338
|
-
|
|
500
|
+
let exitCode: number
|
|
501
|
+
try {
|
|
502
|
+
exitCode = await runFlowPlayback(flowArgs, {
|
|
503
|
+
runSteps: async (driver, steps) => {
|
|
504
|
+
writeFileSync(flowPath, '[]\n')
|
|
505
|
+
try {
|
|
506
|
+
summary = await generateObservedFlow({
|
|
507
|
+
driver,
|
|
508
|
+
steps,
|
|
509
|
+
prompt,
|
|
510
|
+
origin,
|
|
511
|
+
authHeader,
|
|
512
|
+
flowPath,
|
|
513
|
+
})
|
|
514
|
+
} catch (error) {
|
|
515
|
+
generationError = error instanceof Error ? error.message : String(error)
|
|
516
|
+
throw error
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
})
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.error(
|
|
522
|
+
` generation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
523
|
+
)
|
|
524
|
+
console.error(` partial flow: ${flowPath}`)
|
|
525
|
+
return 1
|
|
526
|
+
}
|
|
339
527
|
const upload = getLastFlowPreviewUploadResult()
|
|
340
528
|
const traceSteps = getLastFlowTraceSteps()
|
|
341
529
|
const failedStep = traceSteps.find((step) => step.status === 'failure')
|
|
@@ -344,19 +532,25 @@ export async function runMaestroGenerate(args: string[]): Promise<number> {
|
|
|
344
532
|
origin,
|
|
345
533
|
kind: 'maestro',
|
|
346
534
|
prompt,
|
|
347
|
-
summary
|
|
535
|
+
summary,
|
|
348
536
|
status,
|
|
349
537
|
failureMessage:
|
|
350
|
-
status === 'failed'
|
|
538
|
+
status === 'failed'
|
|
539
|
+
? (generationError ?? failedStep?.error ?? 'flow playback failed')
|
|
540
|
+
: null,
|
|
351
541
|
previewShareId: upload?.shareId ?? null,
|
|
352
542
|
owner: flagValue(args, '--owner') ?? null,
|
|
353
543
|
repo: flagValue(args, '--repo') ?? null,
|
|
354
544
|
durationMs: Date.now() - startedAt,
|
|
355
|
-
flowYamlSizeBytes: Buffer.byteLength(
|
|
545
|
+
flowYamlSizeBytes: Buffer.byteLength(readFileSync(flowPath, 'utf8'), 'utf8'),
|
|
356
546
|
...stepSummaryFromTrace(traceSteps),
|
|
357
547
|
auth: cliAuth,
|
|
358
548
|
})
|
|
359
549
|
|
|
550
|
+
if (exitCode !== 0)
|
|
551
|
+
console.error(
|
|
552
|
+
` partial flow: ${flowPath}${generationError ? ` (${generationError})` : ''}`,
|
|
553
|
+
)
|
|
360
554
|
console.log(`\n result: ${status}`)
|
|
361
555
|
if (run) console.log(` run: ${run.id}`)
|
|
362
556
|
if (upload?.previewUrl) console.log(` preview: ${upload.previewUrl}`)
|