peachsim 0.1.629 → 0.1.631

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 (128) hide show
  1. package/README.md +8 -4
  2. package/cli/app-fonts.ts +23 -4
  3. package/cli/bridge-flow-runner.ts +45 -31
  4. package/cli/cli-update.ts +2 -7
  5. package/cli/cloud-test-tunnel.ts +16 -3
  6. package/cli/command-registry.ts +1 -0
  7. package/cli/commands/compat.ts +11 -60
  8. package/cli/commands/config.ts +1 -1
  9. package/cli/commands/control.ts +40 -8
  10. package/cli/commands/daemon-mac-app.ts +4 -2
  11. package/cli/commands/daemon.ts +4 -4
  12. package/cli/commands/detect.ts +289 -26
  13. package/cli/commands/device.ts +2 -3
  14. package/cli/commands/inspect/actions.ts +125 -90
  15. package/cli/commands/inspect/core.ts +1 -1
  16. package/cli/commands/inspect.ts +10 -1
  17. package/cli/commands/maestro.ts +3 -1
  18. package/cli/commands/platform.ts +1 -1
  19. package/cli/commands/project-report.ts +386 -0
  20. package/cli/commands/projectReportContract.ts +42 -0
  21. package/cli/commands/report-issue.ts +38 -54
  22. package/cli/commands/screenshot-layers.ts +1 -1
  23. package/cli/commands/screenshot.ts +1 -1
  24. package/cli/commands/setup.ts +6 -4
  25. package/cli/commands/skills.ts +0 -31
  26. package/cli/commands/telemetry.ts +8 -3
  27. package/cli/commands/test.ts +347 -134
  28. package/cli/drivers/playwright-provisioning.ts +3 -2
  29. package/cli/drivers/playwright-sim-host.ts +25 -9
  30. package/cli/main.ts +19 -3
  31. package/cli/outbound-endpoints.ts +2 -5
  32. package/cli/privacy.ts +1 -2
  33. package/cli/runtime-diagnostics.ts +51 -0
  34. package/cli/telemetry.ts +78 -7
  35. package/detox/index.ts +77 -51
  36. package/dist-lib/agent-daemon-client.cjs +1 -1
  37. package/dist-lib/agent-events.cjs +1 -1
  38. package/dist-lib/agent-identity.cjs +1 -1
  39. package/dist-lib/agent-sessions.cjs +1 -1
  40. package/dist-lib/attached-projects.cjs +1 -1
  41. package/dist-lib/auth/shared-session.cjs +1 -1
  42. package/dist-lib/backend-origin.cjs +1 -1
  43. package/dist-lib/beta.cjs +1 -1
  44. package/dist-lib/beta.mjs +1 -1
  45. package/dist-lib/bridge-constants.cjs +1 -1
  46. package/dist-lib/bridge-contract-input.cjs +1 -1
  47. package/dist-lib/bridge-contract-input.mjs +1 -1
  48. package/dist-lib/bridge-contract.cjs +1 -1
  49. package/dist-lib/bridge-contract.mjs +1 -1
  50. package/dist-lib/capture-contract.cjs +1 -1
  51. package/dist-lib/capture-contract.mjs +1 -1
  52. package/dist-lib/cli-constants.cjs +1 -1
  53. package/dist-lib/cloud-contract.cjs +1 -1
  54. package/dist-lib/cloud-contract.mjs +1 -1
  55. package/dist-lib/cloud-test-args.cjs +1 -1
  56. package/dist-lib/cloud-test-args.mjs +1 -1
  57. package/dist-lib/cloud.cjs +1 -1
  58. package/dist-lib/cloud.mjs +1 -1
  59. package/dist-lib/config.cjs +1 -1
  60. package/dist-lib/detox/index.cjs +130 -33
  61. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  62. package/dist-lib/home-paths.cjs +8 -5
  63. package/dist-lib/host/bridge-host.cjs +353 -290
  64. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  65. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  66. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  67. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  68. package/dist-lib/host/websocket-proxy.cjs +1 -1
  69. package/dist-lib/index.cjs +257 -121
  70. package/dist-lib/jump-to-source-babel.cjs +1 -1
  71. package/dist-lib/jump-to-source-native.cjs +1 -1
  72. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  73. package/dist-lib/menu.cjs +11 -1
  74. package/dist-lib/menu.mjs +11 -1
  75. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  76. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  77. package/dist-lib/metro-production-bundle.cjs +1 -1
  78. package/dist-lib/metro-production-bundle.mjs +1 -1
  79. package/dist-lib/metro.cjs +132 -9
  80. package/dist-lib/native-app-identity.cjs +278 -0
  81. package/dist-lib/native-app-identity.mjs +245 -0
  82. package/dist-lib/profiles.cjs +1 -1
  83. package/dist-lib/public-brand.cjs +1 -1
  84. package/dist-lib/react-native-host-modules.cjs +1 -1
  85. package/dist-lib/react-native-host-modules.mjs +1 -1
  86. package/dist-lib/render-mode.cjs +1 -1
  87. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  88. package/dist-lib/sdk.cjs +112 -87
  89. package/dist-lib/sdk.mjs +111 -87
  90. package/dist-lib/skills.cjs +1270 -709
  91. package/dist-lib/swift-compile.cjs +1 -1
  92. package/dist-lib/swift-compile.mjs +1 -1
  93. package/dist-lib/swift-package.cjs +1 -1
  94. package/dist-lib/swift-package.mjs +1 -1
  95. package/dist-lib/swift-submit.cjs +1 -1
  96. package/dist-lib/swift-submit.mjs +1 -1
  97. package/dist-lib/swift.cjs +672 -275
  98. package/dist-lib/vite-swift.cjs +1 -1
  99. package/dist-lib/vite.cjs +11 -28
  100. package/kotlin/README.md +32 -4
  101. package/kotlin/fixtures/counter/Counter.kt +26 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  103. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  104. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  105. package/kotlin/fork/LICENSE +178 -0
  106. package/kotlin/fork/NOTICE +25 -0
  107. package/kotlin/fork/README.md +59 -0
  108. package/kotlin/fork/build.sh +128 -0
  109. package/kotlin/fork/compose-worker-globals.patch +96 -0
  110. package/kotlin/fork/fork.json +33 -0
  111. package/kotlin/fork/publish.ts +77 -0
  112. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  113. package/kotlin/toolchain.json +288 -0
  114. package/package.json +13 -5
  115. package/skills/contrast/SKILL.md +1 -0
  116. package/src/automatic-update-policy.ts +17 -0
  117. package/src/home-paths.ts +11 -9
  118. package/src/host/bridge-host.ts +3 -1
  119. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  120. package/src/menu.ts +11 -0
  121. package/src/metro-plugin.ts +18 -0
  122. package/src/native-app-identity.ts +376 -0
  123. package/src/native-firebase-config.ts +90 -0
  124. package/src/screenshots/frame-compose.ts +1 -1
  125. package/src/screenshots/registry.ts +1 -1
  126. package/src/screenshots/schema.ts +2 -2
  127. package/src/vite-plugin-one.ts +11 -28
  128. package/src/vite-plugin.ts +6 -1
