peachsim 0.1.920 → 0.1.922
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 +6 -1
- package/cli/commands/compat.ts +51 -9
- package/cli/commands/daemon.ts +35 -14
- package/cli/commands/flow.ts +7 -2
- package/cli/commands/maestro-generate.ts +292 -98
- package/cli/commands/maestro.ts +51 -29
- package/cli/commands/report-issue.ts +32 -16
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/support.ts +3 -3
- package/cli/main.ts +5 -0
- package/cli/runtime-compat.ts +127 -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 +16 -1
- 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-test/SKILL.md +8 -1
|
@@ -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}`)
|
package/cli/commands/maestro.ts
CHANGED
|
@@ -55,10 +55,10 @@ function printHelp() {
|
|
|
55
55
|
peach maestro — author and run Maestro YAML flows against peach
|
|
56
56
|
|
|
57
57
|
usage:
|
|
58
|
-
peach maestro # discover .maestro/ or maestro/
|
|
58
|
+
peach maestro # discover .maestro/ or maestro/ workspace
|
|
59
59
|
peach maestro test <flow-or-dir> # mirrors "maestro test"
|
|
60
60
|
peach maestro load <flow> # load flow into runner without running (--dry-run)
|
|
61
|
-
peach maestro test .maestro/ #
|
|
61
|
+
peach maestro test .maestro/ # workspace globs, root flows by default
|
|
62
62
|
peach maestro generate "<goal>" # generate, run, and upload a Maestro flow
|
|
63
63
|
peach maestro start|keep|end # author a flow from live CLI actions
|
|
64
64
|
peach maestro validate <flow> # validate a Maestro YAML file
|
|
@@ -78,8 +78,9 @@ options:
|
|
|
78
78
|
JUNIT, NOOP also accepted (case-insensitive)
|
|
79
79
|
--output <file> junit report path (needs --format junit)
|
|
80
80
|
--test-output-dir <dir>
|
|
81
|
-
per-flow screenshots, commands.json, and logs
|
|
82
|
-
(config key testOutputDir:; CLI wins
|
|
81
|
+
per-flow screenshots, commands.json, and logs; stores
|
|
82
|
+
result JSON here (config key testOutputDir:; CLI wins;
|
|
83
|
+
default result dir is --out or /tmp/peach-recordings)
|
|
83
84
|
--debug-output <dir> write a session maestro.log there
|
|
84
85
|
--flatten-debug-output accepted; peach output is already flat
|
|
85
86
|
--test-suite-name <n> junit testsuite name (default "Test Suite")
|
|
@@ -434,7 +435,14 @@ export async function runMaestro(
|
|
|
434
435
|
console.error(` error: no flows in ${target} match the requested tags`)
|
|
435
436
|
return 1
|
|
436
437
|
}
|
|
437
|
-
console.error(` error: no *.yaml or *.yml
|
|
438
|
+
console.error(` error: no selected *.yaml or *.yml flows in ${target}`)
|
|
439
|
+
console.error(
|
|
440
|
+
' Maestro selects root flows by default. Pass the folder containing your flows,',
|
|
441
|
+
)
|
|
442
|
+
console.error(' or add config.yaml with flows: ["flows/**"] to select nested flows.')
|
|
443
|
+
console.error(
|
|
444
|
+
' Keep shared subflows outside the selected globs; runFlow can still load them.',
|
|
445
|
+
)
|
|
438
446
|
return 1
|
|
439
447
|
}
|
|
440
448
|
|
|
@@ -446,6 +454,30 @@ export async function runMaestro(
|
|
|
446
454
|
: workspaceConfig.testOutputDir && workspaceConfigPath
|
|
447
455
|
? path.resolve(path.dirname(workspaceConfigPath), workspaceConfig.testOutputDir)
|
|
448
456
|
: null
|
|
457
|
+
const outIndex = forwardArgs.indexOf('--out')
|
|
458
|
+
const resultOutputRoot =
|
|
459
|
+
testOutputRoot ??
|
|
460
|
+
path.resolve(cwd, outIndex >= 0 ? forwardArgs[outIndex + 1] : '/tmp/peach-recordings')
|
|
461
|
+
let wroteResultFiles = false
|
|
462
|
+
const writeResultFiles = (
|
|
463
|
+
kind: 'preview' | 'test',
|
|
464
|
+
flowIndex: number,
|
|
465
|
+
payload: Record<string, unknown>,
|
|
466
|
+
): boolean => {
|
|
467
|
+
try {
|
|
468
|
+
fs.mkdirSync(resultOutputRoot, { recursive: true })
|
|
469
|
+
for (const name of cloudTestResultFileNames(kind, flowIndex, files.length)) {
|
|
470
|
+
fs.writeFileSync(path.join(resultOutputRoot, name), JSON.stringify(payload))
|
|
471
|
+
wroteResultFiles = true
|
|
472
|
+
}
|
|
473
|
+
return true
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.error(
|
|
476
|
+
` error: could not write Maestro results in ${resultOutputRoot}: ${err instanceof Error ? err.message : String(err)}`,
|
|
477
|
+
)
|
|
478
|
+
return false
|
|
479
|
+
}
|
|
480
|
+
}
|
|
449
481
|
|
|
450
482
|
console.log(`
|
|
451
483
|
peach maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
|
|
@@ -569,10 +601,9 @@ export async function runMaestro(
|
|
|
569
601
|
}
|
|
570
602
|
|
|
571
603
|
const upload = getLastFlowPreviewUploadResult()
|
|
572
|
-
// only --preview uploads, so only it leaves receipts
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
// entries.
|
|
604
|
+
// only --preview uploads, so only it leaves preview receipts. they are the
|
|
605
|
+
// per-flow manifest: written with null ids when the upload failed, so the
|
|
606
|
+
// flow never disappears from the collected entries.
|
|
576
607
|
if (args.includes('--preview')) {
|
|
577
608
|
const uploaded = Boolean(upload?.shareId || upload?.previewUrl)
|
|
578
609
|
const previewPayload = {
|
|
@@ -591,11 +622,10 @@ export async function runMaestro(
|
|
|
591
622
|
: `recording upload failed: ${getLastFlowPreviewUploadError() ?? 'no preview returned'}`,
|
|
592
623
|
).body.split('\n')[0],
|
|
593
624
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
} catch {}
|
|
625
|
+
if (!writeResultFiles('preview', flowIndex, previewPayload)) {
|
|
626
|
+
exit = 1
|
|
627
|
+
worstExit = 1
|
|
628
|
+
}
|
|
599
629
|
if (!uploaded) {
|
|
600
630
|
console.error(' error: required hosted recording upload failed')
|
|
601
631
|
if (exit === 0) exit = 1
|
|
@@ -620,16 +650,6 @@ export async function runMaestro(
|
|
|
620
650
|
})
|
|
621
651
|
if (run) {
|
|
622
652
|
console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`)
|
|
623
|
-
try {
|
|
624
|
-
fs.writeFileSync(
|
|
625
|
-
path.join(cwd, 'test-result.json'),
|
|
626
|
-
JSON.stringify({
|
|
627
|
-
runId: run.id,
|
|
628
|
-
previewUrl: run.previewUrl,
|
|
629
|
-
traceUrl: run.traceUrl,
|
|
630
|
-
}),
|
|
631
|
-
)
|
|
632
|
-
} catch {}
|
|
633
653
|
} else if (process.env.PEACH_REQUIRE_REGISTRATION === 'true') {
|
|
634
654
|
console.error(' error: required hosted run registration failed')
|
|
635
655
|
if (exit === 0) exit = 1
|
|
@@ -643,11 +663,10 @@ export async function runMaestro(
|
|
|
643
663
|
previewUrl: run?.previewUrl ?? null,
|
|
644
664
|
traceUrl: run?.traceUrl ?? null,
|
|
645
665
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
} catch {}
|
|
666
|
+
if (!writeResultFiles('test', flowIndex, testPayload)) {
|
|
667
|
+
exit = 1
|
|
668
|
+
worstExit = 1
|
|
669
|
+
}
|
|
651
670
|
}
|
|
652
671
|
// artifact, upload, and registration failures above flip the local exit
|
|
653
672
|
// after the result was pushed; the report below must see the final one.
|
|
@@ -710,6 +729,9 @@ export async function runMaestro(
|
|
|
710
729
|
if (worstExit === 0) worstExit = 1
|
|
711
730
|
}
|
|
712
731
|
}
|
|
732
|
+
if (wroteResultFiles) {
|
|
733
|
+
console.log(` results: ${path.relative(cwd, resultOutputRoot) || resultOutputRoot}`)
|
|
734
|
+
}
|
|
713
735
|
|
|
714
736
|
return worstExit
|
|
715
737
|
}
|