Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/in-app-graceful-detach-748.md
Original file line number Diff line number Diff line change
@@ -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 세션에서 검증 예정.
216 changes: 211 additions & 5 deletions src/__tests__/in-app-attach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -508,6 +512,8 @@ describe('installRelayWsObserver', () => {
});

afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
window.WebSocket = originalWebSocket as typeof WebSocket;
Object.defineProperty(window, 'parent', {
value: window,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<typeof vi.fn>;

beforeEach(async () => {
vi.resetModules();
document.body.innerHTML = '';
const framework = await import('@apps-in-toss/web-framework');
setScreenAwakeMode = framework.setScreenAwakeMode as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
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<string, unknown>).__ait_relay_ws_observed;
const framework = await import('@apps-in-toss/web-framework');
setScreenAwakeMode = framework.setScreenAwakeMode as ReturnType<typeof vi.fn>;
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<string, unknown>).__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();
});
});
52 changes: 52 additions & 0 deletions src/__tests__/in-app-eruda-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading