@@ -3,6 +3,7 @@ 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 { AppError } from '../../utils/errors.ts' ;
67import {
78 applyRuntimeHintsToApp ,
89 clearRuntimeHintsFromApp ,
@@ -39,7 +40,27 @@ async function withMockedAdb(
3940 ' fi' ,
4041 ' exit 1' ,
4142 'fi' ,
43+ 'if [ "$1" = "shell" ] && [ "$2" = "run-as" ] && [ "$4" = "id" ]; then' ,
44+ ' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT" ]; then' ,
45+ ' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT"' ,
46+ ' else' ,
47+ ' printf "%s\\n" "uid=10162(u0_a162) gid=10162(u0_a162) groups=10162(u0_a162)"' ,
48+ ' fi' ,
49+ ' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_ID_STDERR" ]; then' ,
50+ ' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_ID_STDERR" >&2' ,
51+ ' fi' ,
52+ ' exit "${AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE:-0}"' ,
53+ 'fi' ,
4254 'if [ "$1" = "shell" ] && [ "$2" = "run-as" ] && [ "$4" = "sh" ] && [ "$5" = "-c" ]; then' ,
55+ ' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT" ]; then' ,
56+ ' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT"' ,
57+ ' fi' ,
58+ ' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR" ]; then' ,
59+ ' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR" >&2' ,
60+ ' fi' ,
61+ ' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE" ] && [ "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE" != "0" ]; then' ,
62+ ' exit "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE"' ,
63+ ' fi' ,
4364 ' printf "%s" "$6" > "$AGENT_DEVICE_TEST_SCRIPT_FILE"' ,
4465 ' exit 0' ,
4566 'fi' ,
@@ -55,6 +76,12 @@ async function withMockedAdb(
5576 const previousArgsFile = process . env . AGENT_DEVICE_TEST_ARGS_FILE ;
5677 const previousReadFile = process . env . AGENT_DEVICE_TEST_READ_FILE ;
5778 const previousScriptFile = process . env . AGENT_DEVICE_TEST_SCRIPT_FILE ;
79+ const previousRunAsIdExitCode = process . env . AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE ;
80+ const previousRunAsIdStdout = process . env . AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT ;
81+ const previousRunAsIdStderr = process . env . AGENT_DEVICE_TEST_RUN_AS_ID_STDERR ;
82+ const previousRunAsWriteExitCode = process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE ;
83+ const previousRunAsWriteStdout = process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT ;
84+ const previousRunAsWriteStderr = process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR ;
5885 process . env . PATH = `${ tmpDir } ${ path . delimiter } ${ previousPath ?? '' } ` ;
5986 process . env . AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath ;
6087 process . env . AGENT_DEVICE_TEST_READ_FILE = readFilePath ;
@@ -75,6 +102,12 @@ async function withMockedAdb(
75102 restoreEnv ( 'AGENT_DEVICE_TEST_ARGS_FILE' , previousArgsFile ) ;
76103 restoreEnv ( 'AGENT_DEVICE_TEST_READ_FILE' , previousReadFile ) ;
77104 restoreEnv ( 'AGENT_DEVICE_TEST_SCRIPT_FILE' , previousScriptFile ) ;
105+ restoreEnv ( 'AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE' , previousRunAsIdExitCode ) ;
106+ restoreEnv ( 'AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT' , previousRunAsIdStdout ) ;
107+ restoreEnv ( 'AGENT_DEVICE_TEST_RUN_AS_ID_STDERR' , previousRunAsIdStderr ) ;
108+ restoreEnv ( 'AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE' , previousRunAsWriteExitCode ) ;
109+ restoreEnv ( 'AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT' , previousRunAsWriteStdout ) ;
110+ restoreEnv ( 'AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR' , previousRunAsWriteStderr ) ;
78111 await fs . rm ( tmpDir , { recursive : true , force : true } ) ;
79112 }
80113}
@@ -174,6 +207,76 @@ test('applyRuntimeHintsToApp writes React Native Android dev prefs', async () =>
174207 } ) ;
175208} ) ;
176209
210+ test ( 'applyRuntimeHintsToApp distinguishes run-as denial from general write failures' , async ( ) => {
211+ await withMockedAdb ( async ( { device } ) => {
212+ process . env . AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE = '1' ;
213+ process . env . AGENT_DEVICE_TEST_RUN_AS_ID_STDERR = 'run-as: package not debuggable: com.example.demo' ;
214+ try {
215+ await assert . rejects (
216+ applyRuntimeHintsToApp ( {
217+ device,
218+ appId : 'com.example.demo' ,
219+ runtime : {
220+ platform : 'android' ,
221+ metroHost : '10.0.0.10' ,
222+ metroPort : 8081 ,
223+ } ,
224+ } ) ,
225+ ( error : unknown ) => {
226+ assert . ok ( error instanceof AppError ) ;
227+ assert . equal ( error . message , 'Failed to access Android app sandbox for com.example.demo' ) ;
228+ assert . equal (
229+ error . details ?. hint ,
230+ 'React Native runtime hints require adb run-as access to the app sandbox. Verify the app is debuggable and the selected package/device are correct.' ,
231+ ) ;
232+ assert . equal ( error . details ?. exitCode , 1 ) ;
233+ assert . match ( String ( error . details ?. stderr ) , / n o t d e b u g g a b l e / ) ;
234+ return true ;
235+ } ,
236+ ) ;
237+ } finally {
238+ delete process . env . AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE ;
239+ delete process . env . AGENT_DEVICE_TEST_RUN_AS_ID_STDERR ;
240+ }
241+ } ) ;
242+ } ) ;
243+
244+ test ( 'applyRuntimeHintsToApp preserves write failures after a successful run-as probe' , async ( ) => {
245+ await withMockedAdb ( async ( { device } ) => {
246+ process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE = '1' ;
247+ process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR =
248+ "sh: can't create shared_prefs/ReactNativeDevPrefs.xml: Permission denied" ;
249+ try {
250+ await assert . rejects (
251+ applyRuntimeHintsToApp ( {
252+ device,
253+ appId : 'com.example.demo' ,
254+ runtime : {
255+ platform : 'android' ,
256+ metroHost : '10.0.0.10' ,
257+ metroPort : 8081 ,
258+ } ,
259+ } ) ,
260+ ( error : unknown ) => {
261+ assert . ok ( error instanceof AppError ) ;
262+ assert . equal ( error . message , 'Failed to write Android runtime hints for com.example.demo' ) ;
263+ assert . equal (
264+ error . details ?. hint ,
265+ 'adb run-as succeeded, but writing ReactNativeDevPrefs.xml failed. Inspect stderr/details for the failing shell command.' ,
266+ ) ;
267+ assert . equal ( error . details ?. phase , 'write-runtime-hints' ) ;
268+ assert . equal ( error . details ?. exitCode , 1 ) ;
269+ assert . match ( String ( error . details ?. stderr ) , / p e r m i s s i o n d e n i e d / i) ;
270+ return true ;
271+ } ,
272+ ) ;
273+ } finally {
274+ delete process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE ;
275+ delete process . env . AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR ;
276+ }
277+ } ) ;
278+ } ) ;
279+
177280test ( 'clearRuntimeHintsFromApp removes managed Android runtime prefs but preserves unrelated entries' , async ( ) => {
178281 await withMockedAdb ( async ( { device, readFilePath, scriptFilePath } ) => {
179282 await fs . writeFile (
0 commit comments