@@ -17,6 +17,7 @@ import {
17
17
  } from '../../src/home-paths'
18
18
  import { rnxRuntime } from '../../src/runtime-delivery'
19
19
  import { findDesktopCompanion } from '../desktop-companion'
20
+ import { canPromptForPrivacyChoice } from '../privacy'
20
21
  import { confirm, select } from '../prompt'
21
22
  import {
22
23
  applyRepositorySetup,
@@ -342,11 +343,12 @@ async function decidePrivacy(
342
343
  ): Promise<boolean> {
343
344
  if (options.productAnalytics !== undefined) return options.productAnalytics
344
345
  if (current.configured) return current.productAnalytics
345
- if (options.yes || options.ci || !process.stdin.isTTY) return false
346
+ if (options.yes || options.ci || !canPromptForPrivacyChoice()) return false
346
347
  printPromptBlock('Private by default', [
347
- 'Diagnostics stay off unless you opt in.',
348
- 'Reports can contain package compatibility or an error class.',
349
- 'They never contain your code or app data.',
348
+ 'Diagnostics stay off unless you opt in, including CI, pipes and agent runs. DO_NOT_TRACK overrides opt-in.',
349
+ 'Reports include CLI/runtime versions, OS/arch, command name and a random install ID.',
350
+ 'They include package incompatibilities, error classes and first-line error/warning messages (300 characters, paths redacted).',
351
+ 'Reports exclude arguments, stacks, app source and structured app data.',
350
352
  ])
351
353
  return confirm('Share bounded diagnostics to improve peach?', false)
352
354
  }
@@ -286,37 +286,6 @@ function linkSkill(skill: SkillInfo, targetDir: string, force: boolean): LinkRes
286
286
  return 'conflict'
287
287
  }
