peachsim 0.1.630 → 0.1.632

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 (96) hide show
  1. package/README.md +9 -1
  2. package/cli/app-fonts.ts +23 -4
  3. package/cli/bridge-flow-runner.ts +35 -28
  4. package/cli/command-registry.ts +1 -0
  5. package/cli/commands/compat.ts +11 -60
  6. package/cli/commands/control.ts +12 -5
  7. package/cli/commands/detect.ts +289 -26
  8. package/cli/commands/inspect/core.ts +1 -1
  9. package/cli/commands/inspect.ts +4 -0
  10. package/cli/commands/maestro.ts +8 -2
  11. package/cli/commands/project-report.ts +386 -0
  12. package/cli/commands/projectReportContract.ts +42 -0
  13. package/cli/commands/report-issue.ts +38 -54
  14. package/cli/commands/setup.ts +6 -4
  15. package/cli/commands/telemetry.ts +8 -3
  16. package/cli/commands/test.ts +374 -133
  17. package/cli/drivers/playwright-core-pin.ts +10 -0
  18. package/cli/drivers/playwright-provisioning.ts +36 -1
  19. package/cli/drivers/playwright.ts +26 -19
  20. package/cli/main.ts +11 -1
  21. package/cli/outbound-endpoints.ts +2 -1
  22. package/cli/privacy.ts +1 -2
  23. package/cli/runtime-diagnostics.ts +51 -0
  24. package/cli/shell-init.ts +6 -1
  25. package/cli/telemetry.ts +78 -7
  26. package/cli/ws-bridge.ts +8 -2
  27. package/detox/index.ts +3 -8
  28. package/dist-lib/agent-daemon-client.cjs +1 -1
  29. package/dist-lib/agent-events.cjs +1 -1
  30. package/dist-lib/agent-identity.cjs +1 -1
  31. package/dist-lib/agent-sessions.cjs +1 -1
  32. package/dist-lib/attached-projects.cjs +1 -1
  33. package/dist-lib/auth/shared-session.cjs +1 -1
  34. package/dist-lib/backend-origin.cjs +1 -1
  35. package/dist-lib/beta.cjs +1 -1
  36. package/dist-lib/beta.mjs +1 -1
  37. package/dist-lib/bridge-constants.cjs +1 -1
  38. package/dist-lib/bridge-contract-input.cjs +1 -1
  39. package/dist-lib/bridge-contract-input.mjs +1 -1
  40. package/dist-lib/bridge-contract.cjs +1 -1
  41. package/dist-lib/bridge-contract.mjs +1 -1
  42. package/dist-lib/capture-contract.cjs +1 -1
  43. package/dist-lib/capture-contract.mjs +1 -1
  44. package/dist-lib/cli-constants.cjs +1 -1
  45. package/dist-lib/cloud-contract.cjs +1 -1
  46. package/dist-lib/cloud-contract.mjs +1 -1
  47. package/dist-lib/cloud-test-args.cjs +17 -5
  48. package/dist-lib/cloud-test-args.mjs +17 -5
  49. package/dist-lib/cloud.cjs +1 -1
  50. package/dist-lib/cloud.mjs +1 -1
  51. package/dist-lib/config.cjs +1 -1
  52. package/dist-lib/detox/index.cjs +2 -7
  53. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  54. package/dist-lib/home-paths.cjs +13 -5
  55. package/dist-lib/host/bridge-host.cjs +157 -113
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +11 -2
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +11 -1
  66. package/dist-lib/menu.mjs +11 -1
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +1 -1
  70. package/dist-lib/metro-production-bundle.mjs +1 -1
  71. package/dist-lib/metro.cjs +1 -1
  72. package/dist-lib/native-app-identity.cjs +1 -1
  73. package/dist-lib/native-app-identity.mjs +1 -1
  74. package/dist-lib/profiles.cjs +1 -1
  75. package/dist-lib/public-brand.cjs +1 -1
  76. package/dist-lib/react-native-host-modules.cjs +1 -1
  77. package/dist-lib/react-native-host-modules.mjs +1 -1
  78. package/dist-lib/render-mode.cjs +1 -1
  79. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  80. package/dist-lib/sdk.cjs +3 -1
  81. package/dist-lib/sdk.mjs +2 -1
  82. package/dist-lib/skills.cjs +700 -329
  83. package/dist-lib/swift-compile.cjs +1 -1
  84. package/dist-lib/swift-compile.mjs +1 -1
  85. package/dist-lib/swift-package.cjs +1 -1
  86. package/dist-lib/swift-package.mjs +1 -1
  87. package/dist-lib/swift-submit.cjs +1 -1
  88. package/dist-lib/swift-submit.mjs +1 -1
  89. package/dist-lib/swift.cjs +443 -173
  90. package/dist-lib/vite-swift.cjs +1 -1
  91. package/dist-lib/vite.cjs +1 -1
  92. package/package.json +2 -1
  93. package/skills/contrast/SKILL.md +1 -0
  94. package/src/cloud-test-args.ts +18 -6
  95. package/src/home-paths.ts +19 -9
  96. package/src/menu.ts +11 -0
