peachsim 0.1.637 → 0.1.639

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +8 -5
  2. package/cli/bridge-flow-runner.ts +442 -14
  3. package/cli/command-registry.ts +1 -0
  4. package/cli/commands/control.ts +42 -5
  5. package/cli/commands/inspect/core.ts +93 -24
  6. package/cli/commands/maestro.ts +2 -2
  7. package/cli/commands/serve.ts +13 -1
  8. package/cli/commands/swift.ts +326 -0
  9. package/cli/drivers/playwright-provisioning.ts +8 -16
  10. package/cli/drivers/playwright-sim-host.ts +3 -0
  11. package/cli/drivers/playwright.ts +1 -0
  12. package/cli/internal-child.ts +51 -8
  13. package/cli/main.ts +11 -0
  14. package/cli/outbound-endpoints.ts +5 -1
  15. package/dist-lib/agent-daemon-client.cjs +1 -1
  16. package/dist-lib/agent-events.cjs +1 -1
  17. package/dist-lib/agent-identity.cjs +1 -1
  18. package/dist-lib/agent-sessions.cjs +1 -1
  19. package/dist-lib/attached-projects.cjs +1 -1
  20. package/dist-lib/auth/shared-session.cjs +1 -1
  21. package/dist-lib/backend-origin.cjs +1 -1
  22. package/dist-lib/beta.cjs +1 -1
  23. package/dist-lib/beta.mjs +1 -1
  24. package/dist-lib/bridge-constants.cjs +1 -1
  25. package/dist-lib/bridge-contract-input.cjs +3 -2
  26. package/dist-lib/bridge-contract-input.mjs +3 -2
  27. package/dist-lib/bridge-contract.cjs +1 -1
  28. package/dist-lib/bridge-contract.mjs +1 -1
  29. package/dist-lib/capture-contract.cjs +1 -1
  30. package/dist-lib/capture-contract.mjs +1 -1
  31. package/dist-lib/cli-constants.cjs +1 -1
  32. package/dist-lib/cloud-contract.cjs +1 -1
  33. package/dist-lib/cloud-contract.mjs +1 -1
  34. package/dist-lib/cloud-test-args.cjs +1 -1
  35. package/dist-lib/cloud-test-args.mjs +1 -1
  36. package/dist-lib/cloud.cjs +1 -1
  37. package/dist-lib/cloud.mjs +1 -1
  38. package/dist-lib/config.cjs +1 -1
  39. package/dist-lib/detox/index.cjs +1 -1
  40. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  41. package/dist-lib/home-paths.cjs +1 -1
  42. package/dist-lib/host/bridge-host.cjs +4 -2
  43. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  46. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  47. package/dist-lib/host/websocket-proxy.cjs +1 -1
  48. package/dist-lib/index.cjs +88 -23
  49. package/dist-lib/jump-to-source-babel.cjs +1 -1
  50. package/dist-lib/jump-to-source-native.cjs +1 -1
  51. package/dist-lib/kotlin-host.cjs +1 -1
  52. package/dist-lib/menu.cjs +1 -1
  53. package/dist-lib/menu.mjs +1 -1
  54. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  56. package/dist-lib/metro-production-bundle.cjs +1 -1
  57. package/dist-lib/metro-production-bundle.mjs +1 -1
  58. package/dist-lib/metro.cjs +1 -1
  59. package/dist-lib/native-app-identity.cjs +1 -1
  60. package/dist-lib/native-app-identity.mjs +1 -1
  61. package/dist-lib/profiles.cjs +1 -1
  62. package/dist-lib/public-brand.cjs +1 -1
  63. package/dist-lib/react-native-host-modules.cjs +1 -1
  64. package/dist-lib/react-native-host-modules.mjs +1 -1
  65. package/dist-lib/render-mode.cjs +1 -1
  66. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  67. package/dist-lib/sdk.cjs +78 -20
  68. package/dist-lib/sdk.mjs +77 -20
  69. package/dist-lib/skills.cjs +441 -94
  70. package/dist-lib/swift-compile.cjs +89 -7
  71. package/dist-lib/swift-compile.mjs +89 -7
  72. package/dist-lib/swift-package.cjs +1 -1
  73. package/dist-lib/swift-package.mjs +1 -1
  74. package/dist-lib/swift-submit.cjs +89 -8
  75. package/dist-lib/swift-submit.mjs +78 -8
  76. package/dist-lib/swift.cjs +16 -33220
  77. package/dist-lib/vite-swift.cjs +510 -253
  78. package/dist-lib/vite.cjs +13 -4
  79. package/package.json +1 -1
  80. package/skills/contrast/SKILL.md +1 -0
  81. package/src/assetCatalogContract.ts +16 -0
  82. package/src/bridge-contract-input.ts +3 -2
  83. package/src/bridge-contract.ts +8 -0
  84. package/src/host/bridge-host.ts +5 -5
  85. package/src/metro-runtime-source.ts +15 -0
  86. package/src/native-seam-manifest.ts +4 -6
  87. package/src/swift-compile.ts +105 -9
  88. package/src/swift-guest.ts +51 -0
  89. package/src/swift-host.ts +900 -0
  90. package/src/swift-submit.ts +97 -6
  91. package/src/swift.ts +36 -8
  92. package/src/swiftContract.ts +52 -0
  93. package/src/vite-plugin-one.ts +4 -3
  94. package/src/vite-plugin-swift.ts +43 -755
