diff --git a/.changeset/in-app-graceful-detach-748.md b/.changeset/in-app-graceful-detach-748.md new file mode 100644 index 0000000..d35eec5 --- /dev/null +++ b/.changeset/in-app-graceful-detach-748.md @@ -0,0 +1,12 @@ +--- +'@ait-co/devtools': patch +--- + +in-app 디버그 표면에 graceful detach 추가 — run 종료 시 우리가 주입한 오버레이·상태를 정리해 미니앱을 조작 가능 상태로 되돌린다 (#748). + +run7 실기기 관측(디버그 세션 종료 후 "Debugger Disconnected" 잔존)의 (b)-가설 코드 원인은 우리 표면 3건이었다: ① CDP로 주입된 `#__ait_debug_indicator` 배지가 종료 후 영구 잔존(`attach-orchestrator.ts` `buildIndicatorExpression`가 렌더, `relay-factory.ts` `close()`가 disconnected로만 바꾸고 제거는 안 함), ② eruda 인-페이지 콘솔이 unmount 없이 잔존, ③ keepAwake가 `beforeunload`에서만 복구돼 세션 종료(비-unload) 시 화면이 계속 awake. + +- **배지**(`buildIndicatorExpression`): disconnected 상태를 non-blocking(`pointer-events:none` 즉시)·self-dismissing(창 이후 fade→DOM 제거)으로 변경. 재연결(`ait:relay-ws-state` open) 시 self-dismiss 취소·재마운트(transient 터널 blip은 배지를 날리지 않음), 컨트롤러는 유지돼 재주입 시 `window.WebSocket` 이중 래핑 없음. 기본 disconnected 라벨을 ko-primary `디버거 연결 끊김`으로. +- **in-app**(`attach.ts` `detachDebugSurface()`): 단일 idempotent·비-throw 정리 함수. 배지 제거 + eruda unmount(`unmountEruda()`) + keepAwake 복구. relay WS 종료의 모든 경로에 배선 — 비-4401 종료는 grace window 후(재연결 시 취소), 4401(TOTP 만료=종결)은 즉시, `error`는 방어적 스케줄, `pagehide`는 즉시(beforeunload-safe). `#478` fail-fast 보존을 위해 WS observer proxy는 의도적으로 유지. + +경계(가설 a): 스피너 + 전체 터치 무반응은 우리 레이어 밖 — 우리 표면은 full-viewport 요소·body 스크롤/pointer 잠금·capture-phase 리스너가 없어 구조적으로 모든 입력을 흡수할 수 없다. 네이티브 토스 앱 오버레이는 JS로 해제 불가라 시도하지 않고 코드 주석으로 명시. 실기기 run7 재현 확인은 폰-게이트라 다음 env3 세션에서 검증 예정. diff --git a/src/__tests__/in-app-attach.test.ts b/src/__tests__/in-app-attach.test.ts index 2caa08f..ec1304d 100644 --- a/src/__tests__/in-app-attach.test.ts +++ b/src/__tests__/in-app-attach.test.ts @@ -491,6 +491,10 @@ describe('installRelayWsObserver', () => { let originalWebSocket: typeof WebSocket | undefined; beforeEach(async () => { + // #748: the close handler now schedules a grace-window detach timer on + // non-4401 closes — fake timers keep that off the real clock so it never + // leaks into a later test, and afterEach clears any pending one. + vi.useFakeTimers(); vi.resetModules(); FakeWebSocket.instances.length = 0; // The observer PATCHES window.WebSocket and a module reset does not undo @@ -508,6 +512,8 @@ describe('installRelayWsObserver', () => { }); afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); window.WebSocket = originalWebSocket as typeof WebSocket; Object.defineProperty(window, 'parent', { value: window, @@ -586,11 +592,12 @@ describe('installRelayWsObserver', () => { const close = ev as CloseEvent; closes.push({ code: close.code, reason: close.reason }); }); - await vi.waitFor(() => - expect(closes).toEqual([ - { code: RELAY_AUTH_REJECT_CLOSE_CODE, reason: RELAY_AUTH_REJECT_REASON }, - ]), - ); + // createFailFastSocket dispatches its terminal close via setTimeout(…, 0); + // flush the fake timer to observe it (fake timers active per beforeEach). + await vi.runAllTimersAsync(); + expect(closes).toEqual([ + { code: RELAY_AUTH_REJECT_CLOSE_CODE, reason: RELAY_AUTH_REJECT_REASON }, + ]); // Non-relay origins keep constructing natively even in the expired state. const foreign = new window.WebSocket('wss://api.app.example.com/live'); @@ -621,6 +628,9 @@ describe('installRelayWsObserver — ait:relay-ws-state broadcast (#730)', () => let originalWebSocket: typeof WebSocket | undefined; beforeEach(async () => { + // #748: non-4401 closes schedule a grace-window detach timer — keep it on + // the fake clock so it never leaks past the test. + vi.useFakeTimers(); vi.resetModules(); FakeWebSocket.instances.length = 0; originalWebSocket = window.WebSocket; @@ -636,6 +646,8 @@ describe('installRelayWsObserver — ait:relay-ws-state broadcast (#730)', () => }); afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); window.WebSocket = originalWebSocket as typeof WebSocket; Object.defineProperty(window, 'parent', { value: window, @@ -926,3 +938,197 @@ describe('reportWebViewType (#580)', () => { expect(postMessageSpy).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Graceful detach (#748) — detachDebugSurface + WS-close teardown wiring +// +// Verifies the in-app half of issue #748: on run end / relay WS close, OUR +// debug-surface elements are removed and the keepAwake side effect is restored, +// so nothing we injected lingers and touch/click reach the app again. Transient +// tunnel blips (reconnect within the grace window) must NOT tear the surface +// down; 4401 (terminal) tears down immediately; the no-attach path is untouched. +// --------------------------------------------------------------------------- + +describe('detachDebugSurface (#748)', () => { + let detachDebugSurface: () => void; + let setScreenAwakeMode: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + document.body.innerHTML = ''; + const framework = await import('@apps-in-toss/web-framework'); + setScreenAwakeMode = framework.setScreenAwakeMode as ReturnType; + setScreenAwakeMode.mockClear(); + setScreenAwakeMode.mockResolvedValue({ enabled: false }); + ({ detachDebugSurface } = await import('../in-app/attach.js')); + }); + + /** Inserts a stand-in indicator badge so its removal can be asserted. */ + function addBadge(): HTMLElement { + const el = document.createElement('div'); + el.id = '__ait_debug_indicator'; + el.style.pointerEvents = 'auto'; + document.body.appendChild(el); + return el; + } + + it('removes the #__ait_debug_indicator badge (blocking element gone)', () => { + addBadge(); + expect(document.getElementById('__ait_debug_indicator')).not.toBeNull(); + detachDebugSurface(); + expect(document.getElementById('__ait_debug_indicator')).toBeNull(); + }); + + it('restores screen sleep — setScreenAwakeMode({ enabled: false })', () => { + detachDebugSurface(); + expect(setScreenAwakeMode).toHaveBeenCalledWith({ enabled: false }); + }); + + it('lets a click on the app reach it after teardown (no leftover badge intercepts)', () => { + addBadge(); + detachDebugSurface(); + // With the badge removed, a click dispatched on the body is delivered to the + // app's own handler — nothing of ours sits on top to swallow it. + const appHandler = vi.fn(); + document.body.addEventListener('click', appHandler); + document.body.dispatchEvent(new Event('click', { bubbles: true })); + expect(appHandler).toHaveBeenCalledTimes(1); + expect(document.getElementById('__ait_debug_indicator')).toBeNull(); + }); + + it('does not throw when there is no badge present', () => { + expect(() => detachDebugSurface()).not.toThrow(); + }); + + it('is idempotent — a second call does not disable awake again', () => { + detachDebugSurface(); + detachDebugSurface(); + expect(setScreenAwakeMode).toHaveBeenCalledTimes(1); + }); + + it('does not throw even if setScreenAwakeMode rejects', async () => { + setScreenAwakeMode.mockRejectedValue(new Error('platform unsupported')); + expect(() => detachDebugSurface()).not.toThrow(); + // Flush the swallowed rejection. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}); + +describe('graceful detach on relay WS close (#748)', () => { + const RELAY_URL = 'wss://relay.example.com/'; + let installRelayWsObserver: (relayUrl: string) => void; + let setScreenAwakeMode: ReturnType; + let originalWebSocket: typeof WebSocket | undefined; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + document.body.innerHTML = ''; + FakeWebSocket.instances.length = 0; + originalWebSocket = window.WebSocket; + window.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + Object.defineProperty(window, 'parent', { + value: { postMessage: vi.fn() }, + writable: true, + configurable: true, + }); + delete (window as unknown as Record).__ait_relay_ws_observed; + const framework = await import('@apps-in-toss/web-framework'); + setScreenAwakeMode = framework.setScreenAwakeMode as ReturnType; + setScreenAwakeMode.mockClear(); + setScreenAwakeMode.mockResolvedValue({ enabled: false }); + ({ installRelayWsObserver } = await import('../in-app/attach.js')); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + window.WebSocket = originalWebSocket as typeof WebSocket; + Object.defineProperty(window, 'parent', { + value: window, + writable: true, + configurable: true, + }); + delete (window as unknown as Record).__ait_relay_ws_observed; + }); + + function addBadge(): HTMLElement { + const el = document.createElement('div'); + el.id = '__ait_debug_indicator'; + document.body.appendChild(el); + return el; + } + + it('tears down after the grace window on a non-4401 relay close', () => { + addBadge(); + installRelayWsObserver(RELAY_URL); + const ws = new window.WebSocket('wss://relay.example.com/target/abc'); + ws.dispatchEvent(closeEventWithCode(1006)); + + // Within the grace window a reconnect could still cancel it — not yet gone. + expect(document.getElementById('__ait_debug_indicator')).not.toBeNull(); + expect(setScreenAwakeMode).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5000); + + expect(document.getElementById('__ait_debug_indicator')).toBeNull(); + expect(setScreenAwakeMode).toHaveBeenCalledWith({ enabled: false }); + }); + + it('a reconnect within the grace window cancels the teardown (transient blip)', () => { + addBadge(); + installRelayWsObserver(RELAY_URL); + const ws = new window.WebSocket('wss://relay.example.com/target/abc'); + ws.dispatchEvent(closeEventWithCode(1006)); + + // Reconnect before the grace elapses. + vi.advanceTimersByTime(1000); + const reconnect = new window.WebSocket('wss://relay.example.com/target/def'); + reconnect.dispatchEvent(new Event('open')); + + // Even well past the original window, teardown must NOT have run. + vi.advanceTimersByTime(10000); + expect(document.getElementById('__ait_debug_indicator')).not.toBeNull(); + expect(setScreenAwakeMode).not.toHaveBeenCalled(); + }); + + it('tears down immediately on a 4401 relay close (terminal — no grace)', () => { + addBadge(); + installRelayWsObserver(RELAY_URL); + const ws = new window.WebSocket('wss://relay.example.com/at/123456/target/abc'); + ws.dispatchEvent(closeEventWithCode(RELAY_AUTH_REJECT_CLOSE_CODE)); + + expect(document.getElementById('__ait_debug_indicator')).toBeNull(); + expect(setScreenAwakeMode).toHaveBeenCalledWith({ enabled: false }); + }); + + it('schedules teardown on a relay WS error (unclean-close path)', () => { + addBadge(); + installRelayWsObserver(RELAY_URL); + const ws = new window.WebSocket('wss://relay.example.com/target/abc'); + ws.dispatchEvent(new Event('error')); + + expect(document.getElementById('__ait_debug_indicator')).not.toBeNull(); + vi.advanceTimersByTime(5000); + expect(document.getElementById('__ait_debug_indicator')).toBeNull(); + }); + + it('runs teardown on pagehide (beforeunload-safe path)', () => { + addBadge(); + installRelayWsObserver(RELAY_URL); + window.dispatchEvent(new Event('pagehide')); + expect(document.getElementById('__ait_debug_indicator')).toBeNull(); + expect(setScreenAwakeMode).toHaveBeenCalledWith({ enabled: false }); + }); + + it('no-attach path: without the observer installed, an app WS close does not tear down', () => { + // Gate blocked → installRelayWsObserver never ran. A plain app WebSocket + // closing must not remove our surface or disable awake (zero behavior change). + addBadge(); + const ws = new FakeWebSocket('wss://api.app.example.com/live'); + ws.dispatchEvent(closeEventWithCode(1006)); + vi.advanceTimersByTime(10000); + expect(document.getElementById('__ait_debug_indicator')).not.toBeNull(); + expect(setScreenAwakeMode).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/in-app-eruda-overlay.test.ts b/src/__tests__/in-app-eruda-overlay.test.ts index efe86de..1b782c4 100644 --- a/src/__tests__/in-app-eruda-overlay.test.ts +++ b/src/__tests__/in-app-eruda-overlay.test.ts @@ -19,15 +19,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // eruda is dynamic-imported by the module under test. vi.mock is hoisted; the // factory is re-evaluated after each resetModules() so the spy is fresh. const initSpy = vi.fn(); +const destroySpy = vi.fn(); vi.mock('eruda', () => ({ default: { init: initSpy, + destroy: destroySpy, }, })); beforeEach(() => { vi.resetModules(); initSpy.mockReset(); + destroySpy.mockReset(); }); afterEach(() => { @@ -62,3 +65,52 @@ describe('mountEruda', () => { expect(initSpy).toHaveBeenCalledTimes(2); }); }); + +describe('unmountEruda (#748 graceful detach)', () => { + it('calls eruda.destroy() when eruda was mounted', async () => { + const { mountEruda, unmountEruda } = await import('../in-app/eruda-overlay.js'); + await mountEruda(); + expect(initSpy).toHaveBeenCalledTimes(1); + + unmountEruda(); + expect(destroySpy).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when eruda was never mounted', async () => { + const { unmountEruda } = await import('../in-app/eruda-overlay.js'); + unmountEruda(); + expect(destroySpy).not.toHaveBeenCalled(); + }); + + it('is idempotent — a second unmount does NOT destroy twice', async () => { + const { mountEruda, unmountEruda } = await import('../in-app/eruda-overlay.js'); + await mountEruda(); + unmountEruda(); + unmountEruda(); + expect(destroySpy).toHaveBeenCalledTimes(1); + }); + + it('resets the guard so a later mount can re-init after unmount', async () => { + const { mountEruda, unmountEruda } = await import('../in-app/eruda-overlay.js'); + await mountEruda(); + unmountEruda(); + // A fresh attach re-mounts. + await mountEruda(); + expect(initSpy).toHaveBeenCalledTimes(2); + }); + + it('is fail-silent — a destroy() throw is swallowed and the guard still resets', async () => { + destroySpy.mockImplementationOnce(() => { + throw new Error('destroy boom'); + }); + const { mountEruda, unmountEruda } = await import('../in-app/eruda-overlay.js'); + await mountEruda(); + + // Does not throw despite destroy() throwing. + expect(() => unmountEruda()).not.toThrow(); + + // Guard reset even on a failed destroy → a later mount re-inits. + await mountEruda(); + expect(initSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/in-app/attach.ts b/src/in-app/attach.ts index cdeaffd..a6b4da5 100644 --- a/src/in-app/attach.ts +++ b/src/in-app/attach.ts @@ -15,7 +15,7 @@ import { RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON, } from '../shared/relay-auth-close.js'; -import { mountEruda } from './eruda-overlay.js'; +import { mountEruda, unmountEruda } from './eruda-overlay.js'; import { checkDebugGate, type GateResult } from './index.js'; /** @@ -206,6 +206,125 @@ function createFailFastSocket(url: string): WebSocket { return sock as unknown as WebSocket; } +// --------------------------------------------------------------------------- +// Graceful detach — return the mini-app to a clean, usable state on run end +// (issue #748). +// +// Real-device observation (run7): after an env3 run completes the phone showed +// a persistent "Debugger Disconnected" badge and the app felt wedged. This is +// the in-app half of the fix — a single idempotent teardown that removes OUR +// debug-surface elements (the CDP-injected indicator badge, the eruda console) +// and restores the keepAwake side effect, so nothing we injected lingers after +// the relay session ends. +// +// Trigger model (all close paths, transient-safe): +// - Normal completion / relay death / tunnel drop that does NOT recover / +// dashboard-initiated stop → relay WS closes (any non-4401 code). We +// schedule teardown after a grace window; a reconnect 'open' cancels it, so +// a transient tunnel blip that target.js recovers from does NOT flash away +// the surface. +// - 4401 (relay TOTP auth-reject, #478) → terminal by design (the session +// cannot reconnect without a QR rescan), so we tear down immediately. +// - WS 'error' with no clean close → scheduled defensively (unclean-close +// path). +// - pagehide → immediate (navigating away; restores keepAwake even if no +// close fired). +// +// OUT OF OUR LAYER (issue #748 hypothesis (a)): a native Toss-app loading +// overlay / spinner that absorbs touches is NOT something a JS surface can +// dismiss — we never try. Our surface adds no full-viewport element, no +// capture-phase document listener, and no body pointer-events/scroll lock, so +// it structurally cannot absorb ALL input; the total-touch-absorb + spinner +// symptom is native and stays device-gated (tracked in #748 / sdk-example#277). +// --------------------------------------------------------------------------- + +/** Grace window before a non-terminal relay close tears the surface down. */ +const RECONNECT_GRACE_MS = 5000; + +/** One-shot guard so the teardown runs at most once per page lifecycle. */ +let debugSurfaceDetached = false; + +/** Pending grace-window timer for a non-terminal close, or `null`. */ +let pendingDetachTimer: ReturnType | null = null; + +/** Cancels a scheduled teardown — called when a relay socket re-opens. */ +function cancelScheduledDetach(): void { + if (pendingDetachTimer !== null) { + clearTimeout(pendingDetachTimer); + pendingDetachTimer = null; + } +} + +/** + * Schedules {@link detachDebugSurface} after {@link RECONNECT_GRACE_MS} unless + * a reconnect cancels it first. No-op if teardown already ran or is already + * scheduled. Defensive: if `setTimeout` is somehow unavailable, tears down + * immediately rather than never. + */ +function scheduleDetach(): void { + if (debugSurfaceDetached || pendingDetachTimer !== null) return; + if (typeof setTimeout === 'undefined') { + detachDebugSurface(); + return; + } + pendingDetachTimer = setTimeout(() => { + pendingDetachTimer = null; + detachDebugSurface(); + }, RECONNECT_GRACE_MS); +} + +/** + * Idempotent, non-throwing teardown of the in-app debug surface (issue #748). + * + * Removes every debug-surface element WE injected and restores the one side + * effect WE applied: + * 1. The CDP-injected `#__ait_debug_indicator` badge (the persistent + * "Debugger Disconnected" element). `buildIndicatorExpression` also + * self-dismisses it; this is the in-app hard guarantee — idempotent, a + * no-op if it is already gone. + * 2. The eruda in-page console (floating button + any open panel). + * 3. keepAwake — forced on at attach; restored here so a run that ends + * WITHOUT a page unload does not leave the screen pinned awake (the + * existing `beforeunload` restore only covers the unload path). + * + * Deliberately NOT touched: the `window.WebSocket` observer proxy (kept so the + * #478 post-4401 fail-fast survives; it is non-blocking and never absorbs + * input), and any NATIVE overlay (out of our layer — see the block comment + * above and issue #748 hypothesis (a)). + * + * Never throws into the host app — every step is individually guarded. + * Exported for unit tests and for a consumer that wants to force a clean detach. + */ +export function detachDebugSurface(): void { + if (debugSurfaceDetached) return; + debugSurfaceDetached = true; + cancelScheduledDetach(); + + // 1. Remove the on-phone indicator badge if it is still present. + try { + if (typeof document !== 'undefined') { + document.getElementById('__ait_debug_indicator')?.remove(); + } + } catch { + // Never let a DOM edge case break teardown. + } + + // 2. Tear down the eruda console (unmountEruda is itself fail-silent). + try { + unmountEruda(); + } catch { + // unmountEruda already swallows internally; this is belt-and-suspenders. + } + + // 3. Restore normal screen sleep. SECRET-HANDLING: no relay/TOTP value is + // read or logged here — this only flips the awake flag off. + try { + void setScreenAwakeMode({ enabled: false }).catch(() => {}); + } catch { + // Some platforms/mock reject synchronously — swallow. + } +} + /** * Wraps `window.WebSocket` with a relay-origin-scoped observer (issue #478). * @@ -232,6 +351,12 @@ export function installRelayWsObserver(relayUrl: string): void { // instead of installing a second Proxy on window.WebSocket. window.__ait_relay_ws_observed = true; + // #748: beforeunload-safe teardown — navigating away restores keepAwake and + // clears the surface even if no WS close fired. Registered ONLY here (inside + // the observer install, which runs only after a debug attach), so there is + // zero behavior change when no debug attach happened. Idempotent + fail-safe. + window.addEventListener('pagehide', () => detachDebugSurface(), { once: true }); + const NativeWebSocket = window.WebSocket; const observed = new Proxy(NativeWebSocket, { construct(target, args: unknown[]): object { @@ -248,14 +373,31 @@ export function installRelayWsObserver(relayUrl: string): void { // #730: broadcast generic open/close lifecycle (any close code) so the // debug indicator can flip its live badge — additive to the existing // 4401-specific branch below, which is untouched. - ws.addEventListener('open', () => broadcastRelayWsState('open')); + ws.addEventListener('open', () => { + broadcastRelayWsState('open'); + // #748: a (re)connect aborts any pending graceful-detach — a transient + // tunnel blip that target.js recovers from must not tear the surface down. + cancelScheduledDetach(); + }); ws.addEventListener('close', (event) => { broadcastRelayWsState('close'); if ((event as CloseEvent).code === RELAY_AUTH_REJECT_CLOSE_CODE) { relayAuthExpired = true; notifyAuthExpired(); + // #748: 4401 is terminal by design (no reconnect without a QR rescan) + // — tear down immediately, no grace window. + detachDebugSurface(); + } else { + // #748: any other close may be transient — schedule teardown after a + // grace window; a reconnect 'open' cancels it. + scheduleDetach(); } }); + // #748: an error that never emits a clean close still needs teardown + // (unclean-close path) — schedule defensively; a recovery 'open' cancels it. + ws.addEventListener('error', () => { + scheduleDetach(); + }); return ws; }, }); diff --git a/src/in-app/eruda-overlay.ts b/src/in-app/eruda-overlay.ts index 6556e84..a868a92 100644 --- a/src/in-app/eruda-overlay.ts +++ b/src/in-app/eruda-overlay.ts @@ -34,6 +34,13 @@ /** Module-level guard against double mount across repeated `maybeAttach` calls. */ let erudaMounted = false; +/** + * The loaded eruda module, captured on a successful {@link mountEruda} so + * {@link unmountEruda} can call `.destroy()` on the same instance during + * graceful detach (#748). `null` when eruda was never mounted. + */ +let erudaModule: { init(): void; destroy(): void } | null = null; + /** * Mounts the eruda in-page console once. * @@ -56,9 +63,40 @@ export async function mountEruda(): Promise { try { const eruda = (await import('eruda')).default; eruda.init(); + // Capture for graceful detach (#748) — only after init() succeeds. + erudaModule = eruda; } catch (err) { // Reset so a later attach can retry; never break the Chii session. erudaMounted = false; console.debug('[@ait-co/devtools] eruda console mount skipped:', err); } } + +/** + * Unmounts the eruda in-page console (#748 graceful detach). + * + * Calls `eruda.destroy()`, which removes eruda's floating entry button, any + * open panel, and its `#eruda` shadow host — returning the phone screen to a + * clean, non-debug state when a debug session ends. After a successful unmount + * the guard is reset so a later {@link mountEruda} (a fresh attach) can + * re-mount. + * + * Idempotent: a call when eruda was never mounted (or already unmounted) is a + * no-op. Fail-silent: a `destroy()` throw is swallowed — teardown must never + * throw into the host app. + */ +export function unmountEruda(): void { + if (!erudaMounted || erudaModule === null) { + return; + } + try { + erudaModule.destroy(); + } catch (err) { + console.debug('[@ait-co/devtools] eruda console unmount skipped:', err); + } finally { + // Reset regardless — a failed destroy should not wedge the guard, and a + // later attach must be free to re-mount. + erudaMounted = false; + erudaModule = null; + } +} diff --git a/src/in-app/index.ts b/src/in-app/index.ts index 58e57dc..3472aee 100644 --- a/src/in-app/index.ts +++ b/src/in-app/index.ts @@ -24,8 +24,13 @@ import { evaluateDebugGate, type GateResult } from './gate.js'; -export { deriveTargetScriptUrl, maybeAttach, reportWebViewType } from './attach.js'; -export { mountEruda } from './eruda-overlay.js'; +export { + deriveTargetScriptUrl, + detachDebugSurface, + maybeAttach, + reportWebViewType, +} from './attach.js'; +export { mountEruda, unmountEruda } from './eruda-overlay.js'; export type { GateInput, GateResult, GateResultAttach, GateResultBlocked } from './gate.js'; export { evaluateDebugGate, diff --git a/src/mcp/__tests__/indicator-expression.test.ts b/src/mcp/__tests__/indicator-expression.test.ts new file mode 100644 index 0000000..1a6184c --- /dev/null +++ b/src/mcp/__tests__/indicator-expression.test.ts @@ -0,0 +1,137 @@ +/** + * Unit tests for `buildIndicatorExpression` DOM behavior (#730 + #748). + * + * The function returns a self-contained IIFE string that a debug session + * evaluates on the phone via `Runtime.evaluate`. Here we evaluate that string + * in jsdom (indirect eval, same pattern as test-runner-bundle.test.ts) and + * assert the rendered badge's behavior: + * + * - attached → red badge, `pointer-events:auto`, tap-dismissable. + * - #748 disconnected → NON-BLOCKING (`pointer-events:none`) + SELF-DISMISSING + * (fades and removes itself after the window), so a run that ends with + * `close()` injecting `{ state: 'disconnected' }` no longer leaves a permanent + * "Debugger Disconnected" element that intercepts taps on the phone. + * - reconnect before the self-dismiss cancels it and restores the badge + * (transient tunnel blips do not flash-remove it). + * + * The relay-WS observer inside the expression takes its preferred branch when + * `window.__ait_relay_ws_observed` is set, so no `window.WebSocket` Proxy is + * installed (jsdom has no WebSocket) — state is driven via the enum-only + * `ait:relay-ws-state` CustomEvent and via re-injection, exactly as in prod. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildIndicatorExpression } from '../attach-orchestrator.js'; + +const BADGE_ID = '__ait_debug_indicator'; +// Kept in sync with attach-orchestrator.ts constants. +const SELF_DISMISS_MS = 5000; +const FADE_MS = 400; + +/** Indirect eval so the IIFE runs in global scope with window/document. */ +function evalExpression(opts?: Parameters[0]): void { + // biome-ignore lint/security/noGlobalEval: deliberately evaluating the injected badge IIFE to exercise its DOM behavior. + const indirectEval = eval; + indirectEval(buildIndicatorExpression(opts)); +} + +function badge(): HTMLElement | null { + return document.getElementById(BADGE_ID); +} + +describe('buildIndicatorExpression — badge DOM behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + // Preferred observer branch → no window.WebSocket Proxy in jsdom. + (window as unknown as Record).__ait_relay_ws_observed = true; + delete (window as unknown as Record).__ait_indicator; + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + delete (window as unknown as Record).__ait_relay_ws_observed; + delete (window as unknown as Record).__ait_indicator; + }); + + it('renders the attached badge — visible, red, pointer-events:auto', () => { + evalExpression(); + const el = badge(); + expect(el).not.toBeNull(); + expect(el?.textContent).toBe('Debugger Connected'); + expect(el?.style.pointerEvents).toBe('auto'); + expect(el?.style.background).toBe('rgb(229, 72, 77)'); // #e5484d + expect(el?.style.display).toBe('block'); + }); + + it('disconnected badge is NON-BLOCKING (pointer-events:none) with the ko notice', () => { + evalExpression(); + evalExpression({ state: 'disconnected' }); + const el = badge(); + expect(el).not.toBeNull(); + expect(el?.style.pointerEvents).toBe('none'); + expect(el?.textContent).toBe('디버거 연결 끊김'); + }); + + it('disconnected badge SELF-DISMISSES — removed from the DOM after the window', () => { + evalExpression(); + evalExpression({ state: 'disconnected' }); + expect(badge()).not.toBeNull(); + + vi.advanceTimersByTime(SELF_DISMISS_MS + FADE_MS); + + expect(badge()).toBeNull(); + }); + + it('a reconnect before the self-dismiss cancels it and restores the badge', () => { + evalExpression(); + evalExpression({ state: 'disconnected' }); + + // Reconnect (enum-only CustomEvent from the in-app observer) before dismiss. + vi.advanceTimersByTime(1000); + window.dispatchEvent(new CustomEvent('ait:relay-ws-state', { detail: { state: 'open' } })); + + // Past the original window, the badge is still present and interactive. + vi.advanceTimersByTime(SELF_DISMISS_MS + FADE_MS); + const el = badge(); + expect(el).not.toBeNull(); + expect(el?.style.pointerEvents).toBe('auto'); + expect(el?.textContent).toBe('Debugger Connected'); + }); + + it('a reconnect AFTER a self-dismiss re-mounts the badge (durable controller)', () => { + evalExpression(); + evalExpression({ state: 'disconnected' }); + vi.advanceTimersByTime(SELF_DISMISS_MS + FADE_MS); + expect(badge()).toBeNull(); + + // A genuine re-attach after the badge already detached. + window.dispatchEvent(new CustomEvent('ait:relay-ws-state', { detail: { state: 'open' } })); + const el = badge(); + expect(el).not.toBeNull(); + expect(el?.style.pointerEvents).toBe('auto'); + }); + + it('re-injection does not duplicate the badge
', () => { + evalExpression(); + evalExpression(); + evalExpression({ state: 'disconnected' }); + expect(document.querySelectorAll(`#${BADGE_ID}`)).toHaveLength(1); + }); + + it('a custom disconnectedLabel overrides the ko default', () => { + evalExpression(); + evalExpression({ disconnectedLabel: 'X', state: 'disconnected' }); + expect(badge()?.textContent).toBe('X'); + }); + + it('SECRET-HANDLING: the expression string carries no relay URL / host / TOTP token', () => { + // Structural identifiers like `ait:relay-ws-state` are fine; what must + // NEVER appear is a wss URL, a tunnel host, or a TOTP `/at//` segment. + const expr = buildIndicatorExpression({ state: 'disconnected' }); + expect(expr).not.toMatch(/wss:\/\//); + expect(expr).not.toMatch(/trycloudflare|\.ts\.net/i); + expect(expr).not.toMatch(/\/at\/\d/); + }); +}); diff --git a/src/mcp/attach-orchestrator.ts b/src/mcp/attach-orchestrator.ts index 2c9c793..d4fa64a 100644 --- a/src/mcp/attach-orchestrator.ts +++ b/src/mcp/attach-orchestrator.ts @@ -784,10 +784,21 @@ export async function renderAndMaybeWait( return runWait(baseText); } +/** + * How long the disconnected badge stays on screen before it fades and removes + * itself (#748). Long enough to read, short enough not to linger; a reconnect + * within this window cancels the self-dismiss (transient tunnel blips do not + * flash-remove the badge). + */ +const INDICATOR_SELF_DISMISS_MS = 5000; + +/** Fade duration before the self-dismissed badge is detached from the DOM. */ +const INDICATOR_FADE_MS = 400; + /** * Builds a self-contained IIFE DOM expression that renders a LIVE - * "Debugger Connected" / "Debugger Disconnected" badge on the bottom-left of - * the phone screen (#730). + * "Debugger Connected" / disconnected badge on the bottom-left of the phone + * screen (#730), with a graceful self-dismiss on disconnect (#748). * * **Pure function** — returns a JS expression string; does NOT inject it. * Injection is performed by {@link injectDebugIndicator} in `cell.ts`. @@ -804,7 +815,18 @@ export async function renderAndMaybeWait( * 3. A `pointerdown` listener dismisses (hides) the badge on tap — dismiss * is NON-terminal: a later state transition un-dismisses it, so a * genuine disconnect after a dismissed tap is still visible. - * 4. Observes relay-socket lifecycle WITHOUT opening any new connection: + * 4. **Graceful self-dismiss (#748)** — the disconnected badge is NON-BLOCKING + * (`pointer-events:none` the moment it flips to disconnected, so it can + * never absorb a tap) and SELF-DISMISSING (it fades and detaches itself + * after {@link INDICATOR_SELF_DISMISS_MS}). This is why a run that ends + * with `close()` injecting `{ state: 'disconnected' }` no longer leaves a + * permanent grey "Debugger Disconnected" element on the phone. A reconnect + * (`setState(c, 'attached')`) cancels the pending self-dismiss and + * re-mounts the badge if it had already detached — transient tunnel blips + * do not flash-remove it, and a genuine re-attach restores it. The + * controller object is retained across dismiss (never `delete`d), so a + * later re-injection reuses it and never double-wraps `window.WebSocket`. + * 5. Observes relay-socket lifecycle WITHOUT opening any new connection: * - Preferred path: if the in-app module (`src/in-app/attach.ts`) has * already installed its relay-WS observer (`window.__ait_relay_ws_observed`), * subscribe to the `ait:relay-ws-state` CustomEvent it broadcasts. @@ -821,7 +843,9 @@ export async function renderAndMaybeWait( * pathname match (`/target/`) only. * * @param opts.label - Attached-state badge text (default: `'Debugger Connected'`). - * @param opts.disconnectedLabel - Disconnected-state badge text (default: `'Debugger Disconnected'`). + * @param opts.disconnectedLabel - Disconnected-state badge text. Default is the + * Korean notice `'디버거 연결 끊김'` (ko-primary in-app string convention) — + * shown briefly then self-dismissed. * @param opts.state - Initial/forced state for THIS injection call (default: `'attached'`). * @returns A JS expression string suitable for `Runtime.evaluate`. */ @@ -831,7 +855,7 @@ export function buildIndicatorExpression(opts?: { state?: 'attached' | 'disconnected'; }): string { const label = opts?.label ?? 'Debugger Connected'; - const disconnectedLabel = opts?.disconnectedLabel ?? 'Debugger Disconnected'; + const disconnectedLabel = opts?.disconnectedLabel ?? '디버거 연결 끊김'; // JSON.stringify ensures the labels/state are safely embedded even if they // contain quotes or backslashes. const safeLabel = JSON.stringify(label); @@ -840,17 +864,40 @@ export function buildIndicatorExpression(opts?: { return ( `(() => {` + `var W = window;` + + // mount(c) — (re-)attach the badge node to if it is not connected. + // A self-dismissed badge detaches itself; a later 'attached' re-mounts it. + `function mount(c) { if (!c.el.isConnected && document.body) { document.body.appendChild(c.el); } }` + // render(c) — paints the DOM node from the controller's current state. `function render(c) {` + + `if (c.removed) { c.el.style.display = 'none'; return; }` + + `mount(c);` + `if (c.dismissed) { c.el.style.display = 'none'; return; }` + `c.el.style.display = 'block';` + + `c.el.style.opacity = '1';` + `var up = c.state !== 'disconnected';` + `c.el.style.background = up ? '#e5484d' : '#8a8f98';` + + // Disconnected badge is non-blocking — it can never absorb a tap (#748). + `c.el.style.pointerEvents = up ? 'auto' : 'none';` + `c.el.textContent = up ? ${safeLabel} : ${safeDisconnectedLabel};` + `}` + - // setState(c, next) — a later transition always un-dismisses the badge, - // so a genuine disconnect after a tap-dismiss is still surfaced. - `function setState(c, next) { c.state = next; c.dismissed = false; render(c); }` + + // clearTimers(c) — cancel any pending self-dismiss fade/remove timers. + `function clearTimers(c) { if (c.t1) { clearTimeout(c.t1); c.t1 = 0; } if (c.t2) { clearTimeout(c.t2); c.t2 = 0; } }` + + // setState(c, next) — a later transition always un-dismisses AND un-removes + // the badge (a reconnect after a self-dismiss re-mounts it), and cancels any + // pending self-dismiss so a transient blip that reconnects does not remove it. + `function setState(c, next) {` + + `clearTimers(c);` + + `c.state = next; c.dismissed = false; c.removed = false;` + + `render(c);` + + `if (next === 'disconnected') {` + + // Graceful self-dismiss: fade after a readable delay, then detach (#748). + // The controller object is retained so a re-injection reuses it. + `c.t1 = setTimeout(function () {` + + `try { c.el.style.transition = 'opacity ${INDICATOR_FADE_MS}ms ease'; c.el.style.opacity = '0'; } catch (_) {}` + + `c.t2 = setTimeout(function () { c.removed = true; if (c.el.parentNode) { c.el.parentNode.removeChild(c.el); } }, ${INDICATOR_FADE_MS});` + + `}, ${INDICATOR_SELF_DISMISS_MS});` + + `}` + + `}` + // Idempotent controller — re-injection updates the SAME controller/DOM // node instead of creating a duplicate. `var c = W.__ait_indicator;` + @@ -871,7 +918,7 @@ export function buildIndicatorExpression(opts?: { `'user-select:none',` + `].join(';');` + `document.body.appendChild(el);` + - `c = { el: el, state: 'attached', dismissed: false };` + + `c = { el: el, state: 'attached', dismissed: false, removed: false, t1: 0, t2: 0 };` + // One-tap dismiss — hides the badge; NOT terminal, `setState` re-shows it. `el.addEventListener('pointerdown', function () { c.dismissed = true; render(c); }, { passive: true });` + `W.__ait_indicator = c;` + diff --git a/src/test-runner/cell.test.ts b/src/test-runner/cell.test.ts index 13ccc01..53f8d49 100644 --- a/src/test-runner/cell.test.ts +++ b/src/test-runner/cell.test.ts @@ -272,9 +272,13 @@ describe('buildIndicatorExpression', () => { expect(expr).toContain('#8a8f98'); }); - it('embeds the default disconnected label "Debugger Disconnected"', () => { + // #748: the default disconnected label is now the ko-primary notice (the + // persistent English "Debugger Disconnected" is replaced by a self-dismissing + // one). DOM behavior of the notice is covered in + // src/mcp/__tests__/indicator-expression.test.ts. + it('embeds the ko default disconnected label "디버거 연결 끊김"', () => { const expr = buildIndicatorExpression(); - expect(expr).toContain('Debugger Disconnected'); + expect(expr).toContain('디버거 연결 끊김'); }); it('embeds a custom disconnected label', () => { @@ -282,6 +286,20 @@ describe('buildIndicatorExpression', () => { expect(expr).toContain('"Gone"'); }); + // #748: the disconnected badge is non-blocking (pointer-events flips off) and + // self-dismisses (a timer detaches it), so a run that ends does not leave a + // permanent element intercepting taps. + it('is non-blocking + self-dismissing in the disconnected state (#748)', () => { + const expr = buildIndicatorExpression(); + // pointer-events is driven off the up/disconnected flag. + expect(expr).toContain("c.el.style.pointerEvents = up ? 'auto' : 'none'"); + // A self-dismiss timer detaches the node after the window. + expect(expr).toContain("if (next === 'disconnected')"); + expect(expr).toContain('removeChild(c.el)'); + // A reconnect un-removes it (durable controller) rather than duplicating. + expect(expr).toContain('c.removed = false'); + }); + it('accepts an explicit initial state — disconnected', () => { const expr = buildIndicatorExpression({ state: 'disconnected' }); expect(expr).toContain('setState(c, "disconnected")');