11const DEFAULT_GLOBAL_NAME = '__BINANCE_LIVE_PERFORMANCE_PROBE__' ;
2+ const DEFAULT_COMPLETION_GLOBAL_NAME = '__BINANCE_LIVE_PERFORMANCE_COMPLETION__' ;
23
34function assertRecord ( value , path ) {
45 if ( ! value || typeof value !== 'object' || Array . isArray ( value ) ) {
@@ -66,11 +67,122 @@ export function validateLivePerformanceProbeSnapshot(snapshot) {
6667 return snapshot ;
6768}
6869
70+ export function waitForBinanceLivePerformanceCompletion ( options = { } ) {
71+ const globalName = options . globalName || '__BINANCE_LIVE_PERFORMANCE_PROBE__' ;
72+ const kind = options . kind ;
73+ const timeoutMs = options . timeoutMs || 5_000 ;
74+ const panelSelector = options . panelSelector || '#jh-binance-close-qty-multiplier-panel' ;
75+ const cancelButtonSelector = options . cancelButtonSelector
76+ || '[data-ladder-cancel-symbol="true"]' ;
77+ const statusSelector = options . statusSelector || '#jh-binance-ladder-status' ;
78+ const dialogSelector = options . dialogSelector
79+ || '[role="dialog"], [class*="modal"], [class*="Modal"]' ;
80+ const supportedKinds = [ 'no-orders' , 'dialog-cancel' , 'dialog-confirm' ] ;
81+ if ( ! supportedKinds . includes ( kind ) ) {
82+ throw new Error ( `Unsupported live completion kind: ${ kind } ` ) ;
83+ }
84+ if ( ! Number . isInteger ( timeoutMs ) || timeoutMs <= 0 ) {
85+ throw new Error ( 'timeoutMs must be a positive integer' ) ;
86+ }
87+ if ( ! document . body ) throw new Error ( 'Live completion wait requires document.body' ) ;
88+ const probe = window [ globalName ] ;
89+ if ( ! probe ) throw new Error ( `Live performance probe is not installed: ${ globalName } ` ) ;
90+
91+ const normalizeText = ( value ) => String ( value || '' ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
92+ const isVisible = ( element ) => Boolean ( element && element . getClientRects ( ) . length ) ;
93+ const matchesDialogText = ( value ) => / 取 消 全 部 订 单 | C a n c e l a l l o r d e r s / i. test ( value ) ;
94+ const readVisibleDialog = ( ) => Array . from ( document . querySelectorAll ( dialogSelector ) )
95+ . filter ( isVisible )
96+ . find ( ( dialog ) => matchesDialogText ( normalizeText ( dialog . textContent ) ) ) || null ;
97+ const readState = ( ) => {
98+ const panel = document . querySelector ( panelSelector ) ;
99+ const cancelButton = panel ?. querySelector ( cancelButtonSelector ) || null ;
100+ return {
101+ cancelButtonPresent : Boolean ( cancelButton ) ,
102+ cancelButtonDisabled : Boolean ( cancelButton ?. disabled ) ,
103+ cancelButtonText : normalizeText ( cancelButton ?. textContent ) ,
104+ statusText : normalizeText ( panel ?. querySelector ( statusSelector ) ?. textContent ) ,
105+ dialogVisible : Boolean ( readVisibleDialog ( ) ) ,
106+ } ;
107+ } ;
108+ const hasEvent = ( snapshot , eventKind ) => (
109+ snapshot . events . some ( ( event ) => event . kind === eventKind )
110+ ) ;
111+ const isReady = ( ) => {
112+ const snapshot = probe . snapshot ( ) ;
113+ if ( snapshot . startedAtMonotonicMs === null || snapshot . finishedAtMonotonicMs !== null ) {
114+ return false ;
115+ }
116+ if ( ! snapshot . firstFeedbackCaptured || ! hasEvent ( snapshot , 'first-feedback' ) ) return false ;
117+ const state = readState ( ) ;
118+ if ( ! state . cancelButtonPresent || state . cancelButtonDisabled || state . dialogVisible ) return false ;
119+
120+ if ( kind === 'no-orders' ) {
121+ if ( hasEvent ( snapshot , 'dialog-visible' ) || hasEvent ( snapshot , 'dialog-action' ) ) return false ;
122+ return state . cancelButtonText === '无挂单' || / 当 前 币 无 挂 单 / . test ( state . statusText ) ;
123+ }
124+
125+ const dialogActions = snapshot . events . filter ( ( event ) => event . kind === 'dialog-action' ) ;
126+ if ( dialogActions . length !== 1 || ! hasEvent ( snapshot , 'dialog-hidden' ) ) return false ;
127+ const expectsPrimary = kind === 'dialog-confirm' ;
128+ if ( dialogActions [ 0 ] . detail ?. primary !== expectsPrimary ) return false ;
129+ return kind === 'dialog-cancel'
130+ ? / 已 取 消 撤 单 .* 已 恢 复 页 面 状 态 / . test ( state . statusText )
131+ : / 撤 单 流 程 结 束 .* 已 恢 复 筛 选 状 态 / . test ( state . statusText ) ;
132+ } ;
133+
134+ return new Promise ( ( resolve , reject ) => {
135+ let settled = false ;
136+ let checkQueued = false ;
137+ const queueAttempt = ( ) => {
138+ if ( settled || checkQueued ) return ;
139+ checkQueued = true ;
140+ queueMicrotask ( ( ) => {
141+ checkQueued = false ;
142+ attemptFinish ( ) ;
143+ } ) ;
144+ } ;
145+ const observer = new MutationObserver ( queueAttempt ) ;
146+ const cleanup = ( ) => {
147+ observer . disconnect ( ) ;
148+ window . clearTimeout ( timeoutId ) ;
149+ } ;
150+ const fail = ( error ) => {
151+ if ( settled ) return ;
152+ settled = true ;
153+ cleanup ( ) ;
154+ reject ( error ) ;
155+ } ;
156+ function attemptFinish ( ) {
157+ if ( settled || ! isReady ( ) ) return ;
158+ settled = true ;
159+ cleanup ( ) ;
160+ try {
161+ resolve ( probe . finishAfterPerformanceTail ( ) ) ;
162+ } catch ( error ) {
163+ reject ( error ) ;
164+ }
165+ }
166+ const timeoutId = window . setTimeout ( ( ) => {
167+ fail ( new Error ( `Live completion timed out for ${ kind } ` ) ) ;
168+ } , timeoutMs ) ;
169+ observer . observe ( document . body , {
170+ subtree : true ,
171+ childList : true ,
172+ characterData : true ,
173+ attributes : true ,
174+ attributeFilter : [ 'disabled' , 'aria-disabled' , 'class' , 'style' ] ,
175+ } ) ;
176+ attemptFinish ( ) ;
177+ } ) ;
178+ }
179+
69180export function installBinanceLivePerformanceProbe ( options = { } ) {
70181 const globalName = options . globalName || '__BINANCE_LIVE_PERFORMANCE_PROBE__' ;
71182 const panelSelector = options . panelSelector || '#jh-binance-close-qty-multiplier-panel' ;
72183 const cancelButtonSelector = options . cancelButtonSelector
73184 || '[data-ladder-cancel-symbol="true"]' ;
185+ const readyCancelButtonText = options . readyCancelButtonText || '撤单' ;
74186 const statusSelector = options . statusSelector || '#jh-binance-ladder-status' ;
75187 const dialogSelector = options . dialogSelector
76188 || '[role="dialog"], [class*="modal"], [class*="Modal"]' ;
@@ -139,6 +251,8 @@ export function installBinanceLivePerformanceProbe(options = {}) {
139251 let observedDialogRoot = null ;
140252 let longTaskObserver = null ;
141253 let longAnimationFrameObserver = null ;
254+ let performanceCollectionActive = false ;
255+ let finalizationPromise = null ;
142256 let sampleQueued = false ;
143257 const hasStarted = ( ) => Boolean ( run && run . startedAtMonotonicMs !== null ) ;
144258 const isCollecting = ( ) => Boolean ( hasStarted ( ) && run . finishedAtMonotonicMs === null ) ;
@@ -159,40 +273,57 @@ export function installBinanceLivePerformanceProbe(options = {}) {
159273 if ( ! isCollecting ( ) ) return ;
160274 appendBounded ( 'events' , { kind, atMs : relativeNow ( ) , detail } , eventLimit ) ;
161275 } ;
276+ const recordLongTasks = ( entries ) => {
277+ if ( ! performanceCollectionActive ) return ;
278+ const cutoff = run . finishedAtMonotonicMs ?? Number . POSITIVE_INFINITY ;
279+ for ( const entry of entries ) {
280+ if ( entry . startTime > cutoff ) continue ;
281+ appendBounded ( 'longTasks' , {
282+ startTime : entry . startTime ,
283+ duration : entry . duration ,
284+ } , performanceLimit ) ;
285+ }
286+ } ;
287+ const recordLongAnimationFrames = ( entries ) => {
288+ if ( ! performanceCollectionActive ) return ;
289+ const cutoff = run . finishedAtMonotonicMs ?? Number . POSITIVE_INFINITY ;
290+ for ( const entry of entries ) {
291+ if ( entry . startTime > cutoff ) continue ;
292+ appendBounded ( 'longAnimationFrames' , {
293+ startTime : entry . startTime ,
294+ duration : entry . duration ,
295+ blockingDuration : entry . blockingDuration ,
296+ forcedStyleAndLayoutDuration : entry . scripts ?. reduce (
297+ ( total , script ) => total + ( script . forcedStyleAndLayoutDuration || 0 ) ,
298+ 0 ,
299+ ) || 0 ,
300+ } , performanceLimit ) ;
301+ }
302+ } ;
303+ const flushPerformanceObservers = ( ) => {
304+ recordLongTasks ( longTaskObserver ?. takeRecords ?. ( ) || [ ] ) ;
305+ recordLongAnimationFrames ( longAnimationFrameObserver ?. takeRecords ?. ( ) || [ ] ) ;
306+ } ;
162307 const stopPerformanceObservers = ( ) => {
308+ flushPerformanceObservers ( ) ;
163309 longTaskObserver ?. disconnect ( ) ;
164310 longAnimationFrameObserver ?. disconnect ( ) ;
165311 longTaskObserver = null ;
166312 longAnimationFrameObserver = null ;
313+ performanceCollectionActive = false ;
167314 } ;
168315 const startPerformanceObservers = ( ) => {
169316 stopPerformanceObservers ( ) ;
317+ performanceCollectionActive = true ;
170318 if ( run . performanceSupport . longTask ) {
171319 longTaskObserver = new PerformanceObserver ( ( list ) => {
172- if ( ! isCollecting ( ) ) return ;
173- for ( const entry of list . getEntries ( ) ) {
174- appendBounded ( 'longTasks' , {
175- startTime : entry . startTime ,
176- duration : entry . duration ,
177- } , performanceLimit ) ;
178- }
320+ recordLongTasks ( list . getEntries ( ) ) ;
179321 } ) ;
180322 longTaskObserver . observe ( { type : 'longtask' } ) ;
181323 }
182324 if ( run . performanceSupport . longAnimationFrame ) {
183325 longAnimationFrameObserver = new PerformanceObserver ( ( list ) => {
184- if ( ! isCollecting ( ) ) return ;
185- for ( const entry of list . getEntries ( ) ) {
186- appendBounded ( 'longAnimationFrames' , {
187- startTime : entry . startTime ,
188- duration : entry . duration ,
189- blockingDuration : entry . blockingDuration ,
190- forcedStyleAndLayoutDuration : entry . scripts ?. reduce (
191- ( total , script ) => total + ( script . forcedStyleAndLayoutDuration || 0 ) ,
192- 0 ,
193- ) || 0 ,
194- } , performanceLimit ) ;
195- }
326+ recordLongAnimationFrames ( list . getEntries ( ) ) ;
196327 } ) ;
197328 longAnimationFrameObserver . observe ( { type : 'long-animation-frame' } ) ;
198329 }
@@ -346,16 +477,38 @@ export function installBinanceLivePerformanceProbe(options = {}) {
346477 window . addEventListener ( 'unhandledrejection' , handleUnhandledRejection ) ;
347478
348479 const cloneRun = ( ) => structuredClone ( run ) ;
480+ const markRunFinished = ( ) => {
481+ if ( ! hasStarted ( ) ) throw new Error ( 'Live performance probe has not started' ) ;
482+ if ( run . finishedAtMonotonicMs === null ) {
483+ recordSemanticState ( 'finish' ) ;
484+ appendEvent ( 'finished' ) ;
485+ run . finishedAtMonotonicMs = performance . now ( ) ;
486+ stopMutationObservers ( ) ;
487+ }
488+ } ;
349489 const api = {
350490 arm ( scenarioName ) {
351491 if ( destroyed ) throw new Error ( 'Live performance probe is destroyed' ) ;
492+ if ( finalizationPromise ) {
493+ throw new Error ( 'Cannot arm while live performance evidence is finalizing' ) ;
494+ }
352495 if ( typeof scenarioName !== 'string' || scenarioName . length === 0 ) {
353496 throw new Error ( 'scenarioName must be a non-empty string' ) ;
354497 }
355498 if ( run && run . finishedAtMonotonicMs === null ) {
356499 if ( run . startedAtMonotonicMs === null && run . scenarioName === scenarioName ) return cloneRun ( ) ;
357500 throw new Error ( 'Cannot arm while a live performance run is active' ) ;
358501 }
502+ const initialState = readSemanticState ( ) ;
503+ if (
504+ ! initialState . panelPresent
505+ || ! initialState . cancelButtonPresent
506+ || initialState . cancelButtonDisabled
507+ || initialState . cancelButtonText !== readyCancelButtonText
508+ || initialState . dialogVisible
509+ ) {
510+ throw new Error ( 'Live performance probe cannot arm before the cancel UI is fully ready' ) ;
511+ }
359512 stopPerformanceObservers ( ) ;
360513 const armedAtMonotonicMs = performance . now ( ) ;
361514 run = {
@@ -391,16 +544,26 @@ export function installBinanceLivePerformanceProbe(options = {}) {
391544 return cloneRun ( ) ;
392545 } ,
393546 finish ( ) {
394- if ( ! hasStarted ( ) ) throw new Error ( 'Live performance probe has not started' ) ;
395- if ( run . finishedAtMonotonicMs === null ) {
396- recordSemanticState ( 'finish' ) ;
397- appendEvent ( 'finished' ) ;
398- run . finishedAtMonotonicMs = performance . now ( ) ;
399- stopPerformanceObservers ( ) ;
400- stopMutationObservers ( ) ;
547+ if ( finalizationPromise ) {
548+ throw new Error ( 'Live performance evidence is still finalizing' ) ;
401549 }
550+ markRunFinished ( ) ;
551+ stopPerformanceObservers ( ) ;
402552 return cloneRun ( ) ;
403553 } ,
554+ finishAfterPerformanceTail ( ) {
555+ if ( finalizationPromise ) return finalizationPromise ;
556+ markRunFinished ( ) ;
557+ finalizationPromise = new Promise ( ( resolve ) => {
558+ window . setTimeout ( ( ) => {
559+ stopPerformanceObservers ( ) ;
560+ const snapshot = cloneRun ( ) ;
561+ finalizationPromise = null ;
562+ resolve ( snapshot ) ;
563+ } , 0 ) ;
564+ } ) ;
565+ return finalizationPromise ;
566+ } ,
404567 destroy ( ) {
405568 if ( destroyed ) return run ? cloneRun ( ) : null ;
406569 destroyed = true ;
@@ -421,6 +584,39 @@ export function createLivePerformanceProbeExpression(options = {}) {
421584 return `(${ installBinanceLivePerformanceProbe . toString ( ) } )(${ JSON . stringify ( options ) } )` ;
422585}
423586
587+ export function createLivePerformanceCompletionExpression ( options = { } ) {
588+ return `(${ waitForBinanceLivePerformanceCompletion . toString ( ) } )(${ JSON . stringify ( options ) } )` ;
589+ }
590+
591+ export function createLivePerformanceCompletionPreparationExpression ( options = { } ) {
592+ const completionGlobalName = options . completionGlobalName || DEFAULT_COMPLETION_GLOBAL_NAME ;
593+ const waitOptions = { ...options } ;
594+ delete waitOptions . completionGlobalName ;
595+ const waitExpression = createLivePerformanceCompletionExpression ( waitOptions ) ;
596+ return `(() => {
597+ const completionGlobalName = ${ JSON . stringify ( completionGlobalName ) } ;
598+ if (window[completionGlobalName]) {
599+ throw new Error('Live performance completion is already prepared');
600+ }
601+ window[completionGlobalName] = ${ waitExpression } ;
602+ return { completionGlobalName, prepared: true };
603+ })()` ;
604+ }
605+
606+ export function createLivePerformanceCompletionResultExpression ( options = { } ) {
607+ const completionGlobalName = options . completionGlobalName || DEFAULT_COMPLETION_GLOBAL_NAME ;
608+ return `(async () => {
609+ const completionGlobalName = ${ JSON . stringify ( completionGlobalName ) } ;
610+ const completion = window[completionGlobalName];
611+ if (!completion) throw new Error('Live performance completion is not prepared');
612+ try {
613+ return await completion;
614+ } finally {
615+ delete window[completionGlobalName];
616+ }
617+ })()` ;
618+ }
619+
424620export async function installLivePerformanceProbe ( page , options = { } ) {
425621 return page . evaluate ( installBinanceLivePerformanceProbe , options ) ;
426622}
@@ -436,6 +632,18 @@ export async function finishLivePerformanceProbe(page) {
436632 return page . evaluate ( ( globalName ) => window [ globalName ] . finish ( ) , DEFAULT_GLOBAL_NAME ) ;
437633}
438634
635+ export async function prepareLivePerformanceProbeCompletion ( page , kind , options = { } ) {
636+ return page . evaluate ( createLivePerformanceCompletionPreparationExpression ( {
637+ ...options ,
638+ globalName : DEFAULT_GLOBAL_NAME ,
639+ kind,
640+ } ) ) ;
641+ }
642+
643+ export async function finishLivePerformanceProbeWhenReady ( page , options = { } ) {
644+ return page . evaluate ( createLivePerformanceCompletionResultExpression ( options ) ) ;
645+ }
646+
439647export async function destroyLivePerformanceProbe ( page ) {
440648 return page . evaluate ( ( globalName ) => window [ globalName ] ?. destroy ?. ( ) || null , DEFAULT_GLOBAL_NAME ) ;
441649}
0 commit comments