1+ import type { DeviceAction } from '@midscene/core' ;
12import type { DebugFunction } from '@midscene/shared/logger' ;
3+ import { z } from 'zod' ;
24
35export type BrowserAgentAdapter < Page , NewPageEvent > = {
46 pages ( ) : Page [ ] | Promise < Page [ ] > ;
57 newPage ( ) : Promise < Page > ;
68 isPageClosed ( page : Page ) : boolean ;
79 bringToFront ( page : Page ) : Promise < void > | void ;
10+ pageTitle ( page : Page ) : Promise < string > | string ;
11+ pageUrl ( page : Page ) : string ;
812 onNewPage ( handler : ( event : NewPageEvent ) => void ) : void ;
913 offNewPage ( handler : ( event : NewPageEvent ) => void ) : void ;
1014 resolveNewPage ( event : NewPageEvent ) : Page | Promise < Page | null > | null ;
@@ -21,6 +25,51 @@ export type BrowserAgentPageControllerOptions<Page, NewPageEvent> = {
2125 debug : DebugFunction ;
2226} ;
2327
28+ export type BrowserAgentPageSummary = {
29+ index : number ;
30+ active : boolean ;
31+ title : string ;
32+ url : string ;
33+ } ;
34+
35+ const setActivePageParamSchema = z . object ( {
36+ index : z
37+ . number ( )
38+ . int ( )
39+ . min ( 0 )
40+ . optional ( )
41+ . describe ( '0-based page/tab index returned by ListBrowserPages.' ) ,
42+ title : z
43+ . string ( )
44+ . optional ( )
45+ . describe ( 'Case-insensitive page title substring to match.' ) ,
46+ url : z
47+ . string ( )
48+ . optional ( )
49+ . describe ( 'Case-insensitive page URL substring to match.' ) ,
50+ } ) ;
51+
52+ export type SetActivePageParam = z . infer < typeof setActivePageParamSchema > ;
53+
54+ const normalizeOptionalText = ( value : string | undefined ) => {
55+ const trimmed = value ?. trim ( ) ;
56+ return trimmed ? trimmed . toLowerCase ( ) : undefined ;
57+ } ;
58+
59+ const describeSelector = ( selector : SetActivePageParam ) => {
60+ const parts : string [ ] = [ ] ;
61+ if ( selector . index !== undefined ) {
62+ parts . push ( `index ${ selector . index } ` ) ;
63+ }
64+ if ( selector . title ?. trim ( ) ) {
65+ parts . push ( `title "${ selector . title . trim ( ) } "` ) ;
66+ }
67+ if ( selector . url ?. trim ( ) ) {
68+ parts . push ( `url "${ selector . url . trim ( ) } "` ) ;
69+ }
70+ return parts . join ( ', ' ) ;
71+ } ;
72+
2473export class BrowserAgentPageController < Page , NewPageEvent > {
2574 private readonly agentName : string ;
2675 private readonly adapter : BrowserAgentAdapter < Page , NewPageEvent > ;
@@ -54,6 +103,17 @@ export class BrowserAgentPageController<Page, NewPageEvent> {
54103 return this . adapter . pages ( ) ;
55104 }
56105
106+ async pageSummaries ( ) : Promise < BrowserAgentPageSummary [ ] > {
107+ const pages = await this . adapter . pages ( ) ;
108+ const activePage = this . activePage ;
109+
110+ return Promise . all (
111+ pages . map ( ( page , index ) =>
112+ this . pageSummary ( page , index , page === activePage ) ,
113+ ) ,
114+ ) ;
115+ }
116+
57117 async newPage ( ) {
58118 const page = await this . adapter . newPage ( ) ;
59119 await this . setActivePage ( page ) ;
@@ -75,6 +135,68 @@ export class BrowserAgentPageController<Page, NewPageEvent> {
75135 }
76136 }
77137
138+ async setActivePageBySelector (
139+ selector : SetActivePageParam ,
140+ ) : Promise < BrowserAgentPageSummary > {
141+ const hasIndex = selector . index !== undefined ;
142+ const title = normalizeOptionalText ( selector . title ) ;
143+ const url = normalizeOptionalText ( selector . url ) ;
144+
145+ if ( ! hasIndex && ! title && ! url ) {
146+ throw new Error (
147+ `[midscene] SetActivePage requires index, title, or url for ${ this . agentName } .` ,
148+ ) ;
149+ }
150+
151+ const pages = await this . adapter . pages ( ) ;
152+
153+ if ( hasIndex ) {
154+ const page = pages [ selector . index as number ] ;
155+ if ( ! page || this . adapter . isPageClosed ( page ) ) {
156+ throw new Error (
157+ `[midscene] Cannot find ${ this . agentName } page with index ${ selector . index } . Available page indexes: ${ pages
158+ . map ( ( _ , index ) => index )
159+ . join ( ', ' ) } `,
160+ ) ;
161+ }
162+
163+ await this . setActivePage ( page ) ;
164+ return this . pageSummary ( page , selector . index as number , true ) ;
165+ }
166+
167+ const matchedPages : Array < { page : Page ; index : number } > = [ ] ;
168+ for ( let index = 0 ; index < pages . length ; index ++ ) {
169+ const page = pages [ index ] ;
170+ if ( this . adapter . isPageClosed ( page ) ) {
171+ continue ;
172+ }
173+
174+ const summary = await this . pageSummary ( page , index , false ) ;
175+ const matchedTitle =
176+ ! title || summary . title . toLowerCase ( ) . includes ( title ) ;
177+ const matchedUrl = ! url || summary . url . toLowerCase ( ) . includes ( url ) ;
178+ if ( matchedTitle && matchedUrl ) {
179+ matchedPages . push ( { page, index } ) ;
180+ }
181+ }
182+
183+ if ( matchedPages . length === 0 ) {
184+ throw new Error (
185+ `[midscene] Cannot find ${ this . agentName } page matching ${ describeSelector ( selector ) } .` ,
186+ ) ;
187+ }
188+
189+ if ( matchedPages . length > 1 ) {
190+ throw new Error (
191+ `[midscene] Multiple ${ this . agentName } pages matched ${ describeSelector ( selector ) } . Use ListBrowserPages and pass an index to SetActivePage.` ,
192+ ) ;
193+ }
194+
195+ const { page, index } = matchedPages [ 0 ] ;
196+ await this . setActivePage ( page ) ;
197+ return this . pageSummary ( page , index , true ) ;
198+ }
199+
78200 async waitForNewPage (
79201 action ?: ( ) => Promise < unknown > | unknown ,
80202 opts ?: { timeout ?: number } ,
@@ -94,6 +216,34 @@ export class BrowserAgentPageController<Page, NewPageEvent> {
94216 this . adapter . offNewPage ( this . newPageHandler ) ;
95217 }
96218
219+ private async pageSummary (
220+ page : Page ,
221+ index : number ,
222+ active : boolean ,
223+ ) : Promise < BrowserAgentPageSummary > {
224+ let title = '' ;
225+ let url = '' ;
226+
227+ try {
228+ title = await this . adapter . pageTitle ( page ) ;
229+ } catch ( error ) {
230+ this . debug ( `failed to read page title: ${ error } ` ) ;
231+ }
232+
233+ try {
234+ url = this . adapter . pageUrl ( page ) ;
235+ } catch ( error ) {
236+ this . debug ( `failed to read page url: ${ error } ` ) ;
237+ }
238+
239+ return {
240+ index,
241+ active,
242+ title,
243+ url,
244+ } ;
245+ }
246+
97247 private async followNewPage ( event : NewPageEvent ) {
98248 if ( ! this . isNewPageEvent ( event ) ) {
99249 return ;
@@ -165,3 +315,41 @@ export class BrowserAgentPageController<Page, NewPageEvent> {
165315 return { promise, dispose } ;
166316 }
167317}
318+
319+ export const createBrowserAgentPageActions = < Page , NewPageEvent > ( options : {
320+ agentName : string ;
321+ getPageController : ( ) => BrowserAgentPageController < Page , NewPageEvent > ;
322+ } ) : DeviceAction < any > [ ] => [
323+ {
324+ name : 'ListBrowserPages' ,
325+ description :
326+ 'List all open browser pages/tabs and show which one is currently active. Use this before switching pages when a task refers to another tab or window.' ,
327+ call : async ( ) => options . getPageController ( ) . pageSummaries ( ) ,
328+ } ,
329+ {
330+ name : 'SetActivePage' ,
331+ description :
332+ 'Set the active browser page/tab by 0-based index, title substring, or URL substring. Use index from ListBrowserPages when more than one page could match.' ,
333+ paramSchema : setActivePageParamSchema ,
334+ sample : {
335+ index : 1 ,
336+ } ,
337+ call : async ( param ) =>
338+ options . getPageController ( ) . setActivePageBySelector ( param ) ,
339+ } ,
340+ ] ;
341+
342+ export const appendBrowserAgentPageActions = (
343+ customActions : DeviceAction < any > [ ] | undefined ,
344+ browserActions : DeviceAction < any > [ ] ,
345+ ) => {
346+ if ( ! customActions ?. length ) {
347+ return browserActions ;
348+ }
349+
350+ const customActionNames = new Set ( customActions . map ( ( action ) => action . name ) ) ;
351+ return [
352+ ...customActions ,
353+ ...browserActions . filter ( ( action ) => ! customActionNames . has ( action . name ) ) ,
354+ ] ;
355+ } ;
0 commit comments