@@ -27,6 +27,7 @@ import type {
27
27
  SimSemanticSelector,
28
28
  } from '../../../src/bridge-contract'
29
29
  import type {
30
+ PeachAccessibilityNode,
30
31
  SootSimRouteInfo,
31
32
  SootSimRequestEntry,
32
33
  SootSimTimelineEvent,
@@ -904,40 +905,108 @@ export interface AccessibilityTreeNode {
904
905
  size: { w: number; h: number } | null
905
906
  }
906
907
 
907
- // `get a11y` — flat accessibility summary projected from the visible semantic
908
- // tree, which already excludes hidden and covered content, minus the painted
909
- // content VoiceOver cannot reach.
908
+ // runtime shape check for the engine's PeachAccessibilityNode projection: the
909
+ // bridge is a JSON boundary, so a node that lost a field must read as "no
910
+ // snapshot" rather than flatten into a half-populated row.
911
+ export function isPeachAccessibilityTree(
912
+ value: unknown,
913
+ ): value is PeachAccessibilityNode[] {
914
+ if (!Array.isArray(value)) return false
915
+ return value.every((node) => {
916
+ if (node === null || typeof node !== 'object') return false
917
+ if (
918
+ Reflect.get(node, 'platform') !== 'ios' &&
919
+ Reflect.get(node, 'platform') !== 'android'
920
+ )
921
+ return false
922
+ if (typeof Reflect.get(node, 'elementId') !== 'string') return false
923
+ if (!Number.isInteger(Reflect.get(node, 'nodeId'))) return false
924
+ for (const key of ['type', 'role']) {
925
+ if (typeof Reflect.get(node, key) !== 'string') return false
926
+ }
927
+ for (const key of ['testID', 'label', 'hint', 'value', 'placeholder']) {
928
+ const field = Reflect.get(node, key)
929
+ if (field !== null && typeof field !== 'string') return false
930
+ }
931
+ for (const key of [
932
+ 'isAccessibilityElement',
933
+ 'enabled',
934
+ 'selected',
935
+ 'checked',
936
+ 'clickable',
937
+ 'isTextInput',
938
+ ]) {
939
+ if (typeof Reflect.get(node, key) !== 'boolean') return false
940
+ }
941
+ const hasFocus = Reflect.get(node, 'hasFocus')
942
+ if (hasFocus !== undefined && typeof hasFocus !== 'boolean') return false
943
+ const state = Reflect.get(node, 'state')
944
+ if (state !== null) {
945
+ if (typeof state !== 'object') return false
946
+ const checked = Reflect.get(state, 'checked')
947
+ if (typeof checked !== 'boolean' && checked !== 'mixed') return false
948
+ for (const key of ['disabled', 'selected', 'busy', 'expanded']) {
949
+ if (typeof Reflect.get(state, key) !== 'boolean') return false
950
+ }
951
+ }
952
+ const value = Reflect.get(node, 'accessibilityValue')
953
+ if (value !== null) {
954
+ if (typeof value !== 'object') return false
955
+ for (const key of ['min', 'max', 'now']) {
956
+ const field = Reflect.get(value, key)
957
+ if (field !== undefined && (typeof field !== 'number' || !Number.isFinite(field)))
958
+ return false
959
+ }
960
+ const text = Reflect.get(value, 'text')
961
+ if (text !== undefined && typeof text !== 'string') return false
962
+ }
963
+ if (!isSemanticGeometry(Reflect.get(node, 'frame'))) return false
964
+ return isPeachAccessibilityTree(Reflect.get(node, 'children'))
965
+ })
966
+ }
967
+
968
+ // `get a11y` — flat VoiceOver presentation of the engine's XCTest
969
+ // accessibility projection (SootSimTestBridge.captureAccessibilityTree).
970
+ // VoiceOver reads an accessibility element as one group, so an element's
971
+ // children are hidden from this flat view even though the snapshot keeps them
972
+ // for Maestro's structural selectors (containsChild / containsDescendants).
910
973
  export async function inspectAccessibilityTree(
911
974
  bridge: InspectBridge,
912
975
  ): Promise<AccessibilityTreeNode[]> {
913
976
  const result: unknown = await bridge.send({
914
977
  type: 'query',
915
- query: { kind: 'find', selector: { visible: true } },
978
+ query: { kind: 'accessibility' },
916
979
  })
917
- if (!isSemanticQueryResult(result)) return []
918
- return result.nodes.flatMap((node) => {
919
- if (node.accessibilityHidden) return []
920
- if (!node.role && !node.label && !node.text && !node.pressable) return []
921
- return [
922
- {
923
- role:
924
- node.role ??
925
- (node.pressable ? 'button' : node.type === 'text' ? 'statictext' : 'none'),
926
- label: node.label ?? node.text ?? null,
927
- hint: null,
928
- state: null,
929
- testID: node.testID ?? null,
980
+ const tree =
981
+ result !== null && typeof result === 'object'
982
+ ? Reflect.get(result, 'accessibility')
983
+ : undefined
984
+ if (!isPeachAccessibilityTree(tree))
985
+ throw new Error('engine returned an invalid accessibility snapshot')
986
+ const nodes: AccessibilityTreeNode[] = []
987
+ const visit = (node: PeachAccessibilityNode): void => {
988
+ if (node.isAccessibilityElement) {
989
+ nodes.push({
990
+ role: node.role,
991
+ label: node.label,
992
+ hint: node.hint,
993
+ state: node.state ? { ...node.state } : null,
994
+ testID: node.testID,
930
995
  position: {
931
- x: Math.round(node.geometry.x),
932
- y: Math.round(node.geometry.y),
996
+ x: Math.round(node.frame.x),
997
+ y: Math.round(node.frame.y),
933
998
  },
934
999
  size: {
935
- w: Math.round(node.geometry.width),
936
- h: Math.round(node.geometry.height),
1000
+ w: Math.round(node.frame.width),
1001
+ h: Math.round(node.frame.height),
937
1002
  },
938
- },
939
- ]
940
- })
1003
+ })
1004
+ if (node.platform === 'ios') return
1005
+ }
1006
+ for (const child of node.children) visit(child)
1007
+ }
1008
+ for (const root of tree) visit(root)
1009
+ return nodes
941
1010
  }
942
1011
 
943
1012
  // ─── find ───
@@ -114,6 +114,7 @@ supported verbs:
114
114
  launchApp (including arguments), stopApp, clearState, clearKeychain (warn-only),
115
115
  selectors: text, id, index (negative from the end), childOf, below, above,
116
116
  leftOf, rightOf, width / height / tolerance, traits, enabled, selected, checked,
117
+ focused, containsChild, containsDescendants,
117
118
  tapOn (including repeat / delay), doubleTapOn, tapAtCoords, longPressOn,
118
119
  inputText, pressKey, dispatchKey, hideKeyboard, eraseText,
119
120
  setClipboard, pasteText,
@@ -166,8 +167,7 @@ not yet implemented (the step fails with "unsupported flow step"):
166
167
  travel, setLocation, setAirplaneMode, killApp, faker, inputRandom*,
167
168
  addMedia, startRecording, stopRecording (use --record flag),
168
169
  repeat.while (only repeat.times is supported).
169
- selector keys that fail the step: containsChild, containsDescendants,
170
- focused, css.
170
+ selector keys that fail the step: css.
171
171
  `)
172
172
  }
173
173
 
@@ -36,11 +36,13 @@ usage:
36
36
 
37
37
  options:
38
38
  --port <n> bridge port (defaults to ${DEFAULT_SOOTSIM_BRIDGE_PORT})
39
+ --host <address> listener address (defaults to 127.0.0.1)
39
40
  --quiet suppress per-connection logging
40
41
 
41
42
  examples:
42
43
  peach serve
43
44
  peach serve --port 7668 --quiet
45
+ peach serve --host 0.0.0.0
44
46
  `)