288
288
 
289
- // quiet, conflict-safe (re)install of all bundled skills into the agent stores
290
- // that already exist on this machine. wired into the CLI build so building or
291
- // updating the CLI re-establishes the symlinks idempotently — never forces, so
292
- // it can't clobber a skill the user customized, and never creates a store for
293
- // an agent that isn't set up here. silent on success.
294
- export function refreshInstalledSkills() {
295
- const skills = listSkills()
296
- if (skills.length === 0) return
297
- for (const target of defaultAgentTargets()) {
298
- const skillsDir = path.resolve(target.dir)
299
- // only touch agents already present (~/.claude, ~/.codex) — don't seed a
300
- // store on a CI runner or a machine that doesn't use that agent.
301
- if (!fs.existsSync(path.dirname(skillsDir))) continue
302
- try {
303
- fs.mkdirSync(skillsDir, { recursive: true })
304
- } catch {
305
- continue
306
- }
307
- try {
308
- removeLegacyBundledSkills(skillsDir)
309
- } catch {}
310
- for (const skill of skills) {
311
- try {
312
- linkSkill(skill, skillsDir, false)
313
- } catch {
314
- // best-effort: a single bad store must not fail the build
315
- }
316
- }
317
- }
318
- }
319
-
320
289
  const LEGACY_BUNDLED_SKILL_DIRS = [
321
290
  'sootsim-setup',
322
291
  'sootsim-debug',
@@ -2,8 +2,7 @@
2
2
  // product analytics preference shown by guided setup and Settings.
3
3
  //
4
4
  // this is the process-wide preference; `DO_NOT_TRACK=1` still forces opt-out
5
- // for a single run regardless of what's stored (honored by isOptedOut in
6
- // telemetry.ts, the same cross-tool signal npm/turbo/supabase use).
5
+ // for a single run regardless of stored consent, including CI and agents.
7
6
 
8
7
  import {
9
8
  configFilePath,
@@ -16,7 +15,13 @@ function printStatus(): void {
16
15
  const preferences = readPrivacyPreferences()
17
16
  const enabled = preferences.configured && preferences.productAnalytics
18
17
  console.log(` bounded diagnostics are ${enabled ? 'ON' : 'OFF'}`)
19
- console.log(' permitted fields: package name/version/unsupported API or error class')
18
+ console.log(' fields: CLI/runtime versions, OS/arch, command name, random install ID')
19
+ console.log(
20
+ ' diagnostics: package name/version/unsupported API; error class and first-line error/warning messages (300 characters, paths redacted)',
21
+ )
22
+ console.log(
23
+ ' explicit opt-in also applies in CI, pipes and agent runs; DO_NOT_TRACK wins',
24
+ )
20
25
  console.log(` config: ${configFilePath()}`)
21
26
  const doNotTrack = process.env.DO_NOT_TRACK
22
27
  if (doNotTrack === '1' || doNotTrack === 'true') {
@@ -455,6 +455,24 @@ function collectFlowHelperPaths(flowAbsPath: string): string[] {
455
455
  return helpers
456
456
  }
457
457
 
458
+ // a synthesized workspace config that selects exactly one flow. bun's glob
459
+ // (node:fs.globSync, which the planner reads) treats * ? [ ] { } as special
460
+ // and has no backslash escape, so each is wrapped in a one-character class; a
461
+ // leading `!` would negate the pattern, so it is anchored under ./.
462
+ function selectionConfig(entryKey: string): string {
463
+ if (entryKey.includes('\\')) {
464
+ throw new Error(
465
+ `cloud test cannot select a flow whose path contains a backslash: ${entryKey}`,
466
+ )
467
+ }
468
+ const escaped = entryKey.replace(/[*?[\]{}]/g, (char) => `[${char}]`)
469
+ const glob = escaped.startsWith('!') ? `./${escaped}` : escaped
470
+ return (
471
+ '# synthesized by peach test --cloud: run exactly the submitted entry flow.\n' +
472
+ `flows:\n - ${JSON.stringify(glob)}\n`
473
+ )
474
+ }
475
+
458
476
  function collectTestFiles(
459
477
  targetPath: string,
460
478
  ignoredPath: string | null,
@@ -522,17 +540,14 @@ function collectTestFiles(
522
540
  // the recorder runs an unpacked multi-file upload as a workspace,
523
541
  // whose default glob only sees top-level flows: a nested entry would
524
542
  // lose to a top-level helper. pin a config that selects the entry.
525
- if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
543
+ if (!keys.some((key) => /^config\.ya?ml$/.test(key))) {
526
544
  if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
527
545
  throw new Error(
528
546
  `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
529
547
  )
530
548
  }
531
549
  const entryKey = keys[0] as string
532
- const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
533
- files['config.yaml'] =
534
- '# synthesized by peach test --cloud: run exactly the submitted entry flow.\n' +
535
- `flows:\n - ${JSON.stringify(glob)}\n`
550
+ files['config.yaml'] = selectionConfig(entryKey)
536
551
  }
537
552
  }
538
553
  for (const configName of [
@@ -682,11 +697,16 @@ export async function runCloudTest(
682
697
  return 1
683
698
  }
684
699
 
685
- const targetPath = resolve(process.cwd(), target)
700
+ let targetPath = resolve(process.cwd(), target)
686
701
  if (!existsSync(targetPath)) {
687
702
  console.error(` error: test target not found: ${target}`)
688
703
  return 1
689
704
  }
705
+ // a file literally named config.yaml names a workspace, not a flow: local
706
+ // dispatch runs its directory, so hosted planning does too.
707
+ if (statSync(targetPath).isFile() && /^config\.ya?ml$/.test(basename(targetPath))) {
708
+ targetPath = dirname(targetPath)
709
+ }
690
710
 
691
711
  if (!app) {
692
712
  console.error(
@@ -798,21 +818,135 @@ export async function runCloudTest(
798
818
  app.includes('/api/preview/share/bundle')
799
819
  ? null
800
820
  : resolve(process.cwd(), app)
801
- const testFiles = collectTestFiles(collectionPath, localBundlePath)
802
- if (Object.keys(testFiles).length === 0) {
803
- console.error(` error: no test files found at ${target}`)
804
- return 1
821
+
822
+ // one hosted job per Maestro flow. a suite whose total executor time passes
823
+ // the 45 minute per-job cap still completes when every flow stays under it,
824
+ // so a directory target uploads the whole original file set (flows and
825
+ // helpers byte-identical) plus a synthesized config selecting one flow per
826
+ // job. tag selection resolves here, before any submission, exactly as a
827
+ // local workspace run resolves it: the CLI planner reads the workspace
828
+ // config, applies CLI include/exclude overrides, and the selected flows are
829
+ // the jobs. the jobs run one at a time because live sidecars can share
830
+ // mutable backend state. detox suites and explicit single flows keep their
831
+ // existing one-job whole-suite path.
832
+ const jobs: Array<{
833
+ label: string | null
834
+ testFiles: Record<string, string>
835
+ testArgs: string[] | null
836
+ }> = []
837
+ const hasConfig = forwarded.some(
838
+ (arg) => arg === '--config' || arg.startsWith('--config='),
839
+ )
840
+ const maestroConfig = hasConfig && isMaestroConfigValue(config)
841
+ const maestroTarget = (() => {
842
+ if (hasConfig && !maestroConfig) return false
843
+ if (!statSync(targetPath).isDirectory()) {
844
+ return /\.ya?ml$/.test(extname(targetPath))
845
+ }
846
+ return (
847
+ readdirSync(targetPath).some((name) => /\.ya?ml$/.test(name)) ||
848
+ ['.maestro', 'maestro'].includes(basename(targetPath))
849
+ )
850
+ })()
851
+ if (maestroTarget) {
852
+ // maestro has no test-name or test-path selection; the cloud selector
853
+ // vocabulary is shared with detox, so a jest selector here is refused
854
+ // before submission rather than silently dropped from the hosted job.
855
+ const unsupported = (cloudTestArgs ?? []).filter((arg) => {
856
+ const eq = arg.indexOf('=')
857
+ const head = eq > 0 ? arg.slice(0, eq) : arg
858
+ return ['-t', '--testNamePattern', '--testPathPattern', '--grep'].includes(head)
859
+ })
860
+ if (unsupported.length > 0) {
861
+ for (const arg of unsupported) {
862
+ const eq = arg.indexOf('=')
863
+ const head = eq > 0 ? arg.slice(0, eq) : arg
864
+ console.error(
865
+ ` error: ${head} selects detox/jest tests and does not apply to a maestro target`,
866
+ )
867
+ }
868
+ return 1
869
+ }
870
+ }
871
+ if (maestroTarget && statSync(targetPath).isDirectory()) {
872
+ const { collectFlowFiles, splitTagList } = await import('./maestro')
873
+ const tagFlag = (name: string): string[] | null => {
874
+ let values: string[] | null = null
875
+ for (let i = 0; i < forwarded.length; i++) {
876
+ const arg = forwarded[i]
877
+ const eq = arg.indexOf('=')
878
+ const head = eq > 0 ? arg.slice(0, eq) : arg
879
+ if (head !== name) continue
880
+ const inline = eq > 0 ? arg.slice(eq + 1) : null
881
+ const next = forwarded[i + 1]
882
+ const value = inline ?? (next !== undefined && !next.startsWith('-') ? next : '')
883
+ if (inline === null && next !== undefined && !next.startsWith('-')) i += 1
884
+ if (values === null) values = []
885
+ values.push(...splitTagList(value))
886
+ }
887
+ return values
888
+ }
889
+ const includeTags = tagFlag('--include-tags')
890
+ const excludeTags = tagFlag('--exclude-tags')
891
+ let flows: string[]
892
+ try {
893
+ flows = collectFlowFiles(targetPath, {
894
+ configPath: config ? resolve(process.cwd(), config) : undefined,
895
+ includeTags,
896
+ excludeTags,
897
+ })
898
+ } catch (err) {
899
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
900
+ return 1
901
+ }
902
+ if (flows.length === 0) {
903
+ console.error(
904
+ includeTags !== null || excludeTags !== null
905
+ ? ` error: no flows in ${target} match the requested tags`
906
+ : ` error: no *.yaml or *.yml files found in ${target}`,
907
+ )
908
+ return 1
909
+ }
910
+ const baseFiles = collectTestFiles(targetPath, localBundlePath)
911
+ for (const flow of flows) {
912
+ const entryKey = relative(targetPath, flow).split(sep).join('/')
913
+ let generated: string
914
+ try {
915
+ generated = selectionConfig(entryKey)
916
+ } catch (err) {
917
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
918
+ return 1
919
+ }
920
+ // the recorder selects the root workspace config. nested config.yaml
921
+ // files may be runFlow helpers and must keep their original bytes.
922
+ const testFiles = { ...baseFiles, 'config.yaml': generated }
923
+ jobs.push({ label: entryKey, testFiles, testArgs: null })
924
+ }
925
+ console.log(
926
+ ` cloud test: ${jobs.length} flow${jobs.length === 1 ? '' : 's'}, one hosted job each`,
927
+ )
928
+ } else {
929
+ const testFiles = collectTestFiles(collectionPath, localBundlePath)
930
+ if (Object.keys(testFiles).length === 0) {
931
+ console.error(` error: no test files found at ${target}`)
932
+ return 1
933
+ }
934
+ jobs.push({ label: null, testFiles, testArgs: cloudTestArgs })
805
935
  }
806
936
 
807
- // the tunnel stays open from submit until the job reaches a terminal
808
- // state; tearing it down early strands the recorder mid-run.
937
+ // the tunnel stays open from the first submit until every job reaches a
938
+ // terminal state; tearing it down early strands the recorder mid-run.
809
939
  if (liveDevStack && liveSidecarPorts) {
810
940
  try {
811
941
  const published = await publishLiveDevStack({
812
942
  devPort: liveDevStack.port,
813
943
  sidecarPorts: liveSidecarPorts,
814
944
  })
815
- tunnelTargetUrl = published.targetUrl
945
+ const appUrl = new URL(app)
946
+ const targetUrl = new URL(published.targetUrl)
947
+ targetUrl.pathname = appUrl.pathname
948
+ targetUrl.search = appUrl.search
949
+ tunnelTargetUrl = targetUrl.href
816
950
  tunnelHostHeader = published.hostHeader
817
951
  tunnelJobToken = published.jobToken
818
952
  tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
@@ -824,150 +958,229 @@ export async function runCloudTest(
824
958
  }
825
959
  }
826
960
 
827
- const submitPayload: Record<string, unknown> = {
828
- kind: 'test',
829
- testFiles,
961
+ // a signal stops the run: it lets a submission already in flight finish, so
962
+ // an accepted job is never orphaned, cancels the job currently relying on
963
+ // this process's tunnel, and only then lets the tunnel close. the listener
964
+ // is installed once for the whole invocation and removed on the way out; a
965
+ // second signal waits for that cancellation instead of closing the tunnel
966
+ // while a delete is still pending.
967
+ const tunnelDependent = closeTunnel !== null
968
+ let interrupted = false
969
+ let wake: (() => void) | null = null
970
+ const onSignal = () => {
971
+ if (interrupted) {
972
+ console.error(' already stopping: waiting for the current job to cancel')
973
+ return
974
+ }
975
+ interrupted = true
976
+ console.error('\n interrupted: stopping after the current job')
977
+ wake?.()
978
+ }
979
+ if (tunnelDependent) {
980
+ process.on('SIGINT', onSignal)
981
+ process.on('SIGTERM', onSignal)
830
982
  }
831
- if (bundleUrl) submitPayload.bundleUrl = bundleUrl
832
- if (bundle) submitPayload.bundle = bundle
833
- if (bundleShareId) submitPayload.bundleShareId = bundleShareId
834
- if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
835
- if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
836
- if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
837
- if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
838
- if (owner) submitPayload.owner = owner
839
- if (repo) submitPayload.repo = repo
840
- if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
841
983
 
842
- try {
843
- const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
844
- let response: Response
845
- try {
846
- response = await fetch(endpoint, {
847
- method: 'POST',
848
- headers: {
849
- 'content-type': 'application/json',
850
- authorization: authHeaderValue(auth),
851
- },
852
- body: JSON.stringify(submitPayload),
853
- })
854
- } catch (err) {
855
- console.error(
856
- ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
857
- )
858
- return 1
859
- }
984
+ // a sleep the signal handler can cut short, so an interrupt is noticed while
985
+ // a job poll is waiting instead of after the whole interval.
986
+ const waitForPoll = (ms: number) =>
987
+ new Promise<void>((resolvePoll) => {
988
+ const timer = setTimeout(() => {
989
+ wake = null
990
+ resolvePoll()
991
+ }, ms)
992
+ wake = () => {
993
+ clearTimeout(timer)
994
+ wake = null
995
+ resolvePoll()
996
+ }
997
+ })
860
998
 
861
- if (!response.ok) {
862
- const body = await response.text()
863
- let message = body
864
- try {
865
- const json = JSON.parse(body)
866
- if (json.message) message = json.message
867
- } catch {}
868
- console.error(
869
- ` error: cloud test submission failed (${response.status}): ${message}`,
870
- )
871
- return 1
872
- }
999
+ const submitIdentity: Record<string, unknown> = {}
1000
+ if (bundleUrl) submitIdentity.bundleUrl = bundleUrl
1001
+ if (bundle) submitIdentity.bundle = bundle
1002
+ if (bundleShareId) submitIdentity.bundleShareId = bundleShareId
1003
+ if (tunnelTargetUrl) submitIdentity.targetUrl = tunnelTargetUrl
1004
+ if (tunnelHostHeader) submitIdentity.hostHeader = tunnelHostHeader
1005
+ if (tunnelJobToken) submitIdentity.jobToken = tunnelJobToken
1006
+ if (tunnelSidecarPorts) submitIdentity.sidecarPorts = tunnelSidecarPorts
1007
+ if (owner) submitIdentity.owner = owner
1008
+ if (repo) submitIdentity.repo = repo
873
1009
 
874
- const { id } = (await response.json()) as { id: string }
875
- console.log(` cloud test job submitted: ${id}`)
876
- // a job whose app this process serves over its tunnel cannot finish once
877
- // the process exits, so an interrupt cancels it rather than leaving it to
878
- // fail flow by flow while it bills.
879
- if (closeTunnel) {
880
- const cancelOnSignal = () => {
881
- console.error(`\n interrupted: cancelling ${id}`)
882
- void cancelCloudTest(id, origin).finally(() => {
883
- closeTunnel?.()
884
- process.exit(130)
885
- })
1010
+ const summary: Array<{ label: string; passed: boolean }> = []
1011
+ let worstExit = 0
1012
+ try {
1013
+ for (const [index, job] of jobs.entries()) {
1014
+ const submitPayload: Record<string, unknown> = {
1015
+ kind: 'test',
1016
+ testFiles: job.testFiles,
1017
+ ...submitIdentity,
886
1018
  }
887
- process.once('SIGINT', cancelOnSignal)
888
- process.once('SIGTERM', cancelOnSignal)
889
- }
890
-
891
- const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
892
- const startedAt = Date.now()
1019
+ if (job.testArgs) submitPayload.testArgs = job.testArgs
893
1020
 
894
- while (Date.now() - startedAt < timeoutMs) {
895
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
896
- let pollRes: Response
1021
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
1022
+ let response: Response
897
1023
  try {
898
- pollRes = await fetch(pollUrl, {
899
- headers: { authorization: authHeaderValue(auth) },
1024
+ response = await fetch(endpoint, {
1025
+ method: 'POST',
1026
+ headers: {
1027
+ 'content-type': 'application/json',
1028
+ authorization: authHeaderValue(auth),
1029
+ },
1030
+ body: JSON.stringify(submitPayload),
900
1031
  })
901
1032
  } catch (err) {
902
- console.warn(
903
- ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
1033
+ // a transport failure is not a flow failure: stop submitting further
1034
+ // jobs and report. jobs that already ran keep their collected results.
1035
+ console.error(
1036
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
904
1037
  )
905
- continue
1038
+ if (interrupted) break
1039
+ return 1
906
1040
  }
907
- if (!pollRes.ok) {
908
- console.warn(` warn: polling error: HTTP ${pollRes.status}`)
909
- continue
1041
+
1042
+ if (!response.ok) {
1043
+ const body = await response.text()
1044
+ let message = body
1045
+ try {
1046
+ const json = JSON.parse(body)
1047
+ if (json.message) message = json.message
1048
+ } catch {}
1049
+ console.error(
1050
+ ` error: cloud test submission failed (${response.status}): ${message}`,
1051
+ )
1052
+ if (interrupted) break
1053
+ return 1
910
1054
  }
911
- const job = (await pollRes.json()) as {
912
- id: string
913
- status: string
914
- result?: {
915
- previewId?: string | null
916
- previewUrl?: string | null
917
- runId?: string | null
918
- flows?: Array<{
919
- flow?: string | null
920
- previewId?: string | null
921
- previewUrl?: string | null
922
- runId?: string | null
923
- }> | null
924
- exitCode?: number | null
925
- failureReason?: string | null
926
- } | null
1055
+
1056
+ const { id } = (await response.json()) as { id: string }
1057
+ console.log(
1058
+ jobs.length > 1
1059
+ ? ` cloud test job ${index + 1}/${jobs.length} (${job.label}) submitted: ${id}`
1060
+ : ` cloud test job submitted: ${id}`,
1061
+ )
1062
+ if (interrupted) {
1063
+ // the signal arrived while the POST was in flight: the server accepted
1064
+ // the job, so cancel it before the tunnel closes.
1065
+ if (tunnelDependent) await cancelCloudTest(id, origin)
1066
+ break
927
1067
  }
928
1068
 
929
- if (
930
- job.status === 'done' ||
931
- job.status === 'failed' ||
932
- job.status === 'expired' ||
933
- job.status === 'cancelled'
934
- ) {
935
- const result = job.result
936
- // a job the recorder failed after a passing suite (missing recording or
937
- // registration) is still a failed run.
938
- const exitCode =
939
- job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
940
- console.log(` status: ${job.status}`)
941
- console.log(` exit code: ${exitCode}`)
942
- if (result?.previewUrl) {
943
- console.log(` preview: ${result.previewUrl}`)
944
- } else if (result?.previewId) {
945
- console.log(
946
- ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
1069
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
1070
+ const startedAt = Date.now()
1071
+ let exitCode: number | null = null
1072
+
1073
+ // every flow keeps the job's own budget; a wait that expires cancels
1074
+ // the job before the tunnel closes, and later flows are not submitted.
1075
+ while (!interrupted && Date.now() - startedAt < timeoutMs) {
1076
+ await waitForPoll(pollIntervalMs)
1077
+ if (interrupted) break
1078
+ let pollRes: Response
1079
+ try {
1080
+ pollRes = await fetch(pollUrl, {
1081
+ headers: { authorization: authHeaderValue(auth) },
1082
+ })
1083
+ } catch (err) {
1084
+ console.warn(
1085
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
947
1086
  )
1087
+ continue
1088
+ }
1089
+ if (interrupted) break
1090
+ if (!pollRes.ok) {
1091
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
1092
+ continue
948
1093
  }
949
- if (result?.runId) {
950
- console.log(` run: ${result.runId}`)
1094
+ const jobState = (await pollRes.json()) as {
1095
+ id: string
1096
+ status: string
1097
+ result?: {
1098
+ previewId?: string | null
1099
+ previewUrl?: string | null
1100
+ runId?: string | null
1101
+ flows?: Array<{
1102
+ flow?: string | null
1103
+ previewId?: string | null
1104
+ previewUrl?: string | null
1105
+ runId?: string | null
1106
+ }> | null
1107
+ exitCode?: number | null
1108
+ failureReason?: string | null
1109
+ } | null
951
1110
  }
952
- if (result?.flows && result.flows.length > 1) {
953
- result.flows.forEach((flow, index) => {
1111
+
1112
+ if (
1113
+ jobState.status === 'done' ||
1114
+ jobState.status === 'failed' ||
1115
+ jobState.status === 'expired' ||
1116
+ jobState.status === 'cancelled'
1117
+ ) {
1118
+ const result = jobState.result
1119
+ // a job the recorder failed after a passing suite (missing recording or
1120
+ // registration) is still a failed run.
1121
+ exitCode =
1122
+ jobState.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
1123
+ console.log(` status: ${jobState.status}`)
1124
+ console.log(` exit code: ${exitCode}`)
1125
+ if (result?.previewUrl) {
1126
+ console.log(` preview: ${result.previewUrl}`)
1127
+ } else if (result?.previewId) {
954
1128
  console.log(
955
- ` flow ${index + 1}: ${flow.flow ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
1129
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
956
1130
  )
957
- })
958
- }
959
- if (result?.failureReason) {
960
- console.error(` failure: ${result.failureReason}`)
1131
+ }
1132
+ if (result?.runId) {
1133
+ console.log(` run: ${result.runId}`)
1134
+ }
1135
+ if (result?.flows && (result.flows.length > 1 || jobs.length > 1)) {
1136
+ result.flows.forEach((flow, flowIndex) => {
1137
+ console.log(
1138
+ ` flow ${flowIndex + 1}: ${flow.flow ?? job.label ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
1139
+ )
1140
+ })
1141
+ }
1142
+ if (result?.failureReason) {
1143
+ console.error(` failure: ${result.failureReason}`)
1144
+ }
1145
+ break
961
1146
  }
962
- return exitCode
963
1147
  }
964
- }
965
1148
 
966
- console.error(
967
- ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
968
- )
969
- return 1
1149
+ if (exitCode === null) {
1150
+ // the wait ended without a terminal state: the budget ran out or a
1151
+ // signal asked to stop. a tunnel-dependent job is cancelled before
1152
+ // the tunnel closes instead of failing flow by flow while it bills.
1153
+ if (tunnelDependent) await cancelCloudTest(id, origin)
1154
+ if (interrupted) break
1155
+ console.error(
1156
+ ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
1157
+ )
1158
+ return 1
1159
+ }
1160
+
1161
+ summary.push({ label: job.label ?? id, passed: exitCode === 0 })
1162
+ if (exitCode !== 0 && worstExit === 0) worstExit = exitCode
1163
+ if (interrupted) break
1164
+ }
1165
+ if (interrupted) return 130
1166
+ return worstExit
970
1167
  } finally {
1168
+ if (tunnelDependent) {
1169
+ process.off('SIGINT', onSignal)
1170
+ process.off('SIGTERM', onSignal)
1171
+ }
1172
+ if (jobs.length > 1 && summary.length > 0) {
1173
+ console.log('\n cloud test summary:')
1174
+ for (const entry of summary) {
1175
+ console.log(` ${entry.passed ? 'pass' : 'fail'} ${entry.label}`)
1176
+ }
1177
+ const passed = summary.filter((entry) => entry.passed).length
1178
+ const stopped =
1179
+ summary.length < jobs.length
1180
+ ? ` (stopped after ${summary.length} of ${jobs.length})`
1181
+ : ''
1182
+ console.log(` ${passed}/${summary.length} flows passed${stopped}`)
1183
+ }
971
1184
  closeTunnel?.()
972
1185
  }
973
1186
  }