1- import { beforeEach , test , vi } from 'vitest' ;
1+ import { afterEach , beforeEach , test , vi } from 'vitest' ;
22import assert from 'node:assert/strict' ;
3+ import { EventEmitter } from 'node:events' ;
34import { promises as fs } from 'node:fs' ;
5+ import net from 'node:net' ;
46import os from 'node:os' ;
57import path from 'node:path' ;
8+ import { PassThrough } from 'node:stream' ;
69
710vi . mock ( '../../../utils/exec.ts' , async ( importOriginal ) => {
811 const actual = await importOriginal < typeof import ( '../../../utils/exec.ts' ) > ( ) ;
@@ -22,11 +25,16 @@ import { AppError } from '../../../utils/errors.ts';
2225import { runCmd } from '../../../utils/exec.ts' ;
2326import { sleep } from '../adb.ts' ;
2427import {
28+ resetAndroidSnapshotHelperSessions ,
2529 resetAndroidSnapshotHelperInstallCache ,
2630 type AndroidAdbExecutor ,
2731 type AndroidSnapshotHelperManifest ,
2832} from '../snapshot-helper.ts' ;
29- import { withAndroidAdbProvider , type AndroidAdbProvider } from '../adb-executor.ts' ;
33+ import {
34+ withAndroidAdbProvider ,
35+ type AndroidAdbProcess ,
36+ type AndroidAdbProvider ,
37+ } from '../adb-executor.ts' ;
3038
3139const VALID_PNG = Buffer . from (
3240 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=' ,
@@ -94,7 +102,123 @@ function createHelperAdb(
94102 } ;
95103}
96104
97- beforeEach ( ( ) => {
105+ function createPersistentSnapshotHelperProvider ( options : {
106+ calls : string [ ] [ ] ;
107+ spawnArgs : string [ ] [ ] ;
108+ killedProcesses : FakeAndroidProcess [ ] ;
109+ } ) : AndroidAdbProvider {
110+ return {
111+ exec : async ( args ) => {
112+ options . calls . push ( args ) ;
113+ if ( args . includes ( '--show-versioncode' ) ) return installedHelperProbe ;
114+ if ( args [ 0 ] === 'forward' ) return { exitCode : 0 , stdout : '' , stderr : '' } ;
115+ if ( args [ 0 ] === 'shell' && args [ 1 ] === 'am' && args [ 2 ] === 'force-stop' ) {
116+ return { exitCode : 0 , stdout : '' , stderr : '' } ;
117+ }
118+ throw new Error ( `unexpected persistent helper adb args: ${ args . join ( ' ' ) } ` ) ;
119+ } ,
120+ spawn : ( args ) => {
121+ options . spawnArgs . push ( args ) ;
122+ const process = new FakeAndroidProcess ( ) ;
123+ const port = readSessionPort ( args ) ;
124+ let snapshotCount = 0 ;
125+ const server = net . createServer ( ( socket ) => {
126+ socket . once ( 'data' , ( chunk ) => {
127+ const command = chunk . toString ( 'utf8' ) . trim ( ) ;
128+ const [ , requestId = '' ] = command . split ( / \s + / , 2 ) ;
129+ if ( command . startsWith ( 'quit' ) ) {
130+ socket . end ( sessionResponse ( { requestId, body : '' } ) ) ;
131+ return ;
132+ }
133+ snapshotCount += 1 ;
134+ const body = `<hierarchy><node text="persistent helper snapshot ${ snapshotCount } " bounds="[0,0][10,10]" /></hierarchy>` ;
135+ socket . end (
136+ sessionResponse ( {
137+ requestId,
138+ body,
139+ metadata : {
140+ waitForIdleTimeoutMs : '500' ,
141+ waitForIdleQuietMs : '100' ,
142+ timeoutMs : '5000' ,
143+ maxDepth : '128' ,
144+ maxNodes : '5000' ,
145+ rootPresent : 'true' ,
146+ captureMode : 'interactive-windows' ,
147+ windowCount : '1' ,
148+ nodeCount : '1' ,
149+ truncated : 'false' ,
150+ elapsedMs : '8' ,
151+ } ,
152+ } ) ,
153+ ) ;
154+ } ) ;
155+ } ) ;
156+ server . listen ( port , '127.0.0.1' , ( ) => {
157+ process . stdout . write (
158+ [
159+ 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1' ,
160+ 'INSTRUMENTATION_STATUS: sessionReady=true' ,
161+ 'INSTRUMENTATION_STATUS_CODE: 2' ,
162+ '' ,
163+ ] . join ( '\n' ) ,
164+ ) ;
165+ } ) ;
166+ process . onKill = ( ) => {
167+ options . killedProcesses . push ( process ) ;
168+ server . close ( ( ) => process . emitExit ( 0 , null ) ) ;
169+ } ;
170+ return process ;
171+ } ,
172+ } ;
173+ }
174+
175+ function sessionResponse ( params : {
176+ requestId : string ;
177+ body : string ;
178+ metadata ?: Record < string , string > ;
179+ } ) : string {
180+ const headers = {
181+ agentDeviceProtocol : 'android-snapshot-helper-v1' ,
182+ helperApiVersion : '1' ,
183+ outputFormat : 'uiautomator-xml' ,
184+ requestId : params . requestId ,
185+ ok : 'true' ,
186+ byteLength : String ( Buffer . byteLength ( params . body , 'utf8' ) ) ,
187+ ...params . metadata ,
188+ } ;
189+ return `${ Object . entries ( headers )
190+ . map ( ( [ key , value ] ) => `${ key } =${ value } ` )
191+ . join ( '\n' ) } \n\n${ params . body } `;
192+ }
193+
194+ function readSessionPort ( args : string [ ] ) : number {
195+ const index = args . indexOf ( 'sessionPort' ) ;
196+ assert . notEqual ( index , - 1 ) ;
197+ return Number ( args [ index + 1 ] ) ;
198+ }
199+
200+ class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess {
201+ stdin = new PassThrough ( ) ;
202+ stdout = new PassThrough ( ) ;
203+ stderr = new PassThrough ( ) ;
204+ killed = false ;
205+ onKill : ( ( ) => void ) | undefined ;
206+
207+ kill ( ) : boolean {
208+ if ( this . killed ) return true ;
209+ this . killed = true ;
210+ this . onKill ?.( ) ;
211+ return true ;
212+ }
213+
214+ emitExit ( code : number | null , signal : NodeJS . Signals | null ) : void {
215+ this . emit ( 'exit' , code , signal ) ;
216+ this . emit ( 'close' , code , signal ) ;
217+ }
218+ }
219+
220+ beforeEach ( async ( ) => {
221+ await resetAndroidSnapshotHelperSessions ( ) ;
98222 resetAndroidSnapshotHelperInstallCache ( ) ;
99223 mockRunCmd . mockReset ( ) ;
100224 mockSleep . mockReset ( ) ;
@@ -107,6 +231,10 @@ beforeEach(() => {
107231 } ) ;
108232} ) ;
109233
234+ afterEach ( async ( ) => {
235+ await resetAndroidSnapshotHelperSessions ( ) ;
236+ } ) ;
237+
110238test ( 'screenshotAndroid waits for transient UI to settle before capture' , async ( ) => {
111239 const events : string [ ] = [ ] ;
112240 const outPath = path . join ( os . tmpdir ( ) , `agent-device-android-screenshot-${ Date . now ( ) } .png` ) ;
@@ -496,6 +624,72 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () =>
496624 assert . equal ( mockRunCmd . mock . calls . length , 0 ) ;
497625} ) ;
498626
627+ test ( 'snapshotAndroid stops command-scoped persistent helper session after capture' , async ( ) => {
628+ const adbCalls : string [ ] [ ] = [ ] ;
629+ const spawnArgs : string [ ] [ ] = [ ] ;
630+ const killedProcesses : FakeAndroidProcess [ ] = [ ] ;
631+ const provider = createPersistentSnapshotHelperProvider ( {
632+ calls : adbCalls ,
633+ spawnArgs,
634+ killedProcesses,
635+ } ) ;
636+
637+ const result = await snapshotAndroid ( device , {
638+ helperAdb : provider ,
639+ helperArtifact,
640+ } ) ;
641+
642+ assert . equal ( result . nodes [ 0 ] ?. label , 'persistent helper snapshot 1' ) ;
643+ assert . equal ( result . androidSnapshot . helperTransport , 'persistent-session' ) ;
644+ assert . equal ( result . androidSnapshot . helperSessionReused , false ) ;
645+ assert . equal ( spawnArgs . length , 1 ) ;
646+ assert . equal ( killedProcesses . length , 1 ) ;
647+ assert . equal (
648+ adbCalls . some ( ( args ) => args [ 0 ] === 'forward' && args [ 1 ] === '--remove' ) ,
649+ true ,
650+ ) ;
651+ } ) ;
652+
653+ test ( 'snapshotAndroid keeps daemon-session helper alive for reuse until session cleanup' , async ( ) => {
654+ const adbCalls : string [ ] [ ] = [ ] ;
655+ const spawnArgs : string [ ] [ ] = [ ] ;
656+ const killedProcesses : FakeAndroidProcess [ ] = [ ] ;
657+ const provider = createPersistentSnapshotHelperProvider ( {
658+ calls : adbCalls ,
659+ spawnArgs,
660+ killedProcesses,
661+ } ) ;
662+
663+ const first = await snapshotAndroid ( device , {
664+ helperAdb : provider ,
665+ helperArtifact,
666+ helperSessionScope : 'daemon-session' ,
667+ } ) ;
668+ const second = await snapshotAndroid ( device , {
669+ helperAdb : provider ,
670+ helperArtifact,
671+ helperSessionScope : 'daemon-session' ,
672+ } ) ;
673+
674+ assert . equal ( first . androidSnapshot . helperSessionReused , false ) ;
675+ assert . equal ( second . androidSnapshot . helperSessionReused , true ) ;
676+ assert . equal ( second . nodes [ 0 ] ?. label , 'persistent helper snapshot 2' ) ;
677+ assert . equal ( spawnArgs . length , 1 ) ;
678+ assert . equal ( killedProcesses . length , 0 ) ;
679+ assert . equal (
680+ adbCalls . some ( ( args ) => args [ 0 ] === 'forward' && args [ 1 ] === '--remove' ) ,
681+ false ,
682+ ) ;
683+
684+ await resetAndroidSnapshotHelperSessions ( ) ;
685+
686+ assert . equal ( killedProcesses . length , 1 ) ;
687+ assert . equal (
688+ adbCalls . some ( ( args ) => args [ 0 ] === 'forward' && args [ 1 ] === '--remove' ) ,
689+ true ,
690+ ) ;
691+ } ) ;
692+
499693test ( 'snapshotAndroid falls back to stock uiautomator when helper fails' , async ( ) => {
500694 const adbCalls : string [ ] [ ] = [ ] ;
501695 const stockXml =
0 commit comments