@@ -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,163 @@ 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}`)
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 (
852
+ !maestroTarget &&
853
+ (cloudTestArgs ?? []).some(
854
+ (arg) =>
855
+ arg === '--env' ||
856
+ arg === '-e' ||
857
+ arg.startsWith('--env=') ||
858
+ arg.startsWith('-e='),
859
+ )
860
+ ) {
861
+ console.error(
862
+ ' error: --env supplies maestro flow variables and requires a maestro target',
863
+ )
804
864
  return 1
805
865
  }
866
+ if (maestroTarget) {
867
+ // maestro has no test-name or test-path selection; the cloud selector
868
+ // vocabulary is shared with detox, so a jest selector here is refused
869
+ // before submission rather than silently dropped from the hosted job.
870
+ const unsupported = (cloudTestArgs ?? []).filter((arg) => {
871
+ const eq = arg.indexOf('=')
872
+ const head = eq > 0 ? arg.slice(0, eq) : arg
873
+ return ['-t', '--testNamePattern', '--testPathPattern', '--grep'].includes(head)
874
+ })
875
+ if (unsupported.length > 0) {
876
+ for (const arg of unsupported) {
877
+ const eq = arg.indexOf('=')
878
+ const head = eq > 0 ? arg.slice(0, eq) : arg
879
+ console.error(
880
+ ` error: ${head} selects detox/jest tests and does not apply to a maestro target`,
881
+ )
882
+ }
883
+ return 1
884
+ }
885
+ }
886
+ if (maestroTarget && statSync(targetPath).isDirectory()) {
887
+ const { collectFlowFiles, splitTagList } = await import('./maestro')
888
+ const tagFlag = (name: string): string[] | null => {
889
+ let values: string[] | null = null
890
+ for (let i = 0; i < forwarded.length; i++) {
891
+ const arg = forwarded[i]
892
+ const eq = arg.indexOf('=')
893
+ const head = eq > 0 ? arg.slice(0, eq) : arg
894
+ if (head !== name) continue
895
+ const inline = eq > 0 ? arg.slice(eq + 1) : null
896
+ const next = forwarded[i + 1]
897
+ const value = inline ?? (next !== undefined && !next.startsWith('-') ? next : '')
898
+ if (inline === null && next !== undefined && !next.startsWith('-')) i += 1
899
+ if (values === null) values = []
900
+ values.push(...splitTagList(value))
901
+ }
902
+ return values
903
+ }
904
+ const includeTags = tagFlag('--include-tags')
905
+ const excludeTags = tagFlag('--exclude-tags')
906
+ let flows: string[]
907
+ try {
908
+ flows = collectFlowFiles(targetPath, {
909
+ configPath: config ? resolve(process.cwd(), config) : undefined,
910
+ includeTags,
911
+ excludeTags,
912
+ })
913
+ } catch (err) {
914
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
915
+ return 1
916
+ }
917
+ if (flows.length === 0) {
918
+ console.error(
919
+ includeTags !== null || excludeTags !== null
920
+ ? ` error: no flows in ${target} match the requested tags`
921
+ : ` error: no *.yaml or *.yml files found in ${target}`,
922
+ )
923
+ return 1
924
+ }
925
+ const baseFiles = collectTestFiles(targetPath, localBundlePath)
926
+ // tags have already selected the jobs. only explicit environment overrides
927
+ // remain executor inputs, with duplicates and their order preserved.
928
+ const envArgs: string[] = []
929
+ const executorArgs = cloudTestArgs ?? []
930
+ for (let i = 0; i < executorArgs.length; i++) {
931
+ const arg = executorArgs[i]
932
+ const eq = arg.indexOf('=')
933
+ const head = eq > 0 ? arg.slice(0, eq) : arg
934
+ const value = eq > 0 ? null : executorArgs[++i]
935
+ if (head !== '--env' && head !== '-e') continue
936
+ envArgs.push(arg)
937
+ if (value !== null) envArgs.push(value)
938
+ }
939
+ for (const flow of flows) {
940
+ const entryKey = relative(targetPath, flow).split(sep).join('/')
941
+ let generated: string
942
+ try {
943
+ generated = selectionConfig(entryKey)
944
+ } catch (err) {
945
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
946
+ return 1
947
+ }
948
+ // the recorder selects the root workspace config. nested config.yaml
949
+ // files may be runFlow helpers and must keep their original bytes.
950
+ const testFiles = { ...baseFiles, 'config.yaml': generated }
951
+ jobs.push({ label: entryKey, testFiles, testArgs: envArgs.length ? envArgs : null })
952
+ }
953
+ console.log(
954
+ ` cloud test: ${jobs.length} flow${jobs.length === 1 ? '' : 's'}, one hosted job each`,
955
+ )
956
+ } else {
957
+ const testFiles = collectTestFiles(collectionPath, localBundlePath)
958
+ if (Object.keys(testFiles).length === 0) {
959
+ console.error(` error: no test files found at ${target}`)
960
+ return 1
961
+ }
962
+ jobs.push({ label: null, testFiles, testArgs: cloudTestArgs })
963
+ }
806
964
 
807
- // the tunnel stays open from submit until the job reaches a terminal
808
- // state; tearing it down early strands the recorder mid-run.
965
+ // the tunnel stays open from the first submit until every job reaches a
966
+ // terminal state; tearing it down early strands the recorder mid-run.
809
967
  if (liveDevStack && liveSidecarPorts) {
810
968
  try {
811
969
  const published = await publishLiveDevStack({
812
970
  devPort: liveDevStack.port,
813
971
  sidecarPorts: liveSidecarPorts,
814
972
  })
815
- tunnelTargetUrl = published.targetUrl
973
+ const appUrl = new URL(app)
974
+ const targetUrl = new URL(published.targetUrl)
975
+ targetUrl.pathname = appUrl.pathname
976
+ targetUrl.search = appUrl.search
977
+ tunnelTargetUrl = targetUrl.href
816
978
  tunnelHostHeader = published.hostHeader
817
979
  tunnelJobToken = published.jobToken
818
980
  tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
@@ -824,150 +986,229 @@ export async function runCloudTest(
824
986
  }
825
987
  }
826
988
 
827
- const submitPayload: Record<string, unknown> = {
828
- kind: 'test',
829
- testFiles,
989
+ // a signal stops the run: it lets a submission already in flight finish, so
990
+ // an accepted job is never orphaned, cancels the job currently relying on
991
+ // this process's tunnel, and only then lets the tunnel close. the listener
992
+ // is installed once for the whole invocation and removed on the way out; a
993
+ // second signal waits for that cancellation instead of closing the tunnel
994
+ // while a delete is still pending.
995
+ const tunnelDependent = closeTunnel !== null
996
+ let interrupted = false
997
+ let wake: (() => void) | null = null
998
+ const onSignal = () => {
999
+ if (interrupted) {
1000
+ console.error(' already stopping: waiting for the current job to cancel')
1001
+ return
1002
+ }
1003
+ interrupted = true
1004
+ console.error('\n interrupted: stopping after the current job')
1005
+ wake?.()
1006
+ }
1007
+ if (tunnelDependent) {
1008
+ process.on('SIGINT', onSignal)
1009
+ process.on('SIGTERM', onSignal)
830
1010
  }
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
1011
 
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
- }
1012
+ // a sleep the signal handler can cut short, so an interrupt is noticed while
1013
+ // a job poll is waiting instead of after the whole interval.
1014
+ const waitForPoll = (ms: number) =>
1015
+ new Promise<void>((resolvePoll) => {
1016
+ const timer = setTimeout(() => {
1017
+ wake = null
1018
+ resolvePoll()
1019
+ }, ms)
1020
+ wake = () => {
1021
+ clearTimeout(timer)
1022
+ wake = null
1023
+ resolvePoll()
1024
+ }
1025
+ })
860
1026
 
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
- }
1027
+ const submitIdentity: Record<string, unknown> = {}
1028
+ if (bundleUrl) submitIdentity.bundleUrl = bundleUrl
1029
+ if (bundle) submitIdentity.bundle = bundle
1030
+ if (bundleShareId) submitIdentity.bundleShareId = bundleShareId
1031
+ if (tunnelTargetUrl) submitIdentity.targetUrl = tunnelTargetUrl
1032
+ if (tunnelHostHeader) submitIdentity.hostHeader = tunnelHostHeader
1033
+ if (tunnelJobToken) submitIdentity.jobToken = tunnelJobToken
1034
+ if (tunnelSidecarPorts) submitIdentity.sidecarPorts = tunnelSidecarPorts
1035
+ if (owner) submitIdentity.owner = owner
1036
+ if (repo) submitIdentity.repo = repo
873
1037
 
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
- })
1038
+ const summary: Array<{ label: string; passed: boolean }> = []
1039
+ let worstExit = 0
1040
+ try {
1041
+ for (const [index, job] of jobs.entries()) {
1042
+ const submitPayload: Record<string, unknown> = {
1043
+ kind: 'test',
1044
+ testFiles: job.testFiles,
1045
+ ...submitIdentity,
886
1046
  }
887
- process.once('SIGINT', cancelOnSignal)
888
- process.once('SIGTERM', cancelOnSignal)
889
- }
1047
+ if (job.testArgs) submitPayload.testArgs = job.testArgs
890
1048
 
891
- const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
892
- const startedAt = Date.now()
893
-
894
- while (Date.now() - startedAt < timeoutMs) {
895
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
896
- let pollRes: Response
1049
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
1050
+ let response: Response
897
1051
  try {
898
- pollRes = await fetch(pollUrl, {
899
- headers: { authorization: authHeaderValue(auth) },
1052
+ response = await fetch(endpoint, {
1053
+ method: 'POST',
1054
+ headers: {
1055
+ 'content-type': 'application/json',
1056
+ authorization: authHeaderValue(auth),
1057
+ },
1058
+ body: JSON.stringify(submitPayload),
900
1059
  })
901
1060
  } catch (err) {
902
- console.warn(
903
- ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
1061
+ // a transport failure is not a flow failure: stop submitting further
1062
+ // jobs and report. jobs that already ran keep their collected results.
1063
+ console.error(
1064
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
904
1065
  )
905
- continue
1066
+ if (interrupted) break
1067
+ return 1
906
1068
  }
907
- if (!pollRes.ok) {
908
- console.warn(` warn: polling error: HTTP ${pollRes.status}`)
909
- continue
1069
+
1070
+ if (!response.ok) {
1071
+ const body = await response.text()
1072
+ let message = body
1073
+ try {
1074
+ const json = JSON.parse(body)
1075
+ if (json.message) message = json.message
1076
+ } catch {}
1077
+ console.error(
1078
+ ` error: cloud test submission failed (${response.status}): ${message}`,
1079
+ )
1080
+ if (interrupted) break
1081
+ return 1
910
1082
  }
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
1083
+
1084
+ const { id } = (await response.json()) as { id: string }
1085
+ console.log(
1086
+ jobs.length > 1
1087
+ ? ` cloud test job ${index + 1}/${jobs.length} (${job.label}) submitted: ${id}`
1088
+ : ` cloud test job submitted: ${id}`,
1089
+ )
1090
+ if (interrupted) {
1091
+ // the signal arrived while the POST was in flight: the server accepted
1092
+ // the job, so cancel it before the tunnel closes.
1093
+ if (tunnelDependent) await cancelCloudTest(id, origin)
1094
+ break
927
1095
  }
928
1096
 
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}`,
1097
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
1098
+ const startedAt = Date.now()
1099
+ let exitCode: number | null = null
1100
+
1101
+ // every flow keeps the job's own budget; a wait that expires cancels
1102
+ // the job before the tunnel closes, and later flows are not submitted.
1103
+ while (!interrupted && Date.now() - startedAt < timeoutMs) {
1104
+ await waitForPoll(pollIntervalMs)
1105
+ if (interrupted) break
1106
+ let pollRes: Response
1107
+ try {
1108
+ pollRes = await fetch(pollUrl, {
1109
+ headers: { authorization: authHeaderValue(auth) },
1110
+ })
1111
+ } catch (err) {
1112
+ console.warn(
1113
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
947
1114
  )
1115
+ continue
948
1116
  }
949
- if (result?.runId) {
950
- console.log(` run: ${result.runId}`)
1117
+ if (interrupted) break
1118
+ if (!pollRes.ok) {
1119
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
1120
+ continue
951
1121
  }
952
- if (result?.flows && result.flows.length > 1) {
953
- result.flows.forEach((flow, index) => {
1122
+ const jobState = (await pollRes.json()) as {
1123
+ id: string
1124
+ status: string
1125
+ result?: {
1126
+ previewId?: string | null
1127
+ previewUrl?: string | null
1128
+ runId?: string | null
1129
+ flows?: Array<{
1130
+ flow?: string | null
1131
+ previewId?: string | null
1132
+ previewUrl?: string | null
1133
+ runId?: string | null
1134
+ }> | null
1135
+ exitCode?: number | null
1136
+ failureReason?: string | null
1137
+ } | null
1138
+ }
1139
+
1140
+ if (
1141
+ jobState.status === 'done' ||
1142
+ jobState.status === 'failed' ||
1143
+ jobState.status === 'expired' ||
1144
+ jobState.status === 'cancelled'
1145
+ ) {
1146
+ const result = jobState.result
1147
+ // a job the recorder failed after a passing suite (missing recording or
1148
+ // registration) is still a failed run.
1149
+ exitCode =
1150
+ jobState.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
1151
+ console.log(` status: ${jobState.status}`)
1152
+ console.log(` exit code: ${exitCode}`)
1153
+ if (result?.previewUrl) {
1154
+ console.log(` preview: ${result.previewUrl}`)
1155
+ } else if (result?.previewId) {
954
1156
  console.log(
955
- ` flow ${index + 1}: ${flow.flow ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
1157
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
956
1158
  )
957
- })
1159
+ }
1160
+ if (result?.runId) {
1161
+ console.log(` run: ${result.runId}`)
1162
+ }
1163
+ if (result?.flows && (result.flows.length > 1 || jobs.length > 1)) {
1164
+ result.flows.forEach((flow, flowIndex) => {
1165
+ console.log(
1166
+ ` flow ${flowIndex + 1}: ${flow.flow ?? job.label ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
1167
+ )
1168
+ })
1169
+ }
1170
+ if (result?.failureReason) {
1171
+ console.error(` failure: ${result.failureReason}`)
1172
+ }
1173
+ break
958
1174
  }
959
- if (result?.failureReason) {
960
- console.error(` failure: ${result.failureReason}`)
961
- }
962
- return exitCode
963
1175
  }
964
- }
965
1176
 
966
- console.error(
967
- ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
968
- )
969
- return 1
1177
+ if (exitCode === null) {
1178
+ // the wait ended without a terminal state: the budget ran out or a
1179
+ // signal asked to stop. a tunnel-dependent job is cancelled before
1180
+ // the tunnel closes instead of failing flow by flow while it bills.
1181
+ if (tunnelDependent) await cancelCloudTest(id, origin)
1182
+ if (interrupted) break
1183
+ console.error(
1184
+ ` error: cloud test timed out after ${timeoutMs < 1000 ? `${timeoutMs}ms` : `${timeoutMs / 1000}s`}`,
1185
+ )
1186
+ return 1
1187
+ }
1188
+
1189
+ summary.push({ label: job.label ?? id, passed: exitCode === 0 })
1190
+ if (exitCode !== 0 && worstExit === 0) worstExit = exitCode
1191
+ if (interrupted) break
1192
+ }
1193
+ if (interrupted) return 130
1194
+ return worstExit
970
1195
  } finally {
1196
+ if (tunnelDependent) {
1197
+ process.off('SIGINT', onSignal)
1198
+ process.off('SIGTERM', onSignal)
1199
+ }
1200
+ if (jobs.length > 1 && summary.length > 0) {
1201
+ console.log('\n cloud test summary:')
1202
+ for (const entry of summary) {
1203
+ console.log(` ${entry.passed ? 'pass' : 'fail'} ${entry.label}`)
1204
+ }
1205
+ const passed = summary.filter((entry) => entry.passed).length
1206
+ const stopped =
1207
+ summary.length < jobs.length
1208
+ ? ` (stopped after ${summary.length} of ${jobs.length})`
1209
+ : ''
1210
+ console.log(` ${passed}/${summary.length} flows passed${stopped}`)
1211
+ }
971
1212
  closeTunnel?.()
972
1213
  }