45
47
  rnxExit(0)
46
48
  }
@@ -54,6 +56,12 @@ examples:
54
56
  console.error(` invalid --port value: ${args[portArgIdx + 1]}`)
55
57
  rnxExit(1)
56
58
  }
59
+ const hostArgIdx = args.indexOf('--host')
60
+ const bindHost = hostArgIdx >= 0 ? args[hostArgIdx + 1] : '127.0.0.1'
61
+ if (!bindHost || bindHost.startsWith('-')) {
62
+ console.error(' --host requires a listener address')
63
+ rnxExit(1)
64
+ }
57
65
  const quiet = args.includes('--quiet') || args.includes('-q')
58
66
 
59
67
  // only one bridge per peach home. refuse to start a second one
@@ -74,6 +82,7 @@ examples:
74
82
  const contrastOrigin = await resolveDefaultUploadOrigin()
75
83
  const host = new SootSimBridgeHost({
76
84
  port,
85
+ host: bindHost,
77
86
  writeLockfile: true,
78
87
  contrastOrigin,
79
88
  })
@@ -102,7 +111,10 @@ examples:
102
111
  for (const id of currentIds) lastSimIds.add(id)
103
112
  }, 500)
104
113
 
105
- log(`peach bridge listening on ws://localhost:${boundPort} (runtime http on same port)`)
114
+ const displayHost = bindHost.includes(':') ? `[${bindHost}]` : bindHost
115
+ log(
116
+ `peach bridge listening on ws://${displayHost}:${boundPort} (runtime http on same port)`,
117
+ )
106
118
  if (boundPort !== port) {
107
119
  log(` (preferred port ${port} was taken — fell back to ${boundPort})`)
108
120
  }
