peachsim 0.1.630 → 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.
- package/cli/app-fonts.ts +23 -4
- package/cli/bridge-flow-runner.ts +35 -28
- package/cli/command-registry.ts +1 -0
- package/cli/commands/compat.ts +11 -60
- package/cli/commands/control.ts +12 -5
- package/cli/commands/detect.ts +289 -26
- package/cli/commands/inspect/core.ts +1 -1
- package/cli/commands/inspect.ts +4 -0
- package/cli/commands/maestro.ts +3 -1
- package/cli/commands/project-report.ts +386 -0
- package/cli/commands/projectReportContract.ts +42 -0
- package/cli/commands/report-issue.ts +38 -54
- package/cli/commands/setup.ts +6 -4
- package/cli/commands/telemetry.ts +8 -3
- package/cli/commands/test.ts +347 -134
- package/cli/main.ts +11 -1
- package/cli/outbound-endpoints.ts +2 -1
- package/cli/privacy.ts +1 -2
- package/cli/runtime-diagnostics.ts +51 -0
- package/cli/telemetry.ts +78 -7
- package/detox/index.ts +3 -8
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +2 -7
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +8 -5
- package/dist-lib/host/bridge-host.cjs +110 -67
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +5 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +11 -1
- package/dist-lib/menu.mjs +11 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/native-app-identity.cjs +1 -1
- package/dist-lib/native-app-identity.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +3 -1
- package/dist-lib/sdk.mjs +2 -1
- package/dist-lib/skills.cjs +589 -268
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +439 -167
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +1 -1
- package/package.json +2 -1
- package/skills/contrast/SKILL.md +1 -0
- package/src/home-paths.ts +11 -9
- package/src/menu.ts +11 -0
package/cli/commands/test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
let pollRes: Response
|
|
1021
|
+
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
1022
|
+
let response: Response
|
|
897
1023
|
try {
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
903
|
-
|
|
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
|
-
|
|
1038
|
+
if (interrupted) break
|
|
1039
|
+
return 1
|
|
906
1040
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
-
`
|
|
1129
|
+
` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
|
|
956
1130
|
)
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
}
|
package/cli/main.ts
CHANGED
|
@@ -22,7 +22,10 @@ import { IS_STANDALONE } from './standalone'
|
|
|
22
22
|
// "command timed out after 10s" message and is uncopyable for a bug report.
|
|
23
23
|
// rejections that don't match this shape still get the verbose stack so we
|
|
24
24
|
// don't paper over real bugs.
|
|
25
|
-
function handleTopLevelError(err: unknown): never {
|
|
25
|
+
async function handleTopLevelError(err: unknown): Promise<never> {
|
|
26
|
+
const { trackCliError, flushCliTelemetry } = await import('./telemetry')
|
|
27
|
+
trackCliError(err)
|
|
28
|
+
await flushCliTelemetry()
|
|
26
29
|
const msg = err instanceof Error ? err.message : String(err)
|
|
27
30
|
// peach's known top-level errors are all single-line, narrow strings.
|
|
28
31
|
// everything else gets the full stack so unexpected crashes still surface.
|
|
@@ -595,6 +598,11 @@ try {
|
|
|
595
598
|
break
|
|
596
599
|
}
|
|
597
600
|
|
|
601
|
+
case 'project-report': {
|
|
602
|
+
const { runProjectReport } = await import('./commands/project-report')
|
|
603
|
+
await exitWithFlush(runProjectReport(parsed.commandArgs))
|
|
604
|
+
}
|
|
605
|
+
|
|
598
606
|
case 'report-issue': {
|
|
599
607
|
const { runReportIssue } = await import('./commands/report-issue')
|
|
600
608
|
const code = await runReportIssue(parsed.commandArgs)
|
|
@@ -714,5 +722,7 @@ try {
|
|
|
714
722
|
throw error
|
|
715
723
|
}
|
|
716
724
|
|
|
725
|
+
const { flushCliTelemetry } = await import('./telemetry')
|
|
726
|
+
await flushCliTelemetry()
|
|
717
727
|
await scheduleAutomaticCliUpdate(0)
|
|
718
728
|
await scheduleAutomaticCleanup()
|
|
@@ -38,7 +38,8 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
|
|
|
38
38
|
category: 'bounded-diagnostics',
|
|
39
39
|
destination: 'configured PostHog ingestion host',
|
|
40
40
|
preConsent: false,
|
|
41
|
-
payload:
|
|
41
|
+
payload:
|
|
42
|
+
'anonymous install id, CLI/runtime versions, OS/arch, command name, package compatibility fields, error class and bounded path-redacted first-line error/warning messages',
|
|
42
43
|
},
|
|
43
44
|
contrast_user_action: {
|
|
44
45
|
category: 'explicit-user-action',
|
package/cli/privacy.ts
CHANGED
|
@@ -23,7 +23,6 @@ export function diagnosticsAllowedForCurrentProcess(
|
|
|
23
23
|
): boolean {
|
|
24
24
|
const doNotTrack = process.env.DO_NOT_TRACK
|
|
25
25
|
if (doNotTrack === '1' || doNotTrack === 'true') return false
|
|
26
|
-
if (!canPromptForPrivacyChoice()) return false
|
|
27
26
|
return preferences.configured && preferences.productAnalytics
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -42,7 +41,7 @@ export async function ensurePrivacyChoice(options: {
|
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
const allowed = await confirm(
|
|
45
|
-
'Share anonymous package incompatibilities and error
|
|
44
|
+
'Share anonymous diagnostics, including CI and agent runs: CLI/runtime versions, OS/arch, command name, random install ID, package incompatibilities, error class, and first-line error/warning messages (300 characters, paths redacted; no arguments, stacks, source or structured app data)?',
|
|
46
45
|
false,
|
|
47
46
|
)
|
|
48
47
|
// the engine's historical analytics and crash transports carry broader
|