peachsim 0.1.627 → 0.1.628
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/LICENSE +1 -1
- package/cli/bin.ts +6 -2
- package/cli/bridge-flow-runner.ts +79 -72
- package/cli/commands/cleanup.ts +6 -6
- package/cli/commands/control.ts +4 -5
- package/cli/commands/detox.ts +25 -11
- package/cli/commands/flow.ts +3 -2
- package/cli/commands/inspect.ts +22 -37
- package/cli/commands/mode.ts +11 -7
- package/cli/commands/report-issue.ts +1 -1
- package/cli/commands/setup.ts +16 -11
- package/cli/commands/show-touches-mode.ts +33 -0
- package/cli/commands/skills.ts +16 -0
- package/cli/commands/test.ts +8 -1
- package/cli/commands/welcome.ts +3 -3
- package/cli/drivers/playwright-sim-host.ts +6 -5
- package/cli/help-core.ts +9 -4
- package/cli/internal-child.ts +14 -2
- package/cli/levenshtein.ts +27 -0
- package/cli/main.ts +28 -18
- package/cli/ws-bridge.ts +3 -2
- package/detox/index.ts +234 -7
- 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 +170 -13
- package/dist-lib/dev-bundle-resolution.cjs +2 -2
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +42 -37
- 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 +44 -5
- 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 +7 -1
- package/dist-lib/menu.mjs +7 -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 +43 -4
- 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 +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +140 -149
- 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 +7234 -7069
- package/dist-lib/vite-kotlin.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +43 -4
- package/package.json +1 -1
- package/skills/contrast/SKILL.md +1 -1
- package/src/dev-bundle-resolution.ts +1 -1
- package/src/host/bridge-host.ts +2 -34
- package/src/menu.ts +7 -0
- package/src/native-seam-manifest.ts +4 -16
- package/src/runtime-assets.ts +59 -2
- package/src/vite-plugin.ts +723 -25
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// peach mode show-touches — toggle OS touch indicators (Show Single Touches / Show taps)
|
|
2
|
+
// in the running peach sim.
|
|
3
|
+
|
|
4
|
+
import { rnxExit } from '../run-rnx'
|
|
5
|
+
import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode'
|
|
6
|
+
|
|
7
|
+
export async function runShowTouchesMode(args: string[], opts: ShellBooleanModeOptions) {
|
|
8
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
9
|
+
console.log(`
|
|
10
|
+
peach mode show-touches — toggle touch indicator overlays
|
|
11
|
+
|
|
12
|
+
usage:
|
|
13
|
+
peach mode show-touches [on|off|toggle]
|
|
14
|
+
|
|
15
|
+
arguments:
|
|
16
|
+
on enable touch indicators
|
|
17
|
+
off disable touch indicators
|
|
18
|
+
toggle flip the current state (default)
|
|
19
|
+
|
|
20
|
+
examples:
|
|
21
|
+
peach mode show-touches
|
|
22
|
+
peach mode show-touches on
|
|
23
|
+
peach mode show-touches off
|
|
24
|
+
`)
|
|
25
|
+
rnxExit(0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await runShellBooleanMode(args, opts, {
|
|
29
|
+
modeKey: 'showTouches',
|
|
30
|
+
displayName: 'show-touches',
|
|
31
|
+
actionId: 'toggle-touches',
|
|
32
|
+
})
|
|
33
|
+
}
|
package/cli/commands/skills.ts
CHANGED
|
@@ -9,10 +9,26 @@ import * as fs from 'fs'
|
|
|
9
9
|
import * as os from 'os'
|
|
10
10
|
import * as path from 'path'
|
|
11
11
|
import { fileURLToPath } from 'url'
|
|
12
|
+
import { getCliVersion } from '../../src/cli-version'
|
|
13
|
+
import { rnxHomeDir } from '../../src/home-paths'
|
|
12
14
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
13
15
|
import { rnxExit } from '../run-rnx'
|
|
14
16
|
|
|
17
|
+
declare const __PEACH_BUNDLED_SKILLS__: Record<string, string>
|
|
18
|
+
|
|
15
19
|
function getSkillsDir(): string {
|
|
20
|
+
// the standalone binary carries its skills instead of a package directory;
|
|
21
|
+
// write them once per cli version so installs can link to real files.
|
|
22
|
+
if (typeof __PEACH_BUNDLED_SKILLS__ !== 'undefined') {
|
|
23
|
+
const dir = path.join(rnxHomeDir(), 'skills', getCliVersion())
|
|
24
|
+
for (const [file, body] of Object.entries(__PEACH_BUNDLED_SKILLS__)) {
|
|
25
|
+
const target = path.join(dir, file)
|
|
26
|
+
if (fs.existsSync(target)) continue
|
|
27
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
28
|
+
fs.writeFileSync(target, body)
|
|
29
|
+
}
|
|
30
|
+
return dir
|
|
31
|
+
}
|
|
16
32
|
try {
|
|
17
33
|
const pkgPath = fileURLToPath(
|
|
18
34
|
import.meta.resolve(`${rnxPublicBrand.packageName}/package.json`),
|
package/cli/commands/test.ts
CHANGED
|
@@ -984,7 +984,14 @@ export async function runTest(
|
|
|
984
984
|
return runCloudTest(args, opts)
|
|
985
985
|
}
|
|
986
986
|
|
|
987
|
-
|
|
987
|
+
// a missing or unknown target is the caller's mistake: one line, no stack.
|
|
988
|
+
let selected: ReturnType<typeof resolveTestCommand>
|
|
989
|
+
try {
|
|
990
|
+
selected = resolveTestCommand(args)
|
|
991
|
+
} catch (error) {
|
|
992
|
+
console.error(` error: ${error instanceof Error ? error.message : String(error)}`)
|
|
993
|
+
return 1
|
|
994
|
+
}
|
|
988
995
|
const app = appFromArgs(args)
|
|
989
996
|
const appTarget = app
|
|
990
997
|
? await resolveTestAppTarget(app)
|
package/cli/commands/welcome.ts
CHANGED
|
@@ -4,14 +4,14 @@ import { printBrand } from '../ui'
|
|
|
4
4
|
import { runOpenCommand } from './control'
|
|
5
5
|
import { runSetup } from './setup'
|
|
6
6
|
|
|
7
|
-
export async function runWelcome(): Promise<number> {
|
|
7
|
+
export async function runWelcome(port: number | undefined): Promise<number> {
|
|
8
8
|
if (!isOnboardingComplete() && canPromptForPrivacyChoice()) {
|
|
9
|
-
await runSetup([], { launchConnectRN: true })
|
|
9
|
+
await runSetup([], { launchConnectRN: true, port })
|
|
10
10
|
return 0
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
printBrand('Run any React Native app in a local simulator.')
|
|
14
14
|
console.log(`\n Opening ConnectRN`)
|
|
15
|
-
await runOpenCommand(['ConnectRN', '--no-describe', '--quiet'])
|
|
15
|
+
await runOpenCommand(['ConnectRN', '--no-describe', '--quiet'], { port })
|
|
16
16
|
return 0
|
|
17
17
|
}
|
|
@@ -446,16 +446,17 @@ const cleanupProfile = () => {
|
|
|
446
446
|
const request = readBrowserCompositeCaptureRequest(value);
|
|
447
447
|
const session = await source.page.context().newCDPSession(source.page);
|
|
448
448
|
try {
|
|
449
|
+
const { clip, originX, originY } = resolveBrowserCompositeCdpClip(
|
|
450
|
+
request,
|
|
451
|
+
await session.send('Page.getLayoutMetrics'),
|
|
452
|
+
);
|
|
449
453
|
const result = await session.send('Page.captureScreenshot', {
|
|
450
454
|
format: 'png',
|
|
451
455
|
fromSurface: true,
|
|
452
456
|
captureBeyondViewport: true,
|
|
453
|
-
clip
|
|
454
|
-
request,
|
|
455
|
-
await session.send('Page.getLayoutMetrics'),
|
|
456
|
-
),
|
|
457
|
+
clip,
|
|
457
458
|
});
|
|
458
|
-
return 'data:image/png;base64,' + result.data;
|
|
459
|
+
return { dataUrl: 'data:image/png;base64,' + result.data, originX, originY };
|
|
459
460
|
} finally {
|
|
460
461
|
await session.detach();
|
|
461
462
|
}
|
package/cli/help-core.ts
CHANGED
|
@@ -96,8 +96,13 @@ Run \`peach <command> --help\` for leaf availability and usage.`
|
|
|
96
96
|
export function renderRnxHelp(options: RnxHelpOptions): string {
|
|
97
97
|
const command = options.command ?? null
|
|
98
98
|
const commandArgs = options.commandArgs ?? []
|
|
99
|
+
// the preview selectors and backend lists describe the factory's browser
|
|
100
|
+
// backend; the public cli runs the node backend and never sees them.
|
|
101
|
+
const factory = options.backend === 'browser'
|
|
99
102
|
if (!command) {
|
|
100
|
-
|
|
103
|
+
const index = renderCliIndexHelp(HELP_CONTEXT, formatFlagHelp()).trimEnd()
|
|
104
|
+
if (!factory) return index
|
|
105
|
+
return `${index}
|
|
101
106
|
|
|
102
107
|
browser preview selectors:
|
|
103
108
|
peach web select the existing Factory web preview
|
|
@@ -108,7 +113,7 @@ ${renderRootAvailability(options.backend)}`
|
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
let body: string | null
|
|
111
|
-
if (command === 'web' || command === 'ios' || command === 'android') {
|
|
116
|
+
if (factory && (command === 'web' || command === 'ios' || command === 'android')) {
|
|
112
117
|
body = renderPreviewHelp(command)
|
|
113
118
|
} else if (options.group === command && !options.prefer) {
|
|
114
119
|
body = renderCliGroupHelp(command, HELP_CONTEXT, formatFlagHelp())
|
|
@@ -130,11 +135,11 @@ ${renderRootAvailability(options.backend)}`
|
|
|
130
135
|
const registration = getRnxCommandRegistration(command)
|
|
131
136
|
if (!registration) return body
|
|
132
137
|
const current = getRnxCommandAvailability(command, commandArgs, options.backend)
|
|
138
|
+
if (!factory && current.available) return body
|
|
133
139
|
const currentLine = current.available
|
|
134
140
|
? `${options.backend} backend: available`
|
|
135
141
|
: `${options.backend} backend: ${current.reason}`
|
|
136
|
-
const factoryLeaves =
|
|
137
|
-
options.backend === 'browser' ? renderFactoryBrowserLeaves(command) : null
|
|
142
|
+
const factoryLeaves = factory ? renderFactoryBrowserLeaves(command) : null
|
|
138
143
|
return `${body.trimEnd()}
|
|
139
144
|
|
|
140
145
|
${factoryLeaves ? `${factoryLeaves}\n\n` : ''}${backendLabel(command, commandArgs)}
|
package/cli/internal-child.ts
CHANGED
|
@@ -18,6 +18,12 @@ import * as vm from 'node:vm'
|
|
|
18
18
|
|
|
19
19
|
export const RNX_INTERNAL_COMMAND = '__internal'
|
|
20
20
|
|
|
21
|
+
// playwright's installer forks its downloader script with process.execPath,
|
|
22
|
+
// which here is this binary. the installer child sets this for its whole
|
|
23
|
+
// process tree, and bin.ts then runs a forked `.js` argument as a script
|
|
24
|
+
// instead of reading it as a peach command.
|
|
25
|
+
export const RNX_FORKED_SCRIPT_ENV = 'PEACH_RUNS_FORKED_SCRIPTS'
|
|
26
|
+
|
|
21
27
|
export const RNX_INTERNAL_CHILDREN = {
|
|
22
28
|
playwrightProbe: 'playwright-probe',
|
|
23
29
|
playwrightInstall: 'playwright-install',
|
|
@@ -113,8 +119,14 @@ function playwrightInstall(args: string[]): void {
|
|
|
113
119
|
if (!cliPath) {
|
|
114
120
|
throw new Error('internal playwright-install needs the package CLI path')
|
|
115
121
|
}
|
|
116
|
-
process.
|
|
117
|
-
|
|
122
|
+
process.env[RNX_FORKED_SCRIPT_ENV] = '1'
|
|
123
|
+
runScript(cliPath, cliArgs)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** run a javascript file as node would, with argv shaped for its own parser. */
|
|
127
|
+
export function runScript(scriptPath: string, args: string[]): void {
|
|
128
|
+
process.argv = [process.argv[0], scriptPath, ...args]
|
|
129
|
+
createRequire(scriptPath)(scriptPath)
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
/** the detached browser host. its source is CJS that resolves playwright from
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function levenshtein(a: string, b: string): number {
|
|
2
|
+
if (a === b) return 0
|
|
3
|
+
if (!a.length) return b.length
|
|
4
|
+
if (!b.length) return a.length
|
|
5
|
+
// standard two-row dp: prev[] is the previous row, cur[] is the row being
|
|
6
|
+
// computed; swap pointers per outer iteration. the earlier in-place rolling
|
|
7
|
+
// version overwrote prev[j-1] twice per cell and never set prev[0]=i, so
|
|
8
|
+
// distance(`kitten`,`sitting`) returned 8 instead of 3 and the fuzzy-suggest
|
|
9
|
+
// ranking was effectively random.
|
|
10
|
+
let prev = new Array<number>(b.length + 1)
|
|
11
|
+
let cur = new Array<number>(b.length + 1)
|
|
12
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j
|
|
13
|
+
for (let i = 1; i <= a.length; i++) {
|
|
14
|
+
cur[0] = i
|
|
15
|
+
for (let j = 1; j <= b.length; j++) {
|
|
16
|
+
cur[j] = Math.min(
|
|
17
|
+
prev[j] + 1,
|
|
18
|
+
cur[j - 1] + 1,
|
|
19
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
const tmp = prev
|
|
23
|
+
prev = cur
|
|
24
|
+
cur = tmp
|
|
25
|
+
}
|
|
26
|
+
return prev[b.length]
|
|
27
|
+
}
|
package/cli/main.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// when none of its hidden entry points claimed the process.
|
|
4
4
|
|
|
5
5
|
import { rnxPublicBrand } from '../src/public-brand'
|
|
6
|
-
import {
|
|
6
|
+
import { levenshtein } from './levenshtein'
|
|
7
|
+
import { COMMANDS, parseArgs, TOP_LEVEL_RUNTIME_COMMANDS } from './parse-args'
|
|
7
8
|
import { RnxExit } from './run-rnx'
|
|
8
9
|
import { rnxSelfInvocation } from './self-invocation'
|
|
9
10
|
import { IS_STANDALONE } from './standalone'
|
|
@@ -37,18 +38,29 @@ function handleTopLevelError(err: unknown): never {
|
|
|
37
38
|
msg.startsWith('peach bridge exited before becoming ready') ||
|
|
38
39
|
msg.startsWith(`${rnxPublicBrand.commandName} open: requested bridge port`) ||
|
|
39
40
|
msg.startsWith('background daemon setup is unavailable') ||
|
|
40
|
-
msg.startsWith('peach bridge did not start within')
|
|
41
|
+
msg.startsWith('peach bridge did not start within') ||
|
|
42
|
+
msg.startsWith('could not resolve a native bundle for ')
|
|
41
43
|
if (isKnownCliFailure && !process.env.SOOTSIM_VERBOSE) {
|
|
42
44
|
process.stderr.write(` ${msg}\n`)
|
|
43
45
|
process.exit(1)
|
|
44
46
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
}
|
|
47
|
+
// bun can render an async-thrown error's stack with a bare `Error` header, so
|
|
48
|
+
// the message always goes first.
|
|
49
|
+
process.stderr.write(` ${msg}\n`)
|
|
50
|
+
if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}\n`)
|
|
50
51
|
process.exit(1)
|
|
51
52
|
}
|
|
53
|
+
function printUnknownCommand(attempted: string) {
|
|
54
|
+
const closest = [...COMMANDS]
|
|
55
|
+
.map((name) => ({ name, distance: levenshtein(attempted, name) }))
|
|
56
|
+
.sort((a, b) => a.distance - b.distance)[0]
|
|
57
|
+
console.error(` unknown command: ${attempted}`)
|
|
58
|
+
if (closest && closest.distance <= 2) {
|
|
59
|
+
console.error(` did you mean \`${rnxPublicBrand.commandName} ${closest.name}\`?`)
|
|
60
|
+
}
|
|
61
|
+
console.error(` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`)
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
process.on('unhandledRejection', handleTopLevelError)
|
|
53
65
|
process.on('uncaughtException', handleTopLevelError)
|
|
54
66
|
|
|
@@ -73,7 +85,11 @@ const isCliUpdateWorker =
|
|
|
73
85
|
parsed.commandArgs[0] === '--check-worker'
|
|
74
86
|
const isBareWelcome = !parsed.command && parsed.commandArgs.length === 0
|
|
75
87
|
const isReadOnlyInvocation =
|
|
76
|
-
parsed.version ||
|
|
88
|
+
parsed.version ||
|
|
89
|
+
parsed.help ||
|
|
90
|
+
parsed.commandArgs.includes('--help') ||
|
|
91
|
+
parsed.commandArgs.includes('-h') ||
|
|
92
|
+
parsed.commandArgs.includes('--dry-run')
|
|
77
93
|
|
|
78
94
|
if (isCliUpdateWorker) {
|
|
79
95
|
if (IS_STANDALONE) {
|
|
@@ -264,7 +280,7 @@ if (device || theme) {
|
|
|
264
280
|
// returning runs open ConnectRN directly; app discovery belongs inside it.
|
|
265
281
|
if (!parsed.command && parsed.commandArgs.length === 0) {
|
|
266
282
|
const { runWelcome } = await import('./commands/welcome')
|
|
267
|
-
await exitWithFlush(await runWelcome())
|
|
283
|
+
await exitWithFlush(await runWelcome(port))
|
|
268
284
|
}
|
|
269
285
|
|
|
270
286
|
// route to command. an empty command at this point only happens if the
|
|
@@ -280,10 +296,7 @@ if (!command) {
|
|
|
280
296
|
if (parsed.commandArgs.length > 0) {
|
|
281
297
|
const attempted =
|
|
282
298
|
parsed.commandArgs.find((a) => !a.startsWith('-')) ?? parsed.commandArgs[0]
|
|
283
|
-
|
|
284
|
-
console.error(
|
|
285
|
-
` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`,
|
|
286
|
-
)
|
|
299
|
+
printUnknownCommand(attempted)
|
|
287
300
|
await exitWithFlush(1)
|
|
288
301
|
}
|
|
289
302
|
const { printHelp } = await import('./help')
|
|
@@ -608,7 +621,7 @@ try {
|
|
|
608
621
|
|
|
609
622
|
case 'setup': {
|
|
610
623
|
const { runSetup } = await import('./commands/setup')
|
|
611
|
-
await runSetup(parsed.commandArgs)
|
|
624
|
+
await runSetup(parsed.commandArgs, { port })
|
|
612
625
|
break
|
|
613
626
|
}
|
|
614
627
|
|
|
@@ -685,10 +698,7 @@ try {
|
|
|
685
698
|
}
|
|
686
699
|
|
|
687
700
|
default: {
|
|
688
|
-
|
|
689
|
-
console.error(
|
|
690
|
-
` run \`${rnxPublicBrand.commandName} --help\` to see the full surface.`,
|
|
691
|
-
)
|
|
701
|
+
printUnknownCommand(command)
|
|
692
702
|
await exitWithFlush(1)
|
|
693
703
|
}
|
|
694
704
|
}
|
package/cli/ws-bridge.ts
CHANGED
|
@@ -145,9 +145,10 @@ function resolveDefaultDevBridgeLockfile(): DevBridgeLockfile | null {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// another checkout's bridge on its own offset belongs to that checkout's
|
|
149
|
+
// world; only the canonical bridge is shared, and past it the daemon serves.
|
|
148
150
|
return (
|
|
149
|
-
lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ??
|
|
150
|
-
lockfiles[0]
|
|
151
|
+
lockfiles.find((lockfile) => lockfile.bridgePort === DEFAULT_SOOTSIM_BRIDGE_PORT) ?? null
|
|
151
152
|
)
|
|
152
153
|
}
|
|
153
154
|
|
package/detox/index.ts
CHANGED
|
@@ -369,6 +369,52 @@ function reapOwnedBrowser(): void {
|
|
|
369
369
|
} catch {}
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// an open picture-in-picture window keeps the browser context alive and
|
|
373
|
+
// context.close() never settles (expo-pip has no exit api, and the OS
|
|
374
|
+
// window outlives its video element). exit PiP in every page first; the
|
|
375
|
+
// page's leavepictureinpicture handler stops the capture tracks and
|
|
376
|
+
// removes the video. bounded and best-effort so a wedged page cannot eat
|
|
377
|
+
// the close budget.
|
|
378
|
+
const CONTEXT_MEDIA_QUIESCE_TIMEOUT_MS = 2_500
|
|
379
|
+
|
|
380
|
+
async function quiesceContextMedia(context: BrowserContext): Promise<void> {
|
|
381
|
+
const pages = context.pages()
|
|
382
|
+
if (pages.length === 0) return
|
|
383
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
384
|
+
await Promise.race([
|
|
385
|
+
Promise.all(
|
|
386
|
+
pages.map((page) =>
|
|
387
|
+
page
|
|
388
|
+
.evaluate(() => {
|
|
389
|
+
const doc = globalThis.document
|
|
390
|
+
if (!doc) return
|
|
391
|
+
if (
|
|
392
|
+
doc.pictureInPictureElement &&
|
|
393
|
+
typeof doc.exitPictureInPicture === 'function'
|
|
394
|
+
) {
|
|
395
|
+
void doc.exitPictureInPicture().catch(() => {})
|
|
396
|
+
}
|
|
397
|
+
doc.querySelectorAll('video').forEach((v) => {
|
|
398
|
+
try {
|
|
399
|
+
if (
|
|
400
|
+
v.srcObject &&
|
|
401
|
+
typeof (v.srcObject as MediaStream).getTracks === 'function'
|
|
402
|
+
) {
|
|
403
|
+
;(v.srcObject as MediaStream).getTracks().forEach((t) => t.stop())
|
|
404
|
+
v.srcObject = null
|
|
405
|
+
}
|
|
406
|
+
v.pause()
|
|
407
|
+
v.remove()
|
|
408
|
+
} catch {}
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
.catch(() => {}),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
sleep(CONTEXT_MEDIA_QUIESCE_TIMEOUT_MS),
|
|
415
|
+
]).catch(() => {})
|
|
416
|
+
}
|
|
417
|
+
|
|
372
418
|
async function closeContext() {
|
|
373
419
|
if (_browserCompromised) {
|
|
374
420
|
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
|
|
@@ -377,6 +423,7 @@ async function closeContext() {
|
|
|
377
423
|
const context = _context
|
|
378
424
|
const closingContextId = contextIdentity
|
|
379
425
|
try {
|
|
426
|
+
await quiesceContextMedia(context)
|
|
380
427
|
await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
|
|
381
428
|
_context = null
|
|
382
429
|
_page = null
|
|
@@ -715,7 +762,9 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
|
|
|
715
762
|
// would edit around a caret the tap then moves, so wait for its focus.
|
|
716
763
|
await pollUntil(
|
|
717
764
|
() =>
|
|
718
|
-
page.evaluate(
|
|
765
|
+
page.evaluate(
|
|
766
|
+
async () => (await window.__sootsimTest!.getFocusedNode?.())?.nodeId ?? null,
|
|
767
|
+
),
|
|
719
768
|
(focusedNodeId) => focusedNodeId === node.nodeId,
|
|
720
769
|
{
|
|
721
770
|
timeoutMs: 5000,
|
|
@@ -1535,6 +1584,14 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1535
1584
|
visible: node.layout.width > 0 && node.layout.height > 0,
|
|
1536
1585
|
enabled: !node.accessibilityState?.disabled,
|
|
1537
1586
|
...node,
|
|
1587
|
+
frame: node.absolutePosition
|
|
1588
|
+
? {
|
|
1589
|
+
x: node.absolutePosition.x,
|
|
1590
|
+
y: node.absolutePosition.y,
|
|
1591
|
+
width: node.absolutePosition.width ?? node.frame?.width ?? 0,
|
|
1592
|
+
height: node.absolutePosition.height ?? node.frame?.height ?? 0,
|
|
1593
|
+
}
|
|
1594
|
+
: node.frame,
|
|
1538
1595
|
}
|
|
1539
1596
|
},
|
|
1540
1597
|
|
|
@@ -1552,6 +1609,131 @@ export function element(matcher: Matcher): SootSimElement {
|
|
|
1552
1609
|
}
|
|
1553
1610
|
|
|
1554
1611
|
// device object -- app lifecycle
|
|
1612
|
+
export interface DetoxUserNotificationParams {
|
|
1613
|
+
trigger: {
|
|
1614
|
+
type: string
|
|
1615
|
+
[key: string]: unknown
|
|
1616
|
+
}
|
|
1617
|
+
title?: unknown
|
|
1618
|
+
subtitle?: unknown
|
|
1619
|
+
body?: unknown
|
|
1620
|
+
badge?: unknown
|
|
1621
|
+
category?: unknown
|
|
1622
|
+
'content-available'?: unknown
|
|
1623
|
+
payload?: Record<string, unknown>
|
|
1624
|
+
'user-text'?: unknown
|
|
1625
|
+
'action-identifier'?: unknown
|
|
1626
|
+
[key: string]: unknown
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// detox's documented sendUserNotification data, converted the way detox ios
|
|
1630
|
+
// builds it (DetoxUserNotificationDispatcher.payload): the user info is the
|
|
1631
|
+
// custom `payload`, with the top-level title, subtitle, and body written into
|
|
1632
|
+
// its aps alert and badge, category, and content-available written into its
|
|
1633
|
+
// aps. a top-level key that is absent removes the payload's own value, as
|
|
1634
|
+
// assigning nil to a swift dictionary does; every other aps key survives.
|
|
1635
|
+
function detoxNotificationToApnsPayload(params: DetoxUserNotificationParams): {
|
|
1636
|
+
payload: Record<string, unknown>
|
|
1637
|
+
actionId?: string
|
|
1638
|
+
userText?: string
|
|
1639
|
+
} {
|
|
1640
|
+
const trigger = objectOrEmpty(objectOrEmpty(params).trigger)
|
|
1641
|
+
const supported = ['push', 'calendar', 'location', 'timeInterval']
|
|
1642
|
+
if (typeof trigger.type !== 'string' || !supported.includes(trigger.type)) {
|
|
1643
|
+
throw new Error(
|
|
1644
|
+
`Missing trigger or invalid trigger type. A 'trigger' key must exist, with one of the following types: '${supported.join("', '")}'`,
|
|
1645
|
+
)
|
|
1646
|
+
}
|
|
1647
|
+
if (trigger.type !== 'push') {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
`sootsim device.sendUserNotification: trigger type '${trigger.type}' is not simulated, only 'push'`,
|
|
1650
|
+
)
|
|
1651
|
+
}
|
|
1652
|
+
const assign = (record: Record<string, unknown>, key: string, value: unknown) => {
|
|
1653
|
+
if (value === undefined) delete record[key]
|
|
1654
|
+
else record[key] = value
|
|
1655
|
+
}
|
|
1656
|
+
const payload = { ...objectOrEmpty(params.payload) }
|
|
1657
|
+
const aps = { ...objectOrEmpty(payload.aps) }
|
|
1658
|
+
const alert = { ...objectOrEmpty(aps.alert) }
|
|
1659
|
+
assign(alert, 'title', params.title)
|
|
1660
|
+
assign(alert, 'subtitle', params.subtitle)
|
|
1661
|
+
assign(alert, 'body', params.body)
|
|
1662
|
+
aps.alert = alert
|
|
1663
|
+
assign(aps, 'badge', params.badge)
|
|
1664
|
+
assign(aps, 'category', params.category)
|
|
1665
|
+
assign(aps, 'content-available', params['content-available'])
|
|
1666
|
+
payload.aps = aps
|
|
1667
|
+
return {
|
|
1668
|
+
payload,
|
|
1669
|
+
actionId:
|
|
1670
|
+
typeof params['action-identifier'] === 'string'
|
|
1671
|
+
? params['action-identifier']
|
|
1672
|
+
: undefined,
|
|
1673
|
+
userText: typeof params['user-text'] === 'string' ? params['user-text'] : undefined,
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function objectOrEmpty(value: unknown): Record<string, unknown> {
|
|
1678
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1679
|
+
? { ...value }
|
|
1680
|
+
: {}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
interface PreparedRemotePush {
|
|
1684
|
+
payload: Record<string, unknown>
|
|
1685
|
+
actionId?: string
|
|
1686
|
+
userText?: string
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async function postPreparedRemotePush(
|
|
1690
|
+
prepared: PreparedRemotePush,
|
|
1691
|
+
launch: boolean,
|
|
1692
|
+
): Promise<void> {
|
|
1693
|
+
const { payload, actionId, userText } = prepared
|
|
1694
|
+
const page = getPage()
|
|
1695
|
+
await page.evaluate(
|
|
1696
|
+
({ request }) => {
|
|
1697
|
+
const bridges = (
|
|
1698
|
+
window as unknown as {
|
|
1699
|
+
SootSim?: {
|
|
1700
|
+
bridges?: {
|
|
1701
|
+
shellHost?: { postNativeUIRequest?: (request: unknown) => unknown }
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
).SootSim?.bridges
|
|
1706
|
+
// the shell host queues requests until the shell worker connects, so a
|
|
1707
|
+
// post here never silently drops. (mainShell.postNativeUIRequest would
|
|
1708
|
+
// swallow a not-yet-connected shell and report success.)
|
|
1709
|
+
const target = bridges?.shellHost
|
|
1710
|
+
const post = target?.postNativeUIRequest
|
|
1711
|
+
if (typeof post !== 'function') {
|
|
1712
|
+
throw new Error(
|
|
1713
|
+
'sootsim device.sendUserNotification: simulator shell unavailable — call device.launchApp() first',
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1716
|
+
post.call(target, request)
|
|
1717
|
+
},
|
|
1718
|
+
{
|
|
1719
|
+
request: {
|
|
1720
|
+
kind: 'notification.remote-push',
|
|
1721
|
+
payload,
|
|
1722
|
+
launch,
|
|
1723
|
+
...(actionId !== undefined ? { actionId } : {}),
|
|
1724
|
+
...(userText !== undefined ? { userText } : {}),
|
|
1725
|
+
},
|
|
1726
|
+
},
|
|
1727
|
+
)
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function postRemotePushToSimulator(
|
|
1731
|
+
params: DetoxUserNotificationParams,
|
|
1732
|
+
launch: boolean,
|
|
1733
|
+
): Promise<void> {
|
|
1734
|
+
await postPreparedRemotePush(detoxNotificationToApnsPayload(params), launch)
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1555
1737
|
export const device = {
|
|
1556
1738
|
_currentUrl: '',
|
|
1557
1739
|
_platform: DETOX_PLATFORM as 'ios' | 'android',
|
|
@@ -1561,6 +1743,9 @@ export const device = {
|
|
|
1561
1743
|
newInstance?: boolean
|
|
1562
1744
|
url?: string
|
|
1563
1745
|
launchArgs?: Record<string, any>
|
|
1746
|
+
// launch from a notification, like detox launchApp({ userNotification }):
|
|
1747
|
+
// dispatched during launch, so the app receives its tap response.
|
|
1748
|
+
userNotification?: DetoxUserNotificationParams
|
|
1564
1749
|
// when set, the new BrowserContext records video at this dir. used by
|
|
1565
1750
|
// captureProofMenuStages for the multi-stage animation diff path. must
|
|
1566
1751
|
// be set at launchApp time because playwright recordVideo can't be
|
|
@@ -1601,6 +1786,9 @@ export const device = {
|
|
|
1601
1786
|
)
|
|
1602
1787
|
_suspendedAppId = null
|
|
1603
1788
|
await waitForSootsimTree(page, 10000)
|
|
1789
|
+
if (opts?.userNotification) {
|
|
1790
|
+
await postRemotePushToSimulator(opts.userNotification, true)
|
|
1791
|
+
}
|
|
1604
1792
|
return
|
|
1605
1793
|
}
|
|
1606
1794
|
|
|
@@ -1649,16 +1837,17 @@ export const device = {
|
|
|
1649
1837
|
const request = readBrowserCompositeCaptureRequest(value)
|
|
1650
1838
|
const session = await source.page.context().newCDPSession(source.page)
|
|
1651
1839
|
try {
|
|
1840
|
+
const { clip, originX, originY } = resolveBrowserCompositeCdpClip(
|
|
1841
|
+
request,
|
|
1842
|
+
await session.send('Page.getLayoutMetrics'),
|
|
1843
|
+
)
|
|
1652
1844
|
const result = await session.send('Page.captureScreenshot', {
|
|
1653
1845
|
format: 'png',
|
|
1654
1846
|
fromSurface: true,
|
|
1655
1847
|
captureBeyondViewport: true,
|
|
1656
|
-
clip
|
|
1657
|
-
request,
|
|
1658
|
-
await session.send('Page.getLayoutMetrics'),
|
|
1659
|
-
),
|
|
1848
|
+
clip,
|
|
1660
1849
|
})
|
|
1661
|
-
return 'data:image/png;base64,' + result.data
|
|
1850
|
+
return { dataUrl: 'data:image/png;base64,' + result.data, originX, originY }
|
|
1662
1851
|
} finally {
|
|
1663
1852
|
await session.detach()
|
|
1664
1853
|
}
|
|
@@ -1683,6 +1872,25 @@ export const device = {
|
|
|
1683
1872
|
operation: 'launchApp',
|
|
1684
1873
|
})
|
|
1685
1874
|
|
|
1875
|
+
if (opts?.userNotification) {
|
|
1876
|
+
// detox dispatches the launch notification before the app's js runs.
|
|
1877
|
+
// the shell and tenant channels queue it until they connect, so it is
|
|
1878
|
+
// posted once, as soon as the shell accepts requests.
|
|
1879
|
+
const prepared = detoxNotificationToApnsPayload(opts.userNotification)
|
|
1880
|
+
await pollUntil(
|
|
1881
|
+
() =>
|
|
1882
|
+
postPreparedRemotePush(prepared, true).then(
|
|
1883
|
+
() => true,
|
|
1884
|
+
() => false,
|
|
1885
|
+
),
|
|
1886
|
+
Boolean,
|
|
1887
|
+
{
|
|
1888
|
+
timeoutMs: 60000,
|
|
1889
|
+
label: 'SootSim shell to accept launch notification',
|
|
1890
|
+
},
|
|
1891
|
+
)
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1686
1894
|
await waitForSootsimTree(_page!, 30000)
|
|
1687
1895
|
await waitForSootsimSurfaceMetrics(_page!, 30000)
|
|
1688
1896
|
await waitForSootsimShellReady(_page!, 30000)
|
|
@@ -1728,6 +1936,13 @@ export const device = {
|
|
|
1728
1936
|
await waitForSootsimWorkletsIdle(page, 3000)
|
|
1729
1937
|
},
|
|
1730
1938
|
|
|
1939
|
+
// detox device.sendUserNotification on ios: the notification goes straight
|
|
1940
|
+
// to the app's notification center delegate. an active app gets willPresent;
|
|
1941
|
+
// otherwise the app gets the tap response.
|
|
1942
|
+
async sendUserNotification(params: DetoxUserNotificationParams) {
|
|
1943
|
+
await postRemotePushToSimulator(params, false)
|
|
1944
|
+
},
|
|
1945
|
+
|
|
1731
1946
|
async takeScreenshot(name: string): Promise<string> {
|
|
1732
1947
|
return writeSootsimScreenshot(name)
|
|
1733
1948
|
},
|
|
@@ -2832,8 +3047,20 @@ export const device = {
|
|
|
2832
3047
|
// no-op for sootsim
|
|
2833
3048
|
},
|
|
2834
3049
|
|
|
3050
|
+
// set the simulated device location at the platform level, the same step
|
|
3051
|
+
// native detox's setLocation takes: on iOS it drives the simulator location
|
|
3052
|
+
// (AppleSimUtils/simctl location set) and CLLocationManager delivers the fix
|
|
3053
|
+
// asynchronously. here the platform is the harness browser, so grant page
|
|
3054
|
+
// geolocation and override the context fix; the guest location seam observes
|
|
3055
|
+
// it through navigator.geolocation exactly like the app observes Core
|
|
3056
|
+
// Location. app permission state is untouched, matching native.
|
|
2835
3057
|
async setLocation(lat: number, lon: number) {
|
|
2836
|
-
|
|
3058
|
+
const page = getPage()
|
|
3059
|
+
const context = _context ?? page.context()
|
|
3060
|
+
await context.grantPermissions(['geolocation'], {
|
|
3061
|
+
origin: new URL(page.url()).origin,
|
|
3062
|
+
})
|
|
3063
|
+
await context.setGeolocation({ latitude: lat, longitude: lon })
|
|
2837
3064
|
},
|
|
2838
3065
|
|
|
2839
3066
|
async setStatusBar(config: StatusBarConfig) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.628 | (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.628 | (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.628 | (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;
|