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.
Files changed (88) hide show
  1. package/README.md +6 -1
  2. package/cli/commands/compat.ts +51 -9
  3. package/cli/commands/daemon.ts +35 -14
  4. package/cli/commands/flow.ts +7 -2
  5. package/cli/commands/maestro-generate.ts +292 -98
  6. package/cli/commands/maestro.ts +51 -29
  7. package/cli/commands/report-issue.ts +32 -16
  8. package/cli/commands/skills.ts +1 -1
  9. package/cli/commands/support.ts +3 -3
  10. package/cli/main.ts +5 -0
  11. package/cli/runtime-compat.ts +127 -0
  12. package/dist-lib/agent-daemon-client.cjs +1 -1
  13. package/dist-lib/agent-events.cjs +1 -1
  14. package/dist-lib/agent-identity.cjs +1 -1
  15. package/dist-lib/agent-sessions.cjs +1 -1
  16. package/dist-lib/attached-projects.cjs +1 -1
  17. package/dist-lib/auth/shared-session.cjs +1 -1
  18. package/dist-lib/backend-origin.cjs +1 -1
  19. package/dist-lib/beta.cjs +1 -1
  20. package/dist-lib/beta.mjs +1 -1
  21. package/dist-lib/bridge-constants.cjs +1 -1
  22. package/dist-lib/bridge-contract-input.cjs +1 -1
  23. package/dist-lib/bridge-contract-input.mjs +1 -1
  24. package/dist-lib/bridge-contract.cjs +1 -1
  25. package/dist-lib/bridge-contract.mjs +1 -1
  26. package/dist-lib/bundle-channel.cjs +1 -1
  27. package/dist-lib/capture-contract.cjs +1 -1
  28. package/dist-lib/capture-contract.mjs +1 -1
  29. package/dist-lib/cli-constants.cjs +1 -1
  30. package/dist-lib/cloud-contract.cjs +1 -1
  31. package/dist-lib/cloud-contract.mjs +1 -1
  32. package/dist-lib/cloud-test-args.cjs +1 -1
  33. package/dist-lib/cloud-test-args.mjs +1 -1
  34. package/dist-lib/cloud.cjs +1 -1
  35. package/dist-lib/cloud.mjs +1 -1
  36. package/dist-lib/config.cjs +1 -1
  37. package/dist-lib/detox/index.cjs +1 -1
  38. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  39. package/dist-lib/expo-swift-bundle.cjs +1 -1
  40. package/dist-lib/expo-swift-metadata.cjs +1 -1
  41. package/dist-lib/expo-swift-metadata.mjs +1 -1
  42. package/dist-lib/home-paths.cjs +1 -1
  43. package/dist-lib/host/bridge-host.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  46. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  47. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  48. package/dist-lib/host/websocket-proxy.cjs +1 -1
  49. package/dist-lib/index.cjs +1 -1
  50. package/dist-lib/jump-to-source-babel.cjs +1 -1
  51. package/dist-lib/jump-to-source-native.cjs +1 -1
  52. package/dist-lib/kotlin-host.cjs +1 -1
  53. package/dist-lib/menu.cjs +1 -1
  54. package/dist-lib/menu.mjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  56. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  57. package/dist-lib/metro-production-bundle.cjs +1 -1
  58. package/dist-lib/metro-production-bundle.mjs +1 -1
  59. package/dist-lib/metro.cjs +1 -1
  60. package/dist-lib/native-app-identity.cjs +1 -1
  61. package/dist-lib/native-app-identity.mjs +1 -1
  62. package/dist-lib/native-firebase-metadata.cjs +1 -1
  63. package/dist-lib/native-firebase-metadata.mjs +1 -1
  64. package/dist-lib/plist.cjs +1 -1
  65. package/dist-lib/plist.mjs +1 -1
  66. package/dist-lib/profiles.cjs +1 -1
  67. package/dist-lib/public-brand.cjs +1 -1
  68. package/dist-lib/react-native-host-modules.cjs +1 -1
  69. package/dist-lib/react-native-host-modules.mjs +1 -1
  70. package/dist-lib/render-mode.cjs +1 -1
  71. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  72. package/dist-lib/sdk.cjs +1 -1
  73. package/dist-lib/sdk.mjs +1 -1
  74. package/dist-lib/skills.cjs +16 -1
  75. package/dist-lib/swift-compile.cjs +1 -1
  76. package/dist-lib/swift-compile.mjs +1 -1
  77. package/dist-lib/swift-package.cjs +1 -1
  78. package/dist-lib/swift-package.mjs +1 -1
  79. package/dist-lib/swift-submit.cjs +1 -1
  80. package/dist-lib/swift-submit.mjs +1 -1
  81. package/dist-lib/swift-toolchain.cjs +1 -1
  82. package/dist-lib/swift.cjs +1 -1
  83. package/dist-lib/vite-swift.cjs +1 -1
  84. package/dist-lib/vite.cjs +1 -1
  85. package/dist-lib/worklet-executable.cjs +1 -1
  86. package/dist-lib/worklet-executable.mjs +1 -1
  87. package/package.json +1 -1
  88. 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
