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
10 changes: 10 additions & 0 deletions .changeset/env3-runtime-fidelity-795.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@ait-co/devtools': patch
---

env3(실기기) 런타임 실측과 mock 사이의 잔여 8건 불일치를 닫음(#775 원칙 확장) — 전부 상류 SDK의 type↔runtime 불일치, mock 품질 결함 아님.

- (A) `getPlatformOS`/`getOperationalEnvironment`/`getLocale`/`getDeviceId`/`isMinVersionSupported`/`getSafeAreaInsets`(`src/mock/navigation/index.ts`) — 상류 타입 선언은 동기지만 실기기(2.x×iOS)는 Promise를 반환한다. 선언 타입은 그대로 두고 반환값만 `Promise.resolve(...)`로 감싸 캐스트.
- (B) `fetchContacts.getPermission`(`src/mock/device/contacts.ts`) — 상류가 타입에는 선언하지만 실기기 런타임에는 부착하지 않는다. `withPermission()` 대신 bare async fn을 상류 타입으로 캐스트해 접근 시 `undefined`, 호출 시 native `TypeError`로 떨어지도록 재현(`openPermissionDialog` 부재는 측정 근거 없는 합리적 추론으로 별도 표기, 다른 `withPermission` API로는 확장하지 않음 — #783).

Refs #795, #775, #770, #783.
31 changes: 14 additions & 17 deletions src/__tests__/contacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,26 @@ describe('Contacts mock', () => {
await expect(fetchContacts({ size: 10, offset: 0 })).rejects.toThrow(PermissionError);
});

it('getPermission()이 부착되어 있다', async () => {
expect(typeof fetchContacts.getPermission).toBe('function');
const status = await fetchContacts.getPermission();
expect(status).toBe('allowed');
// devtools#795: 실기기(2.x×iOS)엔 fetchContacts에 .getPermission/.openPermissionDialog가
// 런타임에 붙어있지 않다 — 상류가 타입에만 선언하고 런타임엔 부착하지 않는
// type↔runtime 불일치. mock도 bare fn으로 두어(#775 원칙 확장) 접근 시
// undefined, 호출 시 native TypeError로 떨어지는 실기기 동작을 그대로 재현한다.
// (이 테스트가 과거 재현하던 "resolved" 동작은 비충실했다 — standalone
// getPermission({name:'contacts'})는 여전히 resolved다, permissions.test.ts 참조.)
it('getPermission이 부착되어 있지 않다 (실기기 실측)', () => {
expect(fetchContacts.getPermission).toBeUndefined();
});

it('openPermissionDialog()가 부착되어 있다', async () => {
expect(typeof fetchContacts.openPermissionDialog).toBe('function');
const result = await fetchContacts.openPermissionDialog();
expect(result).toBe('allowed');
it('getPermission() 호출은 native TypeError를 던진다 (실기기 실측)', () => {
expect(() => fetchContacts.getPermission()).toThrow(TypeError);
});

it('getPermission()이 denied 상태를 반환한다', async () => {
aitState.patch('permissions', { contacts: 'denied' });
const status = await fetchContacts.getPermission();
expect(status).toBe('denied');
it('openPermissionDialog이 부착되어 있지 않다 (측정 밖 추론 — #783)', () => {
expect(fetchContacts.openPermissionDialog).toBeUndefined();
});

it('openPermissionDialog()가 denied를 allowed로 전환한다', async () => {
aitState.patch('permissions', { contacts: 'denied' });
const result = await fetchContacts.openPermissionDialog();
expect(result).toBe('allowed');
expect(aitState.state.permissions.contacts).toBe('allowed');
it('openPermissionDialog() 호출은 native TypeError를 던진다 (측정 밖 추론 — #783)', () => {
expect(() => fetchContacts.openPermissionDialog()).toThrow(TypeError);
});

it('빈 contacts 배열일 때 빈 결과를 반환한다', async () => {
Expand Down
9 changes: 5 additions & 4 deletions src/__tests__/fidelity-qa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ describe('fidelity-qa — mock runner smoke test', () => {
expect(results.length).toBeGreaterThanOrEqual(30);
}, 15000);

it('environment probes return expected default values', () => {
expect(getOperationalEnvironment()).toBe('sandbox');
expect(getPlatformOS()).toBe('ios');
expect(getLocale()).toBe('ko-KR');
it('environment probes return expected default values', async () => {
// devtools#795: 실기기 실측이 Promise라 mock도 Promise를 반환한다(#775 원칙 확장).
expect(await getOperationalEnvironment()).toBe('sandbox');
expect(await getPlatformOS()).toBe('ios');
expect(await getLocale()).toBe('ko-KR');
});

it('permissions probes return valid PermissionStatus', async () => {
Expand Down
53 changes: 28 additions & 25 deletions src/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,36 +33,38 @@ describe('Navigation mock', () => {
aitState.reset();
});

it('getPlatformOS: 상태의 platform을 반환한다', () => {
expect(getPlatformOS()).toBe('ios');
// devtools#795: 실기기(2.x×iOS)는 이 6개를 Promise로 반환하지만 상류 타입은
// 동기로 선언한다(#775 원칙 확장) — mock도 Promise를 반환하므로 await한다.
it('getPlatformOS: 상태의 platform을 반환한다 (Promise, devtools#795)', async () => {
expect(await getPlatformOS()).toBe('ios');
aitState.update({ platform: 'android' });
expect(getPlatformOS()).toBe('android');
expect(await getPlatformOS()).toBe('android');
});

it('getOperationalEnvironment: 상태의 environment를 반환한다', () => {
expect(getOperationalEnvironment()).toBe('sandbox');
it('getOperationalEnvironment: 상태의 environment를 반환한다 (Promise, devtools#795)', async () => {
expect(await getOperationalEnvironment()).toBe('sandbox');
aitState.update({ environment: 'toss' });
expect(getOperationalEnvironment()).toBe('toss');
expect(await getOperationalEnvironment()).toBe('toss');
});

describe('isMinVersionSupported', () => {
it('현재 버전이 최소 버전 이상이면 true', () => {
expect(isMinVersionSupported({ ios: '5.240.0', android: '5.240.0' })).toBe(true);
expect(isMinVersionSupported({ ios: '5.200.0', android: '5.200.0' })).toBe(true);
describe('isMinVersionSupported (Promise, devtools#795)', () => {
it('현재 버전이 최소 버전 이상이면 true', async () => {
expect(await isMinVersionSupported({ ios: '5.240.0', android: '5.240.0' })).toBe(true);
expect(await isMinVersionSupported({ ios: '5.200.0', android: '5.200.0' })).toBe(true);
});

it('현재 버전이 최소 버전 미만이면 false', () => {
expect(isMinVersionSupported({ ios: '6.0.0', android: '6.0.0' })).toBe(false);
it('현재 버전이 최소 버전 미만이면 false', async () => {
expect(await isMinVersionSupported({ ios: '6.0.0', android: '6.0.0' })).toBe(false);
});

it('always는 항상 true, never는 항상 false', () => {
expect(isMinVersionSupported({ ios: 'always', android: 'always' })).toBe(true);
expect(isMinVersionSupported({ ios: 'never', android: 'never' })).toBe(false);
it('always는 항상 true, never는 항상 false', async () => {
expect(await isMinVersionSupported({ ios: 'always', android: 'always' })).toBe(true);
expect(await isMinVersionSupported({ ios: 'never', android: 'never' })).toBe(false);
});

it('android 플랫폼일 때 android 버전을 비교한다', () => {
it('android 플랫폼일 때 android 버전을 비교한다', async () => {
aitState.update({ platform: 'android' });
expect(isMinVersionSupported({ ios: '999.0.0', android: '1.0.0' })).toBe(true);
expect(await isMinVersionSupported({ ios: '999.0.0', android: '1.0.0' })).toBe(true);
});
});

Expand Down Expand Up @@ -91,12 +93,12 @@ describe('Navigation mock', () => {
expect(getSchemeUri()).toBe('/test');
});

it('getLocale: 상태의 locale을 반환한다', () => {
expect(getLocale()).toBe('ko-KR');
it('getLocale: 상태의 locale을 반환한다 (Promise, devtools#795)', async () => {
expect(await getLocale()).toBe('ko-KR');
});

it('getDeviceId: 상태의 deviceId를 반환한다', () => {
expect(getDeviceId()).toBe(aitState.state.deviceId);
it('getDeviceId: 상태의 deviceId를 반환한다 (Promise, devtools#795)', async () => {
expect(await getDeviceId()).toBe(aitState.state.deviceId);
});

it('getGroupId: 상태의 groupId를 반환한다', () => {
Expand Down Expand Up @@ -255,11 +257,12 @@ describe('Navigation mock', () => {
expect(result).toEqual({ enabled: true });
});

it('getSafeAreaInsets (deprecated): insets 객체를 반환한다 — 상류 타입 선언(number)이 아니라 실측 shape', () => {
it('getSafeAreaInsets (deprecated): insets 객체를 담은 Promise를 반환한다 — 상류 타입 선언(동기 number)이 아니라 실측(devtools#770/#795)', async () => {
// 상류 SDK 선언은 `(): number`지만 실기기(2.x×iOS) capture는 SafeAreaInsets.get()과
// 같은 객체를 반환함을 보였다(devtools#770). mock은 런타임 실측을 재현한다.
// default는 iPhone 15 Pro partner WebView 실측과 정합 (nav bar top 54).
expect(getSafeAreaInsets()).toEqual({ top: 54, bottom: 34, left: 0, right: 0 });
// 같은 객체를 Promise로 반환함을 보였다(devtools#770 shape, devtools#795 sync/async축).
// mock은 런타임 실측을 재현한다. default는 iPhone 15 Pro partner WebView 실측과 정합
// (nav bar top 54).
expect(await getSafeAreaInsets()).toEqual({ top: 54, bottom: 34, left: 0, right: 0 });
});

describe('SafeAreaInsets', () => {
Expand Down
32 changes: 30 additions & 2 deletions src/mock/device/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Contacts mock
*/

import { checkPermission, withPermission } from '../permissions.js';
import type { PermissionStatus } from '@apps-in-toss/web-framework';
import { checkPermission } from '../permissions.js';
import { aitState } from '../state.js';

const _fetchContacts = async (options: {
Expand All @@ -26,4 +27,31 @@ const _fetchContacts = async (options: {
done: nextOffset >= contacts.length,
};
};
export const fetchContacts = withPermission(_fetchContacts, 'contacts');

/**
* 상류 SDK는 `fetchContacts`의 타입에 `.getPermission`/`.openPermissionDialog`를
* `PermissionFunctionWithDialog`로 선언하지만, 실기기(2.x×iOS)에는 그 메서드가
* **런타임에 붙어 있지 않다**(devtools#795 — 호출 시 `fetchContacts.getPermission
* is not a function` native `TypeError`). standalone `getPermission({name:
* 'contacts', access: 'access'})`는 실기기에서 정상 resolve하므로(env3 run11,
* `../permissions.ts`) 부재는 fetchContacts에 **부착된** 메서드에만 해당하는
* 상류 타입↔런타임 불일치다.
*
* mock은 다른 device API처럼 `withPermission()`으로 감싸지 않고, bare async fn을
* 상류 시그니처로만 캐스트한다 — `.getPermission`/`.openPermissionDialog` 접근은
* `undefined`가 되고, 호출하면 `undefined()` → native `TypeError`로 떨어져
* 실기기와 일치한다(`__typecheck.ts`/`__typecheck-2x.ts`는 캐스트 타입에 두
* 메서드가 여전히 남아 있어 그대로 통과). `_fetchContacts` 내부에서 이미
* `checkPermission`을 호출하므로 메인 동작(권한 거부 시
* `FetchContactsPermissionError`)은 변화 없다.
*
* 직접 관측된 것은 `getPermission` 부재뿐이다. `openPermissionDialog` 부재는
* "상류가 fetchContacts에 권한 헬퍼 전체를 붙이지 않는다"는 합리적 추론이지
* 별도 실측은 아니다. 다른 `withPermission` API(clipboard/camera/location)로는
* 이 부재를 확장하지 않는다 — 그쪽 부착 메서드가 실기기에서 없다는 관측은
* 없다(#783 "측정 밖 확장 금지" 원칙).
*/
export const fetchContacts = _fetchContacts as typeof _fetchContacts & {
getPermission: () => Promise<PermissionStatus>;
openPermissionDialog: () => Promise<'allowed' | 'denied'>;
};
43 changes: 35 additions & 8 deletions src/mock/navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,19 +100,42 @@ requestReview.isSupported = () => true;

// --- 환경 정보 ---

/**
* 아래 5개 함수(`getPlatformOS`/`getOperationalEnvironment`/`isMinVersionSupported`/
* `getLocale`/`getDeviceId`) + 이 파일 하단의 `getSafeAreaInsets`는 실기기(2.x×iOS)
* capture에서 전부 **Promise를 반환**함이 확인됐다(devtools#795 — sdk-example
* type-probe 실측). 그런데 상류 `.d.ts`는 이 6개를 전부 **동기**로 선언한다 —
* 선언과 런타임이 어긋난 상류 타입 버그다.
*
* mock은 타입 선언이 아니라 런타임 실측을 재현해야 개발자가 env1(브라우저)에서
* 겪는 동작이 env3(실기기)와 같아진다(#775 원칙 — Analytics·setClipboardText·
* Storage·getSafeAreaInsets(#770)에 이미 적용). 그래서 시그니처는 상류와 동일하게
* 두고(`__typecheck.ts`/`__typecheck-2x.ts`의 `Assert*`가 계속 컴파일되도록)
* 반환값만 `Promise.resolve(...)`로 감싸 기존 시그니처로 캐스트한다 — 선언 타입이
* 안 바뀌므로 런타임 Promise는 tsc에 보이지 않는다.
*
* `getTossAppVersion`/`getSchemeUri`/`getGroupId`는 이 이슈에서 실측되지 않아
* (#783 "측정 밖 확장 금지") 동기 그대로 둔다.
*/
export function getPlatformOS(): 'ios' | 'android' {
return aitState.state.platform;
return Promise.resolve(aitState.state.platform) as unknown as 'ios' | 'android';
}

export function getOperationalEnvironment(): 'toss' | 'sandbox' {
return aitState.state.environment;
return Promise.resolve(aitState.state.environment) as unknown as 'toss' | 'sandbox';
}

export function getTossAppVersion(): string {
return aitState.state.appVersion;
}

export function isMinVersionSupported(minVersions: { android: string; ios: string }): boolean {
const result = computeIsMinVersionSupported(minVersions);
// 실기기는 Promise 반환, 타입은 동기 — 위 "환경 정보" 섹션 상단 주석 참조(devtools#795).
return Promise.resolve(result) as unknown as boolean;
}

function computeIsMinVersionSupported(minVersions: { android: string; ios: string }): boolean {
const platform = aitState.state.platform;
const required = platform === 'ios' ? minVersions.ios : minVersions.android;
if (required === 'always') return true;
Expand All @@ -132,11 +155,11 @@ export function getSchemeUri(): string {
}

export function getLocale(): string {
return aitState.state.locale;
return Promise.resolve(aitState.state.locale) as unknown as string;
}

export function getDeviceId(): string {
return aitState.state.deviceId;
return Promise.resolve(aitState.state.deviceId) as unknown as string;
}

export function getGroupId(): string {
Expand Down Expand Up @@ -304,13 +327,17 @@ export const SafeAreaInsets = {
* capture는 이 함수가 숫자가 아니라 `SafeAreaInsets.get()`과 같은 객체
* (`{ top, right, bottom, left }`)를 반환함을 보였다(devtools#770 —
* `returnType: "object"`, `valueKeys: ["top","right","bottom","left"]`).
* 즉 선언과 런타임이 어긋나 있는 상류 타입 버그다.
* 즉 선언과 런타임이 어긋나 있는 상류 타입 버그다. 게다가 그 반환 자체도
* 동기가 아니라 **Promise**다(devtools#795 — 위 "환경 정보" 섹션 상단 주석과
* 같은 축, type-probe 실측). shape(object)와 sync/async 두 축 모두 선언과
* 어긋나 있다.
*
* mock은 타입 선언이 아니라 **런타임 실측**을 재현해야 개발자가 env1에서 겪는
* 동작이 실기기와 같아진다. 그래서 시그니처는 상류와 동일하게 `number`로 두되
* (`__typecheck.ts`가 SDK 타입에 대해 계속 컴파일되도록) 반환값만 실측 객체로
* 캐스트한다 — Analytics·setClipboardText·Storage와 같은 처리(#775).
* (`__typecheck.ts`가 SDK 타입에 대해 계속 컴파일되도록) 반환값만 실측 객체를
* `Promise.resolve`로 감싸 캐스트한다 — Analytics·setClipboardText·Storage와
* 같은 처리(#775).
*/
export function getSafeAreaInsets(): number {
return { ...aitState.state.safeAreaInsets } as unknown as number;
return Promise.resolve({ ...aitState.state.safeAreaInsets }) as unknown as number;
}
Loading