@@ -251,6 +251,9 @@ def test_pr_view_has_accessible_refresh_button_and_status_region():
251251def _extract_js_function (source : str , name : str ) -> str :
252252 """Slice a whole `function name(...) {...}` declaration out of the JS."""
253253 start = source .index (f"function { name } (" )
254+ # Keep a leading `async` so awaited helpers stay valid when re-run in Node.
255+ if start >= 6 and source [start - 6 : start ] == "async " :
256+ start -= 6
254257 depth = 0
255258 for pos in range (source .index ("{" , start ), len (source )):
256259 if source [pos ] == "{" :
@@ -485,38 +488,69 @@ def test_focus_restoration_logic_is_present():
485488 html = render_dashboard ()
486489 assert "let detailInvoker = null;" in html
487490 assert "let credInvoker = null;" in html
491+ assert "let detailRequestGen = 0;" in html
488492 assert "function openDetail" in html
489- assert "const trigger = document.activeElement;" in html
493+ assert "const trigger = invoker !== undefined ? invoker : document.activeElement;" in html
490494 assert "detailInvoker = trigger;" in html
491495 assert "function closeDetail" in html
492496 assert "invoker.focus()" in html
493497 assert "function openCredentialsModal" in html
494- assert "credInvoker = document.activeElement " in html
498+ assert "credInvoker = trigger; " in html
495499 assert "function closeCredentialsModal" in html
500+ assert "function openServerDetail" in html
501+ assert "if (gen !== detailRequestGen) return;" in html
502+ # Real callers must keep refresh=true on background reloads.
503+ assert "openServerDetail(name, true)" in html
504+ assert "openServerDetail(data.name, true)" in html
496505
497506
498507_FOCUS_HARNESS = r"""
508+ const panelClassList = {
509+ open: false,
510+ add(c) { if (c === 'open') this.open = true; },
511+ remove(c) { if (c === 'open') this.open = false; },
512+ contains(c) { return c === 'open' ? this.open : false; },
513+ };
514+ const detailPanel = {
515+ classList: panelClassList,
516+ appendChild() {},
517+ contains() { return false; },
518+ innerHTML: '',
519+ textContent: '',
520+ style: {},
521+ };
499522const document = {
500523 activeElement: null,
501524 contains(el) { return true; },
502525 getElementById(id) {
526+ if (id === 'detail-panel') return detailPanel;
503527 return {
504- classList: { remove() {}, add() {} },
528+ classList: { remove() {}, add() {}, contains() { return false; } },
505529 appendChild() {},
506530 contains() { return false; },
507531 innerHTML: '',
508532 textContent: '',
509533 style: {},
534+ disabled: false,
510535 };
511536 }
512537};
513538let selectedNode = null;
514539let writeUrlState = () => {};
515540const makeBadge = () => ({ classList: { remove() {} } });
516541const formatLaunch = () => '-';
542+ function toast() {}
517543
518544let detailInvoker = null;
519545let credInvoker = null;
546+ let detailRequestGen = 0;
547+
548+ let pendingApi = null;
549+ function api(url) {
550+ return new Promise((resolve, reject) => {
551+ pendingApi = { url, resolve, reject };
552+ });
553+ }
520554
521555/*__DASHBOARD_JS__*/
522556
@@ -527,36 +561,79 @@ def test_focus_restoration_logic_is_present():
527561});
528562const cardA = makeCard();
529563const cardB = makeCard();
564+ const commandBar = {
565+ tagName: 'INPUT',
566+ focusCalled: false,
567+ focus() { this.focusCalled = true; },
568+ };
530569
531- document.activeElement = cardA;
532- openDetail({ name: 'a' });
533- const afterOpenDetailInvoker = detailInvoker;
534-
535- // Selecting another server while the panel stays open must retarget focus.
536- document.activeElement = cardB;
537- openDetail({ name: 'b' });
538- const afterReselectInvoker = detailInvoker;
539-
540- // A background refresh (WebSocket update) while the user works elsewhere
541- // (e.g. the command bar) must not steal the return target from the last
542- // explicitly selected row.
543- const commandBar = { tagName: 'INPUT', focusCalled: false, focus() { this.focusCalled = true; } };
544- document.activeElement = commandBar;
545- openDetail({ name: 'b' }, true);
546- const afterRefreshInvoker = detailInvoker;
547-
548- closeDetail();
549- const afterCloseDetailInvoker = detailInvoker;
550-
551- process.stdout.write(JSON.stringify({
552- detailInvokerSet: afterOpenDetailInvoker === cardA,
553- detailInvokerFollowsSelection: afterReselectInvoker === cardB,
554- detailInvokerSurvivesRefresh: afterRefreshInvoker === cardB,
555- detailInvokerCleared: afterCloseDetailInvoker === null,
556- focusCalled: cardB.focusCalled,
557- staleInvokerFocused: cardA.focusCalled,
558- refreshFocusStolen: commandBar.focusCalled,
559- }));
570+ (async () => {
571+ document.activeElement = cardA;
572+ openDetail({ name: 'a' });
573+ const afterOpenDetailInvoker = detailInvoker;
574+
575+ // Selecting another server while the panel stays open must retarget focus.
576+ document.activeElement = cardB;
577+ openDetail({ name: 'b' });
578+ const afterReselectInvoker = detailInvoker;
579+
580+ // A background refresh (WebSocket update) while the user works elsewhere
581+ // (e.g. the command bar) must not steal the return target from the last
582+ // explicitly selected row.
583+ document.activeElement = commandBar;
584+ openDetail({ name: 'b' }, true);
585+ const afterRefreshInvoker = detailInvoker;
586+
587+ // Real caller: openServerDetail captures the invoker before the await.
588+ // Move focus to the command bar while the API response is still pending.
589+ detailInvoker = cardB;
590+ selectedNode = { name: 'b' };
591+ panelClassList.open = true;
592+ document.activeElement = cardB;
593+ const openPromise = openServerDetail('b', false, cardB);
594+ document.activeElement = commandBar;
595+ pendingApi.resolve({
596+ name: 'b', env_required: [], env_configured: true, enabled: true,
597+ });
598+ await openPromise;
599+ const afterAsyncInvoker = detailInvoker;
600+
601+ // Background refresh via the real caller must pass refresh=true and keep
602+ // the prior invoker even when focus sits on the command bar.
603+ document.activeElement = commandBar;
604+ const refreshPromise = openServerDetail('b', true);
605+ pendingApi.resolve({
606+ name: 'b', env_required: [], env_configured: true, enabled: true,
607+ });
608+ await refreshPromise;
609+ const afterCallerRefreshInvoker = detailInvoker;
610+
611+ // Stale refresh after the panel closed must not reopen it.
612+ closeDetail();
613+ const afterCloseDetailInvoker = detailInvoker;
614+ panelClassList.open = false;
615+ selectedNode = null;
616+ const staleRefresh = openServerDetail('b', true);
617+ pendingApi.resolve({ name: 'b', env_required: [], env_configured: true });
618+ await staleRefresh;
619+ const staleRefreshReopened = panelClassList.open;
620+
621+ process.stdout.write(JSON.stringify({
622+ detailInvokerSet: afterOpenDetailInvoker === cardA,
623+ detailInvokerFollowsSelection: afterReselectInvoker === cardB,
624+ detailInvokerSurvivesRefresh: afterRefreshInvoker === cardB,
625+ detailInvokerSurvivesAsyncFetch: afterAsyncInvoker === cardB,
626+ detailInvokerSurvivesCallerRefresh: afterCallerRefreshInvoker === cardB,
627+ detailInvokerCleared: afterCloseDetailInvoker === null,
628+ focusCalled: cardB.focusCalled,
629+ staleInvokerFocused: cardA.focusCalled,
630+ refreshFocusStolen: commandBar.focusCalled,
631+ staleRefreshDropped: staleRefreshReopened === false,
632+ }));
633+ })().catch((err) => {
634+ console.error(err);
635+ process.exit(1);
636+ });
560637"""
561638
562639
@@ -567,7 +644,8 @@ def test_focus_restoration_behavior_node(tmp_path):
567644 assert node is not None
568645 html = render_dashboard ()
569646 dashboard_js = "\n " .join (
570- _extract_js_function (html , name ) for name in ("openDetail" , "closeDetail" )
647+ _extract_js_function (html , name )
648+ for name in ("openDetail" , "closeDetail" , "openServerDetail" )
571649 )
572650 script = tmp_path / "focus_restoration.cjs"
573651 script .write_text (
@@ -582,10 +660,13 @@ def test_focus_restoration_behavior_node(tmp_path):
582660 assert res ["detailInvokerSet" ] is True
583661 assert res ["detailInvokerFollowsSelection" ] is True
584662 assert res ["detailInvokerSurvivesRefresh" ] is True
663+ assert res ["detailInvokerSurvivesAsyncFetch" ] is True
664+ assert res ["detailInvokerSurvivesCallerRefresh" ] is True
585665 assert res ["detailInvokerCleared" ] is True
586666 assert res ["focusCalled" ] is True
587667 assert res ["staleInvokerFocused" ] is False
588668 assert res ["refreshFocusStolen" ] is False
669+ assert res ["staleRefreshDropped" ] is True
589670
590671
591672# The credentials modal builds real DOM, so it needs the element shim rather
0 commit comments