@@ -170,95 +170,109 @@ test('an open menu keeps its container and skills group across projection refres
170170 await composer . fill ( 'seed session' ) ;
171171 await composer . press ( 'Enter' ) ;
172172 await expect ( page . getByText ( 'Fake backend received: seed session' ) ) . toBeVisible ( ) ;
173+ await expect ( page . getByRole ( 'button' , { name : '停止' } ) ) . toHaveCount ( 0 ) ;
174+
175+ // The completed turn publishes its own projection refresh. Wait on the
176+ // composer's public loading state so that work cannot spill into the window
177+ // this test is about.
178+ await page . getByRole ( 'button' , { name : '添加上下文' } ) . click ( ) ;
179+ const contextMenu = page . getByRole ( 'menu' , { name : '添加上下文' } ) ;
180+ await expect ( contextMenu . getByRole ( 'menuitem' , { name : / 选 择 技 能 / } ) ) . not . toHaveAttribute (
181+ 'aria-busy' ,
182+ 'true' ,
183+ ) ;
184+ await page . keyboard . press ( 'Escape' ) ;
185+ await expect ( contextMenu ) . toHaveCount ( 0 ) ;
173186
174187 await composer . click ( ) ;
175188 await composer . pressSequentially ( '/' ) ;
176189 const menu = page . getByRole ( 'listbox' , { name : '命令和技能' } ) ;
177190 await expect ( menu . getByRole ( 'group' , { name : 'Skills' } ) ) . toBeVisible ( ) ;
178191
179- // Armed before the refresh: the flicker was the skills group (and with it
180- // the listbox geometry) being torn down and re-created when the projection
181- // cleared and repopulated, so any removal during the refresh is the
182- // regression (#2667).
183- //
184- // The watch is scoped to exactly that property: one observer on the menu
185- // itself (subtree) catches a group being torn down inside it, and one on
186- // its parent catches the whole listbox being replaced. Overlays elsewhere
187- // in the document (toasts, tooltips, other popups) are outside the window.
188- // The observers disconnect once the refresh rounds have settled, and the
189- // count is read once afterwards - a monotonic counter cannot be polled
190- // back to zero, so polling it only added latency (see #3727).
191- await page . evaluate ( ( ) => {
192- const menu = document . querySelector ( '[role="listbox"]' ) ;
193- if ( ! ( menu instanceof HTMLElement ) ) throw new Error ( 'slash menu not found' ) ;
194- const state = {
195- removals : 0 ,
196- observers : [ ] as MutationObserver [ ] ,
197- } ;
198- ( globalThis as unknown as { __slashMenuWatch ?: unknown } ) . __slashMenuWatch = state ;
199- const watch = ( target : Node , childList : MutationObserverInit ) => {
200- const observer = new MutationObserver ( ( mutations ) => {
201- for ( const mutation of mutations ) {
202- for ( const node of mutation . removedNodes ) {
203- if (
204- node === menu ||
205- ( node instanceof HTMLElement && node . matches ( '[role="group"]' ) )
206- ) {
207- state . removals += 1 ;
208- }
209- }
210- }
211- } ) ;
212- observer . observe ( target , childList ) ;
213- state . observers . push ( observer ) ;
214- } ;
215- if ( menu . parentElement ) watch ( menu . parentElement , { childList : true } ) ;
216- watch ( menu , { childList : true , subtree : true } ) ;
217- } ) ;
218-
219192 // A thinking-level change publishes the session's 'updated' event and
220193 // reloads the Skill projection without changing what the menu shows: the
221194 // exact same-content refresh that used to alternate the popup (#2667).
222- const sessionId = await page . evaluate ( async ( ) => {
195+ const observation = await menu . evaluate ( async ( menuElement ) => {
223196 const sessions = await (
224197 window as unknown as {
225198 maka : { sessions : { list ( ) : Promise < Array < { id : string } > > } } ;
226199 }
227200 ) . maka . sessions . list ( ) ;
228- return sessions [ 0 ] ?. id ;
229- } ) ;
230- for ( let round = 0 ; round < 3 ; round += 1 ) {
231- await page . evaluate (
232- ( id ) =>
233- (
234- window as unknown as {
235- maka : { sessions : { setThinkingLevel ( id : string , level ?: null ) : Promise < unknown > } } ;
236- }
237- ) . maka . sessions . setThinkingLevel ( id ! , null ) ,
238- sessionId ,
239- ) ;
240- }
241- // The refresh round trip is IPC-fast; the visibility wait below gives it
242- // room while asserting the menu never lost its skills group.
243- await expect ( menu . getByRole ( 'group' , { name : 'Skills' } ) ) . toBeVisible ( ) ;
244- // Settle one extra frame pair so any teardown triggered by the last
245- // refresh lands inside the observation window before it is closed.
246- await page . evaluate (
247- ( ) =>
248- new Promise < void > ( ( resolve ) => {
249- requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
250- } ) ,
251- ) ;
252- const removals = await page . evaluate ( ( ) => {
253- const state = (
254- globalThis as unknown as {
255- __slashMenuWatch ?: { removals : number ; observers : MutationObserver [ ] } ;
201+ const sessionId = sessions [ 0 ] ?. id ;
202+ if ( ! sessionId ) throw new Error ( 'Session missing before projection refresh' ) ;
203+
204+ // Arm immediately before the refreshes and only on this popover. The
205+ // document body also contains unrelated overlays whose teardown says
206+ // nothing about this menu's identity.
207+ const menuContainer = menuElement . parentElement ;
208+ const state = { menuRemovals : 0 , skillsGroupRemovals : 0 } ;
209+ const skillsGroup = menuElement . querySelector < HTMLElement > ( '[role="group"][aria-label="Skills"]' ) ;
210+ if ( ! menuContainer ) throw new Error ( 'Slash menu container missing before projection refresh' ) ;
211+ if ( ! skillsGroup ) throw new Error ( 'Skills group missing before projection refresh' ) ;
212+ const recordRemoval = (
213+ mutations : MutationRecord [ ] ,
214+ watchedNode : Node ,
215+ key : 'menuRemovals' | 'skillsGroupRemovals' ,
216+ ) => {
217+ for ( const mutation of mutations ) {
218+ for ( const node of mutation . removedNodes ) {
219+ if ( node === watchedNode ) state [ key ] += 1 ;
220+ }
221+ }
222+ } ;
223+ const menuObserver = new MutationObserver ( ( mutations ) => {
224+ recordRemoval ( mutations , menuElement , 'menuRemovals' ) ;
225+ } ) ;
226+ const skillsGroupObserver = new MutationObserver ( ( mutations ) => {
227+ recordRemoval ( mutations , skillsGroup , 'skillsGroupRemovals' ) ;
228+ } ) ;
229+ menuObserver . observe ( menuContainer , { childList : true } ) ;
230+ skillsGroupObserver . observe ( menuElement , { childList : true } ) ;
231+ const maka = (
232+ window as unknown as {
233+ maka : {
234+ sessions : {
235+ setThinkingLevel ( id : string , level ?: null ) : Promise < unknown > ;
236+ } ;
237+ } ;
256238 }
257- ) . __slashMenuWatch ;
258- if ( ! state ) throw new Error ( 'slash menu watch was never armed' ) ;
259- for ( const observer of state . observers ) observer . disconnect ( ) ;
260- return state . removals ;
239+ ) . maka ;
240+ const e2eControls = (
241+ window as unknown as {
242+ makaE2eLatch ?: {
243+ waitForInvocableSkillsCall ( sessionId : string ) : Promise < void > ;
244+ } ;
245+ }
246+ ) . makaE2eLatch ;
247+ if ( ! e2eControls ) throw new Error ( 'E2E bridge controls missing before projection refresh' ) ;
248+ try {
249+ for ( let round = 0 ; round < 3 ; round += 1 ) {
250+ const projectionSettled = e2eControls . waitForInvocableSkillsCall ( sessionId ) ;
251+ await maka . sessions . setThinkingLevel ( sessionId , null ) ;
252+ await projectionSettled ;
253+ await new Promise < void > ( ( resolve ) => {
254+ requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
255+ } ) ;
256+ }
257+ } finally {
258+ // Drain the final queued batch before closing the exact refresh window;
259+ // polling a monotonic counter cannot turn a failure into success.
260+ recordRemoval ( menuObserver . takeRecords ( ) , menuElement , 'menuRemovals' ) ;
261+ recordRemoval ( skillsGroupObserver . takeRecords ( ) , skillsGroup , 'skillsGroupRemovals' ) ;
262+ menuObserver . disconnect ( ) ;
263+ skillsGroupObserver . disconnect ( ) ;
264+ }
265+ return {
266+ ...state ,
267+ menuConnected : menuElement . isConnected ,
268+ skillsGroupConnected : skillsGroup . isConnected ,
269+ } ;
270+ } ) ;
271+ expect ( observation ) . toEqual ( {
272+ menuRemovals : 0 ,
273+ skillsGroupRemovals : 0 ,
274+ menuConnected : true ,
275+ skillsGroupConnected : true ,
261276 } ) ;
262- expect ( removals ) . toBe ( 0 ) ;
263277 await expect ( menu . getByRole ( 'group' , { name : 'Skills' } ) ) . toBeVisible ( ) ;
264278} ) ;
0 commit comments