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.
- package/README.md +8 -5
- package/cli/bridge-flow-runner.ts +442 -14
- package/cli/command-registry.ts +1 -0
- package/cli/commands/control.ts +42 -5
- package/cli/commands/inspect/core.ts +93 -24
- package/cli/commands/maestro.ts +2 -2
- package/cli/commands/serve.ts +13 -1
- package/cli/commands/swift.ts +326 -0
- package/cli/drivers/playwright-provisioning.ts +8 -16
- package/cli/drivers/playwright-sim-host.ts +3 -0
- package/cli/drivers/playwright.ts +1 -0
- package/cli/internal-child.ts +51 -8
- package/cli/main.ts +11 -0
- package/cli/outbound-endpoints.ts +5 -1
- 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 +3 -2
- package/dist-lib/bridge-contract-input.mjs +3 -2
- 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 +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 +4 -2
- 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 +88 -23
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +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/native-app-identity.cjs +1 -1
- package/dist-lib/native-app-identity.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +78 -20
- package/dist-lib/sdk.mjs +77 -20
- package/dist-lib/skills.cjs +441 -94
- package/dist-lib/swift-compile.cjs +89 -7
- package/dist-lib/swift-compile.mjs +89 -7
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +89 -8
- package/dist-lib/swift-submit.mjs +78 -8
- package/dist-lib/swift.cjs +16 -33220
- package/dist-lib/vite-swift.cjs +510 -253
- package/dist-lib/vite.cjs +13 -4
- package/package.json +1 -1
- package/skills/contrast/SKILL.md +1 -0
- package/src/assetCatalogContract.ts +16 -0
- package/src/bridge-contract-input.ts +3 -2
- package/src/bridge-contract.ts +8 -0
- package/src/host/bridge-host.ts +5 -5
- package/src/metro-runtime-source.ts +15 -0
- package/src/native-seam-manifest.ts +4 -6
- package/src/swift-compile.ts +105 -9
- package/src/swift-guest.ts +51 -0
- package/src/swift-host.ts +900 -0
- package/src/swift-submit.ts +97 -6
- package/src/swift.ts +36 -8
- package/src/swiftContract.ts +52 -0
- package/src/vite-plugin-one.ts +4 -3
- 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
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
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: '
|
|
978
|
+
query: { kind: 'accessibility' },
|
|
916
979
|
})
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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.
|
|
932
|
-
y: Math.round(node.
|
|
996
|
+
x: Math.round(node.frame.x),
|
|
997
|
+
y: Math.round(node.frame.y),
|
|
933
998
|
},
|
|
934
999
|
size: {
|
|
935
|
-
w: Math.round(node.
|
|
936
|
-
h: Math.round(node.
|
|
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 ───
|
package/cli/commands/maestro.ts
CHANGED
|
@@ -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:
|
|
170
|
-
focused, css.
|
|
170
|
+
selector keys that fail the step: css.
|
|
171
171
|
`)
|
|
172
172
|
}
|
|
173
173
|
|
package/cli/commands/serve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
170
|
-
//
|
|
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',
|