peachsim 0.1.625 → 0.1.627
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/cli/commands/box/bundle-plane.ts +7 -2
- package/cli/commands/box.ts +9 -2
- package/cli/commands/control.ts +32 -1
- package/cli/commands/flow.ts +17 -6
- package/cli/commands/maestro-generate.ts +5 -12
- package/cli/commands/maestro.ts +16 -6
- package/cli/commands/platform.ts +1 -0
- package/cli/commands/test.ts +14 -0
- package/cli/commands/upload.ts +18 -8
- package/cli/drivers/playwright-sim-host.ts +10 -3
- package/cli/drivers/playwright.ts +8 -5
- package/cli/run-rnx.ts +8 -4
- package/dist-lib/agent-daemon-client.cjs +2 -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 +4 -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 +18 -8
- package/dist-lib/capture-contract.mjs +18 -8
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +239 -102
- 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 +19 -8
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/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 +215 -28
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +292 -128
- 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 +14488 -14349
- package/dist-lib/vite-kotlin.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +2 -1
- package/install.mjs +16 -0
- package/install.sh +199 -0
- package/package.json +6 -1
- package/scripts/dev-server-scanner.ts +77 -46
- package/src/bridge-constants.ts +11 -0
- package/src/capture-contract.ts +40 -7
- package/src/host/bridge-host.ts +24 -103
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ integration library used to drive a browser-native React Native simulator.
|
|
|
8
8
|
The CLI and library are separate public artifacts. The recommended installer
|
|
9
9
|
downloads one Bun-compiled `peach` executable that needs no Node.js, npm, or Bun
|
|
10
10
|
installation. The `peachsim` npm package contains bundler plugins, testing
|
|
11
|
-
drivers, and library exports
|
|
11
|
+
drivers, and library exports; its only command, `npx peachsim`, runs that same
|
|
12
|
+
installer from the copy of `install.sh` it ships.
|
|
12
13
|
|
|
13
14
|
The executable updates itself with `peach upgrade`. Successful interactive human
|
|
14
15
|
commands refresh a cached stable CLI version in a detached process at most once
|
|
@@ -220,7 +221,7 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
|
|
|
220
221
|
## Getting started
|
|
221
222
|
|
|
222
223
|
```sh
|
|
223
|
-
curl -fsSL https://peachsim.com/install.sh | sh
|
|
224
|
+
curl -fsSL https://peachsim.com/install.sh | sh # or: npx peachsim
|
|
224
225
|
peach # open ConnectRN and choose a running app in the simulator
|
|
225
226
|
peach open /settings # dispatch a React Native deep link into that app
|
|
226
227
|
```
|
|
@@ -8,15 +8,20 @@
|
|
|
8
8
|
// the bundle is split here rather than by the caller, so the one place that
|
|
9
9
|
// decides where the parts go is the one place that decides how big they are.
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
PREBUILT_BUNDLE_DEVICE_PATH,
|
|
13
|
+
PREBUILT_BUNDLE_PART_BYTES,
|
|
14
|
+
prebuiltBundlePartPath,
|
|
15
|
+
} from '@peach/box/template'
|
|
12
16
|
import type { ProjectFileMeta, ProjectFilePlane } from '@peach/box/plane'
|
|
13
17
|
|
|
14
18
|
export class BundleFilePlane implements ProjectFilePlane {
|
|
15
19
|
private readonly parts = new Map<string, Uint8Array>()
|
|
16
20
|
private readonly entries = new Map<string, ProjectFileMeta>()
|
|
17
21
|
|
|
18
|
-
constructor(bundle: Uint8Array) {
|
|
22
|
+
constructor(bundle: Uint8Array, device: string | null) {
|
|
19
23
|
if (bundle.byteLength === 0) throw new Error('a Box bundle cannot be empty')
|
|
24
|
+
if (device) this.record(PREBUILT_BUNDLE_DEVICE_PATH, new TextEncoder().encode(device))
|
|
20
25
|
for (
|
|
21
26
|
let offset = 0, index = 0;
|
|
22
27
|
offset < bundle.byteLength;
|
package/cli/commands/box.ts
CHANGED
|
@@ -527,6 +527,8 @@ function cloudBoxClient(options: {
|
|
|
527
527
|
*/
|
|
528
528
|
export async function createPrebuiltBundleBox(options: {
|
|
529
529
|
bundle: Uint8Array
|
|
530
|
+
// the simulator's device model, or null for the Box page's default
|
|
531
|
+
device: string | null
|
|
530
532
|
name: string
|
|
531
533
|
endpoint: string
|
|
532
534
|
apiOrigin: string
|
|
@@ -537,7 +539,7 @@ export async function createPrebuiltBundleBox(options: {
|
|
|
537
539
|
name: options.name,
|
|
538
540
|
stack: prebuiltBundleBoxTemplate.stack,
|
|
539
541
|
template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
|
|
540
|
-
source: new BundleFilePlane(options.bundle),
|
|
542
|
+
source: new BundleFilePlane(options.bundle, options.device),
|
|
541
543
|
})
|
|
542
544
|
}
|
|
543
545
|
|
|
@@ -653,7 +655,12 @@ async function runCloudBoxCreate(
|
|
|
653
655
|
try {
|
|
654
656
|
const box =
|
|
655
657
|
'bundle' in source
|
|
656
|
-
? await createPrebuiltBundleBox({
|
|
658
|
+
? await createPrebuiltBundleBox({
|
|
659
|
+
...connection,
|
|
660
|
+
bundle: source.bundle,
|
|
661
|
+
device: null,
|
|
662
|
+
name,
|
|
663
|
+
})
|
|
657
664
|
: await cloudBoxClient(connection).create({
|
|
658
665
|
name,
|
|
659
666
|
stack: template.stack,
|
package/cli/commands/control.ts
CHANGED
|
@@ -840,6 +840,9 @@ export interface BuildShellUrlOptions {
|
|
|
840
840
|
// screen holding a focused text field never reaches three identical idle
|
|
841
841
|
// frames, so the screenshot guard burns all 12 attempts and then fails.
|
|
842
842
|
proof?: boolean
|
|
843
|
+
// launch-time environment variables, stamped as repeated ?env=KEY%3DVALUE.
|
|
844
|
+
// see the --env flag above.
|
|
845
|
+
env?: string[]
|
|
843
846
|
}
|
|
844
847
|
|
|
845
848
|
function bundleUrlOf(urlStr: string): string | undefined {
|
|
@@ -867,12 +870,20 @@ export async function buildShellUrl(
|
|
|
867
870
|
await stampAppSplash(await stampAppFonts(resolved, project), project),
|
|
868
871
|
project,
|
|
869
872
|
)
|
|
870
|
-
if (
|
|
873
|
+
if (
|
|
874
|
+
!opts.device &&
|
|
875
|
+
!opts.proof &&
|
|
876
|
+
!opts.posture &&
|
|
877
|
+
!opts.osVersion &&
|
|
878
|
+
!opts.env?.length
|
|
879
|
+
)
|
|
880
|
+
return stamped
|
|
871
881
|
const url = new URL(stamped)
|
|
872
882
|
if (opts.device) url.searchParams.set('device', opts.device)
|
|
873
883
|
if (opts.posture) url.searchParams.set('posture', opts.posture)
|
|
874
884
|
if (opts.osVersion) url.searchParams.set('osVersion', opts.osVersion)
|
|
875
885
|
if (opts.proof) url.searchParams.set('proof', '1')
|
|
886
|
+
for (const entry of opts.env ?? []) url.searchParams.append('env', entry)
|
|
876
887
|
return url.toString()
|
|
877
888
|
}
|
|
878
889
|
|
|
@@ -1412,6 +1423,7 @@ export async function runOpenCommand(
|
|
|
1412
1423
|
'--posture',
|
|
1413
1424
|
'--os-version',
|
|
1414
1425
|
'--viewport',
|
|
1426
|
+
'--env',
|
|
1415
1427
|
],
|
|
1416
1428
|
})
|
|
1417
1429
|
const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
|
|
@@ -1621,6 +1633,24 @@ export async function runOpenCommand(
|
|
|
1621
1633
|
rnxExit(1)
|
|
1622
1634
|
}
|
|
1623
1635
|
|
|
1636
|
+
// --env <KEY=VALUE> (repeatable) sets a launch-time environment variable,
|
|
1637
|
+
// the peach analogue of an Xcode scheme env var or SIMCTL_CHILD_<VAR>:
|
|
1638
|
+
// stamped as repeated ?env=, carried on AppLoadRequest, and read into a
|
|
1639
|
+
// Swift guest's ProcessInfo.environment (WASI environ) exactly as it would
|
|
1640
|
+
// read a real launch env. the guest app opts in by reading the key itself;
|
|
1641
|
+
// no other surface is affected.
|
|
1642
|
+
const envArgs: string[] = []
|
|
1643
|
+
for (let i = 0; i < args.length; i++) {
|
|
1644
|
+
if (args[i] !== '--env') continue
|
|
1645
|
+
const raw = args[i + 1]
|
|
1646
|
+
const eqIndex = raw ? raw.indexOf('=') : -1
|
|
1647
|
+
if (!raw || eqIndex <= 0) {
|
|
1648
|
+
console.error(` peach open: --env expects KEY=VALUE, got "${raw ?? ''}"`)
|
|
1649
|
+
rnxExit(1)
|
|
1650
|
+
}
|
|
1651
|
+
envArgs.push(raw)
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1624
1654
|
// --viewport <WxH> sizes the driver-launched page explicitly (film and
|
|
1625
1655
|
// promo captures frame the 3d stage in landscape). takes precedence over
|
|
1626
1656
|
// the device-derived window; without either, the driver keeps its own
|
|
@@ -1645,6 +1675,7 @@ export async function runOpenCommand(
|
|
|
1645
1675
|
resolvedBundle,
|
|
1646
1676
|
project,
|
|
1647
1677
|
proof: args.includes('--proof'),
|
|
1678
|
+
env: envArgs.length ? envArgs : undefined,
|
|
1648
1679
|
}
|
|
1649
1680
|
|
|
1650
1681
|
// resolve the shell base URL from the bridge port we settled on, so the page
|
package/cli/commands/flow.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { parseFlowFile, validateFlowFile } from '../flow-file'
|
|
|
21
21
|
import { FlowLiveStatusReporter } from '../flow-live-status'
|
|
22
22
|
import { finalizeFlowSession, keepFlowCandidate, startFlowSession } from '../flow-session'
|
|
23
23
|
import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args'
|
|
24
|
-
import { rnxExit } from '../run-rnx'
|
|
24
|
+
import { PeachExit, rnxExit } from '../run-rnx'
|
|
25
25
|
import {
|
|
26
26
|
BRIDGE_VALUE_FLAGS,
|
|
27
27
|
createBridge,
|
|
@@ -155,6 +155,13 @@ export function getLastFlowPreviewUploadResult(): UploadResult | null {
|
|
|
155
155
|
return lastPreviewUploadResult
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// why the last flow's preview upload failed, as the upload printed it
|
|
159
|
+
let lastPreviewUploadError: string | null = null
|
|
160
|
+
|
|
161
|
+
export function getLastFlowPreviewUploadError(): string | null {
|
|
162
|
+
return lastPreviewUploadError
|
|
163
|
+
}
|
|
164
|
+
|
|
158
165
|
// per-step trace of the most recent playback, preview or not — callers
|
|
159
166
|
// Maestro callers fold this into their run registration.
|
|
160
167
|
let lastFlowTraceSteps: SootSimFlowTraceStep[] = []
|
|
@@ -211,6 +218,7 @@ export async function runFlowPlayback(
|
|
|
211
218
|
hooks: { beforeSteps?: () => Promise<void> } = {},
|
|
212
219
|
): Promise<number> {
|
|
213
220
|
lastPreviewUploadResult = null
|
|
221
|
+
lastPreviewUploadError = null
|
|
214
222
|
lastFlowTraceSteps = []
|
|
215
223
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
216
224
|
console.log(`
|
|
@@ -278,7 +286,8 @@ options:
|
|
|
278
286
|
public link origin for --preview (default: peachsim.com
|
|
279
287
|
for prod uploads)
|
|
280
288
|
--billing-kind test_run
|
|
281
|
-
|
|
289
|
+
the chained upload is a hosted test job's recording,
|
|
290
|
+
covered by the job's minutes (set by the hosted recorder)
|
|
282
291
|
--owner <org> associate preview upload with a linked org repo
|
|
283
292
|
--repo <repo> repo name for --owner; user must belong to the team
|
|
284
293
|
|
|
@@ -631,9 +640,9 @@ flow extension:
|
|
|
631
640
|
// pr-preview run, so allow it for preview uploads. local flow recording
|
|
632
641
|
// writes a file and matches `peach record --mode video`.
|
|
633
642
|
allowGitHubRecording: preview,
|
|
634
|
-
// a test_run
|
|
635
|
-
//
|
|
636
|
-
//
|
|
643
|
+
// a test_run recording is covered by its hosted job's minutes, not by a
|
|
644
|
+
// plan entitlement: the plan gate would refuse every pay-as-you-go
|
|
645
|
+
// hosted test.
|
|
637
646
|
requireRecordingEntitlement: preview && previewBillingKind !== 'test_run',
|
|
638
647
|
// re-arm the event recorder after every launchApp-with-reload. the
|
|
639
648
|
// recorder lives in-page state and gets wiped by window.location.reload.
|
|
@@ -947,7 +956,9 @@ flow extension:
|
|
|
947
956
|
const { runUpload } = await import('./upload')
|
|
948
957
|
lastPreviewUploadResult = await runUpload(uploadArgs, {})
|
|
949
958
|
} catch (err: any) {
|
|
950
|
-
|
|
959
|
+
lastPreviewUploadError =
|
|
960
|
+
err instanceof PeachExit && err.reason ? err.reason : String(err?.message || err)
|
|
961
|
+
console.error(` preview upload failed: ${lastPreviewUploadError}`)
|
|
951
962
|
exitCode = 1
|
|
952
963
|
}
|
|
953
964
|
}
|
|
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync } from 'fs'
|
|
|
4
4
|
import { tmpdir } from 'os'
|
|
5
5
|
import { join } from 'path'
|
|
6
6
|
import yaml from 'yaml'
|
|
7
|
+
import { isRecord } from '../../src/is-record'
|
|
7
8
|
import { authHeaderValue, resolveCliAuth } from '../auth'
|
|
8
9
|
import { registerRun, stepSummaryFromTrace } from '../run-registry'
|
|
9
10
|
import { createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
|
|
@@ -18,7 +19,6 @@ import {
|
|
|
18
19
|
} from './flow'
|
|
19
20
|
import { inspectDescribe } from './inspect/core'
|
|
20
21
|
import { resolveDefaultUploadOrigin } from './upload'
|
|
21
|
-
import { isRecord } from '../../src/is-record'
|
|
22
22
|
|
|
23
23
|
const DEFAULT_TEST_MODEL = process.env.PEACH_TEST_MODEL || 'deepseek-v4-flash'
|
|
24
24
|
const DESCRIBE_CONTEXT_LIMIT = 18_000
|
|
@@ -239,14 +239,7 @@ export function buildFlowArgs(
|
|
|
239
239
|
flowPath: string,
|
|
240
240
|
origin: string,
|
|
241
241
|
) {
|
|
242
|
-
const out = [
|
|
243
|
-
flowPath,
|
|
244
|
-
'--preview',
|
|
245
|
-
'--preview-origin',
|
|
246
|
-
origin,
|
|
247
|
-
'--billing-kind',
|
|
248
|
-
'test_run',
|
|
249
|
-
]
|
|
242
|
+
const out = [flowPath, '--preview', '--preview-origin', origin]
|
|
250
243
|
for (let i = 0; i < args.length; i++) {
|
|
251
244
|
if (i === promptIndex) continue
|
|
252
245
|
const arg = args[i]
|
|
@@ -285,10 +278,10 @@ export function findUnexpectedGenerateArgs(
|
|
|
285
278
|
if (i === promptIndex) continue
|
|
286
279
|
const arg = args[i]
|
|
287
280
|
if (!arg) continue
|
|
288
|
-
//
|
|
289
|
-
//
|
|
281
|
+
// a generated flow's recording is an ordinary preview upload; the only
|
|
282
|
+
// billing kind is a hosted test job's, which the recorder sets.
|
|
290
283
|
if (arg === '--billing-kind') {
|
|
291
|
-
problems.push({ arg, message: `${arg} is
|
|
284
|
+
problems.push({ arg, message: `${arg} is only for hosted test jobs` })
|
|
292
285
|
i += 1
|
|
293
286
|
continue
|
|
294
287
|
}
|
package/cli/commands/maestro.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { RnxExit } from '../run-rnx'
|
|
|
26
26
|
import {
|
|
27
27
|
findUnexpectedFlowArgs,
|
|
28
28
|
FLOW_ARG_VALUE_FLAGS,
|
|
29
|
+
getLastFlowPreviewUploadError,
|
|
29
30
|
getLastFlowPreviewUploadResult,
|
|
30
31
|
getLastFlowTraceSteps,
|
|
31
32
|
hoistLeadingSimFlag,
|
|
@@ -728,6 +729,10 @@ export async function runMaestro(
|
|
|
728
729
|
console.error(` error: ${target} not found`)
|
|
729
730
|
return 1
|
|
730
731
|
}
|
|
732
|
+
// cwd is the real path, so name every flow from the target's real path too:
|
|
733
|
+
// a target under a symlink (macOS /tmp, /var/folders) otherwise reports as
|
|
734
|
+
// ../../../var/folders/... instead of flows/login.yaml.
|
|
735
|
+
target = fs.realpathSync(target)
|
|
731
736
|
|
|
732
737
|
let files: string[]
|
|
733
738
|
let workspaceConfig: MaestroWorkspaceConfig = {}
|
|
@@ -886,24 +891,29 @@ export async function runMaestro(
|
|
|
886
891
|
// the upload failed, so the flow never disappears from the collected
|
|
887
892
|
// entries.
|
|
888
893
|
if (args.includes('--preview')) {
|
|
894
|
+
const uploaded = Boolean(upload?.shareId || upload?.previewUrl)
|
|
889
895
|
const previewPayload = {
|
|
890
896
|
flow: path.relative(cwd, file) || file,
|
|
891
897
|
previewId: upload?.shareId ?? null,
|
|
892
898
|
previewUrl: upload?.previewUrl ?? null,
|
|
893
|
-
// the failing step as the junit report names it, on one line
|
|
899
|
+
// the failing step as the junit report names it, on one line, else
|
|
900
|
+
// why the required recording upload failed
|
|
894
901
|
failure:
|
|
895
|
-
exit === 0
|
|
902
|
+
exit === 0 && uploaded
|
|
896
903
|
? null
|
|
897
|
-
: failureFromTraceSteps(
|
|
898
|
-
|
|
899
|
-
|
|
904
|
+
: failureFromTraceSteps(
|
|
905
|
+
traceSteps,
|
|
906
|
+
uploaded
|
|
907
|
+
? 'flow playback failed'
|
|
908
|
+
: `recording upload failed: ${getLastFlowPreviewUploadError() ?? 'no preview returned'}`,
|
|
909
|
+
).body.split('\n')[0],
|
|
900
910
|
}
|
|
901
911
|
try {
|
|
902
912
|
for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
|
|
903
913
|
fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
|
|
904
914
|
}
|
|
905
915
|
} catch {}
|
|
906
|
-
if (!
|
|
916
|
+
if (!uploaded) {
|
|
907
917
|
console.error(' error: required hosted recording upload failed')
|
|
908
918
|
if (exit === 0) exit = 1
|
|
909
919
|
if (worstExit === 0) worstExit = 1
|
package/cli/commands/platform.ts
CHANGED
|
@@ -454,6 +454,7 @@ async function runRemoteBox(
|
|
|
454
454
|
}
|
|
455
455
|
const created = await box.createPrebuiltBundleBox({
|
|
456
456
|
bundle: new Uint8Array(artifact.bytes),
|
|
457
|
+
device: selectedDevice,
|
|
457
458
|
name: input.name ?? boxNameForBundle(input.bundlePath),
|
|
458
459
|
endpoint,
|
|
459
460
|
apiOrigin: box.cloudBoxApiOrigin(args, auth),
|
package/cli/commands/test.ts
CHANGED
|
@@ -873,6 +873,20 @@ export async function runCloudTest(
|
|
|
873
873
|
|
|
874
874
|
const { id } = (await response.json()) as { id: string }
|
|
875
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
|
+
})
|
|
886
|
+
}
|
|
887
|
+
process.once('SIGINT', cancelOnSignal)
|
|
888
|
+
process.once('SIGTERM', cancelOnSignal)
|
|
889
|
+
}
|
|
876
890
|
|
|
877
891
|
const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
|
|
878
892
|
const startedAt = Date.now()
|
package/cli/commands/upload.ts
CHANGED
|
@@ -194,7 +194,8 @@ options:
|
|
|
194
194
|
--repo <repo> repo name for --owner; the signed-in user must belong
|
|
195
195
|
to the local/org team that owns the link
|
|
196
196
|
--billing-kind test_run
|
|
197
|
-
|
|
197
|
+
the recording of a hosted test job, covered by the
|
|
198
|
+
job's minutes (set by the hosted recorder)
|
|
198
199
|
--sim <sim> target a specific sim (see: peach list)
|
|
199
200
|
--open open the resulting /preview/<id> url in the browser
|
|
200
201
|
--assets-only drop API/JSON/HTML records before upload; keep images,
|
|
@@ -476,6 +477,12 @@ export async function runUpload(
|
|
|
476
477
|
console.error(` invalid --billing-kind: ${billingKind}`)
|
|
477
478
|
rnxExit(1)
|
|
478
479
|
}
|
|
480
|
+
// the hosted recorder names the job whose minutes cover this recording
|
|
481
|
+
const runnerJobId = process.env.CONTRAST_RUNNER_JOB_ID
|
|
482
|
+
if (billingKind === 'test_run' && !runnerJobId) {
|
|
483
|
+
console.error(' --billing-kind test_run is only for hosted test jobs')
|
|
484
|
+
rnxExit(1)
|
|
485
|
+
}
|
|
479
486
|
if (!eventsPath && !videoPath) {
|
|
480
487
|
console.error(
|
|
481
488
|
' preview uploads require playback data: pass --events or --video.\n' +
|
|
@@ -1045,6 +1052,7 @@ export async function runUpload(
|
|
|
1045
1052
|
pullRequestNumber: provenance.pullRequestNumber ?? undefined,
|
|
1046
1053
|
pullRequestTitle: provenance.pullRequestTitle ?? undefined,
|
|
1047
1054
|
billingKind: billingKind === 'test_run' ? 'test_run' : undefined,
|
|
1055
|
+
runnerJobId: billingKind === 'test_run' ? runnerJobId : undefined,
|
|
1048
1056
|
runScope: runScope || undefined,
|
|
1049
1057
|
files: prepared.map((f) => ({
|
|
1050
1058
|
url: f.url,
|
|
@@ -1161,19 +1169,21 @@ export async function runUpload(
|
|
|
1161
1169
|
if (e.phase === 'init' && e.status === 401) {
|
|
1162
1170
|
console.error(' preview upload requires a valid login.')
|
|
1163
1171
|
console.error(' run `peach login` and retry.')
|
|
1164
|
-
rnxExit(1)
|
|
1172
|
+
rnxExit(1, 'preview upload requires a valid login')
|
|
1165
1173
|
}
|
|
1166
1174
|
if (e.status === 402) {
|
|
1167
1175
|
// billing gate — surface the server's own `message`, not raw JSON.
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1176
|
+
const message =
|
|
1177
|
+
parseServerMessage(e.responseText) ??
|
|
1178
|
+
'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'
|
|
1179
|
+
console.error(` ${message}`)
|
|
1180
|
+
rnxExit(1, message)
|
|
1172
1181
|
}
|
|
1173
1182
|
// PresignedUploadError.message already carries the "<phase> failed:"
|
|
1174
1183
|
// prefix — don't re-prefix it (that produced "init failed: init failed:").
|
|
1175
|
-
|
|
1176
|
-
|
|
1184
|
+
const message = e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`
|
|
1185
|
+
console.error(` ${message}`)
|
|
1186
|
+
rnxExit(1, message)
|
|
1177
1187
|
}
|
|
1178
1188
|
|
|
1179
1189
|
const previewUrl = `${publicOrigin}${finalize.url}`
|
|
@@ -7,8 +7,10 @@ import { createBrowserPointerDispatcher } from '../../../peach-engine/src/render
|
|
|
7
7
|
//
|
|
8
8
|
// it resolves playwright from the absolute path the driver hands it, launches
|
|
9
9
|
// Chrome for Testing, opens the target url, then keeps itself (and therefore
|
|
10
|
-
// the browser) alive until the
|
|
11
|
-
// the
|
|
10
|
+
// the browser) alive until the page asks it to close (`peach close`, or the
|
|
11
|
+
// bridge reaping the sim after AUTOMATION_SIM_IDLE_REAP_TTL_MS with no cli),
|
|
12
|
+
// the browser disconnects, or its owning session exits. all inputs arrive
|
|
13
|
+
// through the environment.
|
|
12
14
|
//
|
|
13
15
|
// it stays a source string rather than a module of its own: playwright is
|
|
14
16
|
// resolved from the user's project at runtime and is not a dependency here, so
|
|
@@ -515,7 +517,12 @@ const cleanupProfile = () => {
|
|
|
515
517
|
process.stderr.write('[console.error] ' + m.text() + '\\n');
|
|
516
518
|
}
|
|
517
519
|
});
|
|
518
|
-
page
|
|
520
|
+
// a crashed renderer never runs the page's bridge close path, so nothing
|
|
521
|
+
// would ever close this host.
|
|
522
|
+
page.on('crash', () => {
|
|
523
|
+
process.stderr.write('[page] renderer crashed; closing playwright host\\n');
|
|
524
|
+
void shutdown(1);
|
|
525
|
+
});
|
|
519
526
|
try {
|
|
520
527
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
521
528
|
} catch (err) {
|
|
@@ -16,6 +16,7 @@ import { spawn } from 'child_process'
|
|
|
16
16
|
import { closeSync, mkdtempSync, openSync, readFileSync } from 'fs'
|
|
17
17
|
import { tmpdir } from 'os'
|
|
18
18
|
import { join } from 'path'
|
|
19
|
+
import { AUTOMATION_SIM_IDLE_REAP_TTL_MS } from '../../src/bridge-constants'
|
|
19
20
|
import { playwrightHostLogName } from '../../src/playwright-host-log'
|
|
20
21
|
import { ensureProfile, playwrightProfileUserDataDir } from '../../src/profiles'
|
|
21
22
|
import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
|
|
@@ -172,12 +173,14 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
|
|
|
172
173
|
}${hint ? `\n${hint}` : ''}`,
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
|
-
// name
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// the
|
|
176
|
+
// name both lifetime bounds: the bridge closes an automation sim no cli
|
|
177
|
+
// has touched for the idle window, and the host watchdog closes chrome ~5s
|
|
178
|
+
// after the owning session pid exits, which surprises one-shot/detached
|
|
179
|
+
// shells (their topmost non-init ancestor is the shell itself, so the sim
|
|
180
|
+
// dies the moment the command finishes).
|
|
179
181
|
const ownerComm = getProcessComm(ownerPid)
|
|
180
|
-
const
|
|
182
|
+
const idleMinutes = AUTOMATION_SIM_IDLE_REAP_TTL_MS / 60_000
|
|
183
|
+
const ownerNote = ` — browser closes after ${idleMinutes} min with no peach command, or when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits`
|
|
181
184
|
return {
|
|
182
185
|
launched: true,
|
|
183
186
|
message: `playwright chrome launched → ${opts.url}${ownerNote}`,
|
package/cli/run-rnx.ts
CHANGED
|
@@ -38,13 +38,17 @@ interface RnxSinks {
|
|
|
38
38
|
// thrown by `peachExit` in place of process.exit. every catch that reports an
|
|
39
39
|
// error must re-throw this before reporting, or it turns a deliberate exit
|
|
40
40
|
// into a spurious "command failed" line with the sentinel's own text.
|
|
41
|
+
// `reason` carries the error the command already printed, so an in-process
|
|
42
|
+
// caller can report why instead of only the exit code.
|
|
41
43
|
export class PeachExit extends Error {
|
|
42
44
|
readonly code: number
|
|
45
|
+
readonly reason: string | null
|
|
43
46
|
|
|
44
|
-
constructor(code: number) {
|
|
47
|
+
constructor(code: number, reason: string | null = null) {
|
|
45
48
|
super(`peach exited with code ${code}`)
|
|
46
49
|
this.name = 'PeachExit'
|
|
47
50
|
this.code = code
|
|
51
|
+
this.reason = reason
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -61,11 +65,11 @@ export function rethrowIfExit(error: unknown): void {
|
|
|
61
65
|
|
|
62
66
|
// the in-process replacement for process.exit. returns `never` exactly as
|
|
63
67
|
// process.exit does, so it substitutes without changing any call site's types.
|
|
64
|
-
export function peachExit(code: number): never {
|
|
65
|
-
throw new PeachExit(code)
|
|
68
|
+
export function peachExit(code: number, reason?: string): never {
|
|
69
|
+
throw new PeachExit(code, reason)
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
export const rnxExit: (code: number) => never = peachExit
|
|
72
|
+
export const rnxExit: (code: number, reason?: string) => never = peachExit
|
|
69
73
|
|
|
70
74
|
function write(stream: 'stdout' | 'stderr', text: string): boolean {
|
|
71
75
|
const current = sinks.getStore()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -218,6 +218,7 @@ function fileFromImportMeta() {
|
|
|
218
218
|
// src/bridge-constants.ts
|
|
219
219
|
var DEFAULT_SOOTSIM_BRIDGE_PORT = 7668;
|
|
220
220
|
var DEFAULT_SOOTSIM_BRIDGE_URL = `http://localhost:${DEFAULT_SOOTSIM_BRIDGE_PORT}/`;
|
|
221
|
+
var AUTOMATION_SIM_IDLE_REAP_TTL_MS = 5 * 6e4;
|
|
221
222
|
|
|
222
223
|
// src/agent-daemon-client.ts
|
|
223
224
|
var AgentDaemonError = class extends Error {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
package/dist-lib/beta.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
package/dist-lib/beta.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -22,6 +22,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
22
22
|
// src/bridge-constants.ts
|
|
23
23
|
var bridge_constants_exports = {};
|
|
24
24
|
__export(bridge_constants_exports, {
|
|
25
|
+
AUTOMATION_SIM_IDLE_REAP_TTL_MS: () => AUTOMATION_SIM_IDLE_REAP_TTL_MS,
|
|
25
26
|
DEFAULT_SOOTSIM_BRIDGE_PORT: () => DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
26
27
|
DEFAULT_SOOTSIM_BRIDGE_URL: () => DEFAULT_SOOTSIM_BRIDGE_URL,
|
|
27
28
|
SOOTSIM_BRIDGE_SIM_CLOSE_CODE: () => SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
|
|
@@ -145,8 +146,10 @@ function isSootsimBridgeHealth(value, expectedPort) {
|
|
|
145
146
|
}
|
|
146
147
|
var SOOTSIM_BRIDGE_SIM_CLOSE_CODE = 4001;
|
|
147
148
|
var SOOTSIM_BRIDGE_SIM_CLOSE_REASON = "sootsim close";
|
|
149
|
+
var AUTOMATION_SIM_IDLE_REAP_TTL_MS = 5 * 6e4;
|
|
148
150
|
// Annotate the CommonJS export names for ESM import in node:
|
|
149
151
|
0 && (module.exports = {
|
|
152
|
+
AUTOMATION_SIM_IDLE_REAP_TTL_MS,
|
|
150
153
|
DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
151
154
|
DEFAULT_SOOTSIM_BRIDGE_URL,
|
|
152
155
|
SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.627 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|