@@ -0,0 +1,326 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import { WebSocket, WebSocketServer } from 'ws'
5
+ import { isLoopbackHost, resolveContrastBackendOrigin } from '../../src/backend-origin'
6
+ import { swiftGuestBundle } from '../../src/swift-guest'
7
+ import { SWIFT_HMR_PATH, SWIFT_PACKAGE_PATH } from '../../src/swift-submit'
8
+ import { authHeaderValue, resolveCliAuth } from '../auth'
9
+ import { PeachExit } from '../run-rnx'
10
+ import { createBridge } from '../ws-bridge'
11
+ import { runCloseCommand, runOpenCommand, type OpenCommandResources } from './control'
12
+ import type { SwiftEntryModuleArgs } from '../../../sootsim-swift/src/entryModule'
13
+
14
+ export async function runSwift(
15
+ args: string[],
16
+ opts: { port?: number; device?: string; driver?: string; headless?: boolean } = {},
17
+ ): Promise<number> {
18
+ const input = [...args]
19
+ const openArgs: string[] = []
20
+ let hostPort = 0
21
+ let uploadSource = false
22
+ const valueFlags = new Set([
23
+ '--device',
24
+ '--driver',
25
+ '--profile',
26
+ '--cdp-port',
27
+ '--os-version',
28
+ '--base-url',
29
+ ])
30
+ const booleanFlags = new Set([
31
+ '--headless',
32
+ '--headed',
33
+ '--ephemeral',
34
+ '--quiet',
35
+ '--proof',
36
+ ])
37
+ let packagePath = ''
38
+ for (let index = 0; index < input.length; index++) {
39
+ const arg = input[index]
40
+ if (arg === '--upload-source') uploadSource = true
41
+ else if (arg === '--host-port' || valueFlags.has(arg)) {
42
+ const value = input[++index]
43
+ if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`)
44
+ if (arg === '--host-port') {
45
+ hostPort = Number(value)
46
+ if (!Number.isInteger(hostPort) || hostPort < 1 || hostPort > 65535) {
47
+ throw new Error('--host-port must be a port from 1 to 65535')
48
+ }
49
+ } else openArgs.push(arg, value)
50
+ } else if (booleanFlags.has(arg)) openArgs.push(arg)
51
+ else if (arg.startsWith('-')) throw new Error(`unknown peach swift flag: ${arg}`)
52
+ else if (packagePath) throw new Error('peach swift expects one SwiftPM directory')
53
+ else packagePath = path.resolve(arg)
54
+ }
55
+ if (!packagePath)
56
+ throw new Error('usage: peach swift <SwiftPM-directory> --upload-source')
57
+ if (!uploadSource) {
58
+ throw new Error(
59
+ 'peach swift uploads the selected package sources and resources to the authenticated Contrast compiler. Pass --upload-source to consent to this directory and its watched edits.',
60
+ )
61
+ }
62
+ const auth = resolveCliAuth()
63
+ if (!auth || auth.kind === 'github') {
64
+ throw new Error(
65
+ 'peach swift needs `peach login` or PEACH_API_KEY before reading project sources',
66
+ )
67
+ }
68
+ const compileOrigin = resolveContrastBackendOrigin(
69
+ auth.kind === 'session' ? auth.origin : null,
70
+ )
71
+ packagePath = fs.realpathSync(packagePath)
72
+ if (
73
+ !fs.statSync(packagePath).isDirectory() ||
74
+ !fs.existsSync(path.join(packagePath, 'Package.swift'))
75
+ ) {
76
+ throw new Error('peach swift requires a directory containing Package.swift')
77
+ }
78
+ const host = await import('../../src/swift-host')
79
+ // collection preflights every input path before any source bytes are read.
80
+ host.swiftPackageWatchFiles(packagePath, undefined)
81
+ const packageName = path.basename(packagePath)
82
+ const guest = swiftGuestBundle(packageName)
83
+ const controller = new AbortController()
84
+ const resources: OpenCommandResources = {}
85
+ let metadata: (SwiftEntryModuleArgs & { hmrPath: string }) | null = null
86
+ let compileError = 'Swift compilation is starting'
87
+ let origin = ''
88
+ let requested = 0
89
+ let completed = 0
90
+ let building: Promise<void> | undefined
91
+ const sockets = new WebSocketServer({ noServer: true })
92
+ const send = (message: object) => {
93
+ const text = JSON.stringify(message)
94
+ for (const client of sockets.clients)
95
+ if (client.readyState === WebSocket.OPEN) client.send(text)
96
+ }
97
+ const refresh = (): Promise<void> => {
98
+ requested++
99
+ if (building) return building
100
+ building = (async () => {
101
+ while (!controller.signal.aborted && completed !== requested) {
102
+ const revision = requested
103
+ try {
104
+ const next = await host.swiftPackageMetadata(
105
+ packagePath,
106
+ packageName,
107
+ undefined,
108
+ origin,
109
+ {
110
+ origin: compileOrigin,
111
+ authorization: authHeaderValue(auth),
112
+ signal: controller.signal,
113
+ },
114
+ )
115
+ if (controller.signal.aborted) return
116
+ if (revision === requested) {
117
+ metadata = { ...next, hmrPath: `${origin}${SWIFT_HMR_PATH}` }
118
+ compileError = ''
119
+ send({ type: 'package', packageName, metadata })
120
+ console.log(` Swift artifact: ${next.hash}`)
121
+ }
122
+ } catch (error) {
123
+ if (controller.signal.aborted) return
124
+ if (revision === requested) {
125
+ compileError = error instanceof Error ? error.message : String(error)
126
+ console.error(compileError)
127
+ send({ type: 'error', packageName, message: compileError })
128
+ }
129
+ }
130
+ completed = revision
131
+ }
132
+ })().finally(() => {
133
+ building = undefined
134
+ })
135
+ return building
136
+ }
137
+ const validRequest = (req: http.IncomingMessage): boolean => {
138
+ if (req.headers.host !== new URL(origin).host) return false
139
+ if (!req.headers.origin) return true
140
+ try {
141
+ const requestOrigin = new URL(req.headers.origin)
142
+ return (
143
+ (requestOrigin.protocol === 'http:' || requestOrigin.protocol === 'https:') &&
144
+ isLoopbackHost(requestOrigin.hostname)
145
+ )
146
+ } catch {
147
+ return false
148
+ }
149
+ }
150
+ const server = http.createServer((req, res) => {
151
+ if (!validRequest(req)) {
152
+ res.statusCode = 403
153
+ res.end('forbidden Swift host origin')
154
+ return
155
+ }
156
+ res.setHeader('access-control-allow-origin', req.headers.origin ?? '*')
157
+ res.setHeader('vary', 'origin')
158
+ res.setHeader('cache-control', 'no-store')
159
+ if (req.method !== 'GET') {
160
+ res.statusCode = 405
161
+ res.end('GET required')
162
+ return
163
+ }
164
+ const pathname = new URL(req.url ?? '/', origin).pathname
165
+ if (pathname === '/__peach/swift-guest.js') {
166
+ res.setHeader('content-type', 'application/javascript')
167
+ res.end(guest)
168
+ } else if (pathname === `${SWIFT_PACKAGE_PATH}${encodeURIComponent(packageName)}`) {
169
+ if (!metadata) {
170
+ res.statusCode = 503
171
+ res.end(compileError)
172
+ return
173
+ }
174
+ res.setHeader('content-type', 'application/json')
175
+ res.end(JSON.stringify(metadata))
176
+ } else if (!host.serveSwiftPackageAsset(pathname, res)) {
177
+ res.statusCode = 404
178
+ res.end('unknown Swift host asset')
179
+ }
180
+ })
181
+ server.on('upgrade', (req, socket, head) => {
182
+ if (
183
+ !validRequest(req) ||
184
+ new URL(req.url ?? '/', origin).pathname !== SWIFT_HMR_PATH
185
+ ) {
186
+ socket.destroy()
187
+ return
188
+ }
189
+ sockets.handleUpgrade(req, socket, head, (client) => {
190
+ sockets.emit('connection', client, req)
191
+ client.send(
192
+ JSON.stringify(
193
+ metadata
194
+ ? { type: 'package', packageName, metadata }
195
+ : { type: 'error', packageName, message: compileError },
196
+ ),
197
+ )
198
+ if (metadata && compileError)
199
+ client.send(JSON.stringify({ type: 'error', packageName, message: compileError }))
200
+ })
201
+ })
202
+ const stop = () => controller.abort()
203
+ process.once('SIGINT', stop)
204
+ process.once('SIGTERM', stop)
205
+ let watcher: fs.FSWatcher | undefined
206
+ let code = 0
207
+ try {
208
+ await new Promise<void>((resolve, reject) => {
209
+ server.once('error', reject)
210
+ server.listen(hostPort, '127.0.0.1', () => {
211
+ server.removeListener('error', reject)
212
+ const address = server.address()
213
+ if (!address || typeof address === 'string') {
214
+ reject(new Error('Swift host has no loopback port'))
215
+ return
216
+ }
217
+ origin = `http://127.0.0.1:${address.port}`
218
+ resolve()
219
+ })
220
+ })
221
+ server.on('error', (error) => {
222
+ console.error(error.message)
223
+ code = 1
224
+ stop()
225
+ })
226
+ watcher = fs.watch(packagePath, { recursive: true }, (_event, file) => {
227
+ if (
228
+ file &&
229
+ file
230
+ .split(/[\\/]/)
231
+ .some(
232
+ (segment) =>
233
+ (segment.startsWith('.') &&
234
+ segment !== '.one-native-source.json' &&
235
+ segment !== '.one-native-source.swift.txt') ||
236
+ ['node_modules', 'ios', 'android', 'build', 'dist', 'types'].includes(
237
+ segment,
238
+ ),
239
+ )
240
+ )
241
+ return
242
+ void refresh()
243
+ })
244
+ watcher.on('error', (error) => {
245
+ console.error(error.message)
246
+ code = 1
247
+ stop()
248
+ })
249
+ console.log(
250
+ ` Swift source upload: ${packagePath} → ${compileOrigin}/api/swift-compile`,
251
+ )
252
+ console.log(` Swift preview host: ${origin}`)
253
+ await refresh()
254
+ controller.signal.throwIfAborted()
255
+ if (opts.device) openArgs.push('--device', opts.device)
256
+ if (opts.driver) openArgs.push('--driver', opts.driver)
257
+ if (opts.headless) openArgs.push('--headless')
258
+ await runOpenCommand(
259
+ [`${origin}/__peach/swift-guest.js`, '--new', '--no-hmr', ...openArgs],
260
+ {
261
+ port: opts.port,
262
+ resources,
263
+ signal: controller.signal,
264
+ ownerPid: process.pid,
265
+ },
266
+ )
267
+ await new Promise<void>((resolve) => {
268
+ if (controller.signal.aborted) resolve()
269
+ else controller.signal.addEventListener('abort', () => resolve(), { once: true })
270
+ })
271
+ } catch (error) {
272
+ if (!controller.signal.aborted) {
273
+ console.error(
274
+ ` peach swift failed: ${error instanceof Error ? error.message : String(error)}`,
275
+ )
276
+ code = error instanceof PeachExit ? error.code : 1
277
+ }
278
+ } finally {
279
+ controller.abort()
280
+ process.removeListener('SIGINT', stop)
281
+ process.removeListener('SIGTERM', stop)
282
+ watcher?.close()
283
+ await building
284
+ for (const client of sockets.clients) client.terminate()
285
+ sockets.close()
286
+ server.closeAllConnections()
287
+ await new Promise<void>((resolve) => server.close(() => resolve()))
288
+ if (resources.simId && resources.port) {
289
+ try {
290
+ await runCloseCommand(['--sim', resources.simId], { port: resources.port })
291
+ } catch (error) {
292
+ console.error(` Swift preview cleanup failed: ${String(error)}`)
293
+ code = 1
294
+ }
295
+ }
296
+ if (
297
+ resources.driver?.driverId === 'playwright' &&
298
+ resources.driver.launched &&
299
+ resources.driver.pid
300
+ ) {
301
+ try {
302
+ process.kill(resources.driver.pid, 'SIGTERM')
303
+ } catch (error) {
304
+ if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) {
305
+ console.error(String(error))
306
+ code = 1
307
+ }
308
+ }
309
+ }
310
+ if (resources.bridge && !resources.bridgeReady) resources.bridge.kill('SIGTERM')
311
+ if (resources.bridge && resources.bridgeReady && resources.port) {
312
+ const bridge = createBridge(resources.port)
313
+ try {
314
+ if ((await bridge.listSims()).length === 0) resources.bridge.kill('SIGTERM')
315
+ else console.log(' preserving the bridge adopted by another simulator')
316
+ } catch (error) {
317
+ console.error(` cannot verify owned bridge cleanup: ${String(error)}`)
318
+ code = 1
319
+ } finally {
320
+ bridge.close()
321
+ }
322
+ }
323
+ host.disposeSwiftProject(packagePath)
324
+ }
325
+ return code
326
+ }
@@ -48,18 +48,12 @@ export function resolvePlaywright(): ResolvedPlaywright | null {
48
48
  const packageJsonPath = requireFromRoot.resolve(`${spec}/package.json`)
49
49
  const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
50
50
  if (!isRecord(parsed) || typeof parsed.version !== 'string') continue
51
- const bin = parsed.bin
52
- let binPath: string | null = null
53
- if (typeof bin === 'string') {
54
- binPath = bin
55
- } else if (isRecord(bin)) {
56
- const named = bin.playwright ?? bin['playwright-core']
57
- const first = Object.values(bin).find((value) => typeof value === 'string')
58
- binPath =
59
- typeof named === 'string' ? named : typeof first === 'string' ? first : null
60
- }
61
- if (!binPath) continue
62
- const cliPath = join(dirname(packageJsonPath), binPath)
51
+ // resolve the core beside the API entry, never from the caller's
52
+ // project when running this package's provisioning subcommand.
53
+ const corePackagePath = createRequire(modulePath).resolve(
54
+ 'playwright-core/package.json',
55
+ )
56
+ const cliPath = join(dirname(corePackagePath), 'cli.js')
63
57
  if (!existsSync(cliPath)) continue
64
58
  return {
65
59
  spec,
@@ -166,10 +160,8 @@ export function ensurePlaywrightBrowser(
166
160
  return { ok: true, executablePath: before.executablePath, installed: false }
167
161
  }
168
162
 
169
- // the resolved package is the authority for both the required executable
170
- // path and the installer. never scan a shared cache or select a neighboring
171
- // revision: Playwright's own CLI installs the exact revision its API named.
172
- // that CLI is a javascript file, so this CLI runs it as itself.
163
+ // install only when neither an explicit binary nor an installed cache
164
+ // revision is available. the absolute core CLI owns its downloader scripts.
173
165
  const installer = rnxSelfInvocation()
174
166
  const install = spawnSync(
175
167
  installer.executable,
@@ -26,6 +26,8 @@ import { DISPOSABLE_BROWSER_CACHE_DIR_NAMES } from '../../src/browser-cache'
26
26
 
27
27
  export const PLAYWRIGHT_SIM_HOST = `
28
28
  const modulePath = process.env.SOOTSIM_PW_MODULE;
29
+ const executablePath = process.env.SOOTSIM_PW_EXECUTABLE;
30
+ if (!executablePath) throw new Error('SOOTSIM_PW_EXECUTABLE is not set');
29
31
  const url = process.env.SOOTSIM_PW_URL;
30
32
  // the WS bridge port the sim page must register with, injected below as
31
33
  // window.__sootsimBridgePort so the page skips its shell-port heuristic (which
@@ -332,6 +334,7 @@ const cleanupProfile = () => {
332
334
  const opts = channel
333
335
  ? { headless, channel, viewport, deviceScaleFactor }
334
336
  : { headless, viewport, deviceScaleFactor };
337
+ opts.executablePath = executablePath;
335
338
  // playwright injects --force-color-profile=srgb by default, which reads
336
339
  // visibly washed-out next to a normal chrome window on a wide-gamut mac
337
340
  // display. strip it so driver-launched sims color-match the user's own
@@ -132,6 +132,7 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
132
132
  env: {
133
133
  ...playwrightEnv,
134
134
  SOOTSIM_PW_MODULE: resolved.modulePath,
135
+ SOOTSIM_PW_EXECUTABLE: provisioning.executablePath,
135
136
  SOOTSIM_PW_URL: opts.url,
136
137
  SOOTSIM_PW_BRIDGE_PORT: opts.bridgePort ? String(opts.bridgePort) : '',
137
138
  SOOTSIM_PW_HEADLESS: (opts.headless ?? true) ? '1' : '0',