973
1214
  }
@@ -0,0 +1,10 @@
1
+ // the playwright-core this CLI provisions into its own data dir when neither
2
+ // the app nor the CLI's install tree has a playwright package. the standalone
3
+ // binary has no node_modules, so this is how it gets one. it is the exact
4
+ // package and tarball the npm install depends on: build-cli-binary.ts fails
5
+ // unless both fields match package.json and bun.lock.
6
+ export const PINNED_PLAYWRIGHT_CORE = {
7
+ version: '1.61.0',
8
+ integrity:
9
+ 'sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==',
10
+ }
@@ -2,9 +2,12 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, join } from 'node:path'
5
+ import { installVerifiedTarball } from '@contrast/runtime-delivery'
6
+ import { playwrightCoreDir } from '../../src/home-paths'
5
7
  import { isRecord } from '../../src/is-record'
6
8
  import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
7
9
  import { rnxSelfInvocation } from '../self-invocation'
10
+ import { PINNED_PLAYWRIGHT_CORE } from './playwright-core-pin'
8
11
 
9
12
  export interface ResolvedPlaywright {
10
13
  // package name that resolved (for human-facing messaging)
@@ -30,10 +33,12 @@ export type PlaywrightBrowserProvisioningResult =
30
33
 
31
34
  export function resolvePlaywright(): ResolvedPlaywright | null {
32
35
  // resolve from several roots, not just cwd. a user can invoke a global CLI
33
- // from an arbitrary directory while Playwright is installed beside the CLI.
36
+ // from an arbitrary directory while Playwright is installed beside the CLI,
37
+ // and the last root is the pinned package this CLI provisions for itself.
34
38
  const roots = [`${process.cwd()}/`]
35
39
  const self = rnxSelfInvocation()
36
40
  roots.push(self.prefixArgs[0] ?? self.executable)
41
+ roots.push(`${playwrightCoreDir(PINNED_PLAYWRIGHT_CORE.version)}/`)
37
42
  for (const root of roots) {
38
43
  try {
39
44
  const requireFromRoot = createRequire(root)
@@ -69,6 +74,36 @@ export function resolvePlaywright(): ResolvedPlaywright | null {
69
74
  return null
70
75
  }
71
76
 
77
+ /** the playwright package to drive: whichever one resolves, else the pinned
78
+ * playwright-core, downloaded from the npm registry and refused unless its
79
+ * tarball matches the pinned integrity. */
80
+ export async function provisionPlaywright(): Promise<ResolvedPlaywright> {
81
+ const existing = resolvePlaywright()
82
+ if (existing) return existing
83
+ const { version, integrity } = PINNED_PLAYWRIGHT_CORE
84
+ const [algorithm, digest] = integrity.split('-', 2)
85
+ if (algorithm !== 'sha512' || !digest) {
86
+ throw new Error(`pinned playwright-core integrity is not sha512: ${integrity}`)
87
+ }
88
+ process.stderr.write(`peach: installing playwright-core ${version}…\n`)
89
+ await installVerifiedTarball({
90
+ url: `https://registry.npmjs.org/playwright-core/-/playwright-core-${version}.tgz`,
91
+ digest: { algorithm, encoding: 'base64', value: digest },
92
+ destDir: join(playwrightCoreDir(version), 'node_modules', 'playwright-core'),
93
+ installedMarker: 'package.json',
94
+ // npm tarballs hold the package under a leading `package/`
95
+ stripComponents: 1,
96
+ label: `playwright-core ${version}`,
97
+ })
98
+ const installed = resolvePlaywright()
99
+ if (!installed) {
100
+ throw new Error(
101
+ `playwright-core ${version} installed to ${playwrightCoreDir(version)} but does not resolve`,
102
+ )
103
+ }
104
+ return installed
105
+ }
106
+
72
107
  type BrowserProbeResult =
73
108
  | { ok: true; executablePath: string; exists: boolean }
74
109
  | { ok: false; message: string }