diff --git a/.changeset/env3-runtime-fidelity-795.md b/.changeset/env3-runtime-fidelity-795.md new file mode 100644 index 00000000..9833961d --- /dev/null +++ b/.changeset/env3-runtime-fidelity-795.md @@ -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. diff --git a/src/__tests__/contacts.test.ts b/src/__tests__/contacts.test.ts index a5ff5542..1a1ca683 100644 --- a/src/__tests__/contacts.test.ts +++ b/src/__tests__/contacts.test.ts @@ -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 () => { diff --git a/src/__tests__/fidelity-qa.test.ts b/src/__tests__/fidelity-qa.test.ts index a3937417..fc916d63 100644 --- a/src/__tests__/fidelity-qa.test.ts +++ b/src/__tests__/fidelity-qa.test.ts @@ -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 () => { diff --git a/src/__tests__/navigation.test.ts b/src/__tests__/navigation.test.ts index f3e54ce6..88e8779b 100644 --- a/src/__tests__/navigation.test.ts +++ b/src/__tests__/navigation.test.ts @@ -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); }); }); @@ -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를 반환한다', () => { @@ -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', () => { diff --git a/src/mock/device/contacts.ts b/src/mock/device/contacts.ts index f17cd484..1545ef13 100644 --- a/src/mock/device/contacts.ts +++ b/src/mock/device/contacts.ts @@ -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: { @@ -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; + openPermissionDialog: () => Promise<'allowed' | 'denied'>; +}; diff --git a/src/mock/navigation/index.ts b/src/mock/navigation/index.ts index afa6f976..bc7dcd2d 100644 --- a/src/mock/navigation/index.ts +++ b/src/mock/navigation/index.ts @@ -100,12 +100,29 @@ 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 { @@ -113,6 +130,12 @@ export function getTossAppVersion(): string { } 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; @@ -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 { @@ -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; }