@@ -3,10 +3,47 @@ import assert from 'node:assert/strict';
33import { promises as fs } from 'node:fs' ;
44import os from 'node:os' ;
55import path from 'node:path' ;
6- import { listIosApps , openIosApp , parseIosDeviceAppsPayload , resolveIosApp } from '../index.ts' ;
6+ import { listIosApps , openIosApp , parseIosDeviceAppsPayload , reinstallIosApp , resolveIosApp } from '../index.ts' ;
77import type { DeviceInfo } from '../../../utils/device.ts' ;
88import { AppError } from '../../../utils/errors.ts' ;
99
10+ const IOS_TEST_DEVICE : DeviceInfo = {
11+ platform : 'ios' ,
12+ id : 'ios-device-1' ,
13+ name : 'iPhone Device' ,
14+ kind : 'device' ,
15+ booted : true ,
16+ } ;
17+
18+ async function withMockedXcrun (
19+ tempPrefix : string ,
20+ script : string ,
21+ run : ( ctx : { tmpDir : string ; argsLogPath : string ; device : DeviceInfo } ) => Promise < void > ,
22+ ) : Promise < void > {
23+ const tmpDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , tempPrefix ) ) ;
24+ const xcrunPath = path . join ( tmpDir , 'xcrun' ) ;
25+ const argsLogPath = path . join ( tmpDir , 'args.log' ) ;
26+ await fs . writeFile ( xcrunPath , script , 'utf8' ) ;
27+ await fs . chmod ( xcrunPath , 0o755 ) ;
28+
29+ const previousPath = process . env . PATH ;
30+ const previousArgsFile = process . env . AGENT_DEVICE_TEST_ARGS_FILE ;
31+ process . env . PATH = `${ tmpDir } ${ path . delimiter } ${ previousPath ?? '' } ` ;
32+ process . env . AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath ;
33+
34+ try {
35+ await run ( { tmpDir, argsLogPath, device : IOS_TEST_DEVICE } ) ;
36+ } finally {
37+ process . env . PATH = previousPath ;
38+ if ( previousArgsFile === undefined ) {
39+ delete process . env . AGENT_DEVICE_TEST_ARGS_FILE ;
40+ } else {
41+ process . env . AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile ;
42+ }
43+ await fs . rm ( tmpDir , { recursive : true , force : true } ) ;
44+ }
45+ }
46+
1047test ( 'openIosApp custom scheme deep links on iOS devices require app bundle context' , async ( ) => {
1148 const device : DeviceInfo = {
1249 platform : 'ios' ,
@@ -130,6 +167,111 @@ test('openIosApp custom scheme on iOS device uses active app context', async ()
130167 }
131168} ) ;
132169
170+ test ( 'reinstallIosApp on iOS physical device uses devicectl uninstall + install' , async ( ) => {
171+ await withMockedXcrun (
172+ 'agent-device-ios-reinstall-device-test-' ,
173+ `#!/bin/sh
174+ printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
175+ if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "info" ] && [ "$4" = "apps" ]; then
176+ out=""
177+ while [ "$#" -gt 0 ]; do
178+ if [ "$1" = "--json-output" ]; then
179+ out="$2"
180+ shift 2
181+ continue
182+ fi
183+ shift
184+ done
185+ cat > "$out" <<'JSON'
186+ {"result":{"apps":[{"bundleIdentifier":"com.example.demo","name":"Demo"}]}}
187+ JSON
188+ fi
189+ exit 0
190+ ` ,
191+ async ( { tmpDir, argsLogPath, device } ) => {
192+ const appPath = path . join ( tmpDir , 'Sample.app' ) ;
193+ await fs . writeFile ( appPath , 'placeholder' , 'utf8' ) ;
194+ const result = await reinstallIosApp ( device , 'Demo' , appPath ) ;
195+ assert . equal ( result . bundleId , 'com.example.demo' ) ;
196+
197+ const args = ( await fs . readFile ( argsLogPath , 'utf8' ) )
198+ . trim ( )
199+ . split ( '\n' )
200+ . filter ( Boolean ) ;
201+
202+ const uninstallIdx = args . indexOf ( 'uninstall' ) ;
203+ const installIdx = args . indexOf ( 'install' ) ;
204+ assert . notEqual ( uninstallIdx , - 1 ) ;
205+ assert . notEqual ( installIdx , - 1 ) ;
206+ assert . equal ( uninstallIdx < installIdx , true , 'reinstall should uninstall before install' ) ;
207+ assert . deepEqual ( args . slice ( uninstallIdx - 2 , uninstallIdx + 5 ) , [
208+ 'devicectl' ,
209+ 'device' ,
210+ 'uninstall' ,
211+ 'app' ,
212+ '--device' ,
213+ 'ios-device-1' ,
214+ 'com.example.demo' ,
215+ ] ) ;
216+ assert . deepEqual ( args . slice ( installIdx - 2 , installIdx + 5 ) , [
217+ 'devicectl' ,
218+ 'device' ,
219+ 'install' ,
220+ 'app' ,
221+ '--device' ,
222+ 'ios-device-1' ,
223+ appPath ,
224+ ] ) ;
225+ } ,
226+ ) ;
227+ } ) ;
228+
229+ test ( 'reinstallIosApp on iOS physical device proceeds when uninstall reports app not installed' , async ( ) => {
230+ await withMockedXcrun (
231+ 'agent-device-ios-reinstall-device-missing-app-test-' ,
232+ `#!/bin/sh
233+ printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
234+ if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "info" ] && [ "$4" = "apps" ]; then
235+ out=""
236+ while [ "$#" -gt 0 ]; do
237+ if [ "$1" = "--json-output" ]; then
238+ out="$2"
239+ shift 2
240+ continue
241+ fi
242+ shift
243+ done
244+ cat > "$out" <<'JSON'
245+ {"result":{"apps":[{"bundleIdentifier":"com.example.demo","name":"Demo"}]}}
246+ JSON
247+ exit 0
248+ fi
249+ if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "uninstall" ] && [ "$4" = "app" ]; then
250+ echo "app not installed" >&2
251+ exit 1
252+ fi
253+ if [ "$1" = "devicectl" ] && [ "$2" = "device" ] && [ "$3" = "install" ] && [ "$4" = "app" ]; then
254+ exit 0
255+ fi
256+ echo "unexpected xcrun args: $@" >&2
257+ exit 1
258+ ` ,
259+ async ( { tmpDir, argsLogPath, device } ) => {
260+ const appPath = path . join ( tmpDir , 'Sample.app' ) ;
261+ await fs . writeFile ( appPath , 'placeholder' , 'utf8' ) ;
262+ const result = await reinstallIosApp ( device , 'Demo' , appPath ) ;
263+ assert . equal ( result . bundleId , 'com.example.demo' ) ;
264+
265+ const args = ( await fs . readFile ( argsLogPath , 'utf8' ) )
266+ . trim ( )
267+ . split ( '\n' )
268+ . filter ( Boolean ) ;
269+ assert . equal ( args . includes ( 'uninstall' ) , true ) ;
270+ assert . equal ( args . includes ( 'install' ) , true ) ;
271+ } ,
272+ ) ;
273+ } ) ;
274+
133275test ( 'openIosApp with app and URL on iOS device launches app bundle with payload URL' , async ( ) => {
134276 const tmpDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , 'agent-device-ios-open-app-url-test-' ) ) ;
135277 const xcrunPath = path . join ( tmpDir , 'xcrun' ) ;
0 commit comments