- // generate is a front-end for runFlowPlayback: it inspects the sim, asks the
41
- // model for a flow, then hands the argv down. these are the only flags it reads
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
- function stripPromptForBridge(args: string[], promptIndex: number): string[] {
65
- return args.filter((_, index) => index !== promptIndex)
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 human-readable result goal","steps":[{"waitFor":{"text":"...","timeout":10000}}]}
62
+ {"summary":"short result goal","step":{"tapOn":{"id":"observed-id"}},"done":false}
103
63
 
104
- Use only these flow commands:
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
- Prefer ids/testIDs from the tree. Prefer visible text when no id exists.
117
- Do not invent app-specific data that is not visible. Keep the flow short.
118
- End with an assertion or wait that proves the user's goal.
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(prompt: string, describe: string): string {
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
- Current peach visible tree:
127
- ${describe || '[no tree available]'}
94
+ Successfully executed steps:
95
+ ${yaml.stringify(steps)}
96
+
97
+ Current peach observed selectors (id, text and role):
98
+ ${describe}
128
99
 
129
- Generate the shortest replayable flow that verifies the goal from this current state.
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: RNX_TEST_SYSTEM_PROMPT,
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: 1800, temperature: 0.2 },
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) as {
177
- type?: string
178
- delta?: string
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(event.errorMessage || 'llm 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 test flow')
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
- function parseGeneratedFlow(text: string): {
215
- summary: string
216
- steps: Record<string, unknown>[]
217
- yamlText: string
218
- } {
219
- const parsed = extractJson(text)
220
- if (!isRecord(parsed)) throw new Error('llm did not return a JSON object')
221
- const summary =
222
- typeof parsed.summary === 'string' && parsed.summary.trim()
223
- ? parsed.summary.trim().slice(0, 500)
224
- : 'peach generated test'
225
- const rawSteps = parsed.steps
226
- if (!Array.isArray(rawSteps) || rawSteps.length === 0) {
227
- throw new Error('llm returned no flow steps')
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
- const steps = rawSteps.map((step, index) => {
230
- if (!isRecord(step)) throw new Error(`flow step ${index + 1} is not an object`)
231
- return step
232
- })
233
- return { summary, steps, yamlText: yaml.stringify(steps) }
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
- writeFileSync(flowPath, flow.yamlText)
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
- const exitCode = await runFlowPlayback(flowArgs)
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: flow.summary,
535
+ summary,
348
536
  status,
349
537
  failureMessage:
350
- status === 'failed' ? (failedStep?.error ?? 'flow playback failed') : null,
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(flow.yamlText, 'utf8'),
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}`)
@@ -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/ in cwd, run all
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/ # every *.yaml / *.yml in a directory
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/maestro.log
82
- (config key testOutputDir:; CLI wins over config)
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 files found in ${target}`)
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 in the working
573
- // directory. they are the per-flow manifest: written with null ids when
574
- // the upload failed, so the flow never disappears from the collected
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
- try {
595
- for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
596
- fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
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
- try {
647
- for (const name of cloudTestResultFileNames('test', flowIndex, files.length)) {
648
- fs.writeFileSync(path.join(cwd, name), JSON.stringify(testPayload))
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
  }