@ait-co/devtools 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,6 @@
1
- //#region src/mock/proxy.ts
2
- /**
3
- * 미구현 API용 Proxy 트립와이어.
4
- *
5
- * 미구현 프로퍼티에 접근하면 throw한다. 이는 "devtools에서는 멀쩡히 돌지만
6
- * 실 SDK에선 실제로 동작하는" 시나리오를 차단하기 위한 의도적 선택이다.
7
- * mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
8
- * 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
9
- * 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
10
- */
11
- const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
12
- function createMockProxy(moduleName, implementations) {
13
- return new Proxy(implementations, { get(target, prop) {
14
- if (typeof prop === "symbol") return void 0;
15
- if (prop in target) return target[prop];
16
- throw new Error(`[@ait-co/devtools] ${moduleName}.${prop} is not mocked. This API may exist in @apps-in-toss/web-framework, but devtools' mock does not cover it yet. Please file an issue: ${ISSUES_URL}`);
17
- } });
18
- }
19
- //#endregion
20
1
  //#region src/mock/state.ts
2
+ /** SDK 호출 로그 ring buffer 상한 */
3
+ const SDK_CALL_LOG_MAX = 200;
21
4
  const DEFAULT_STATE = {
22
5
  platform: "ios",
23
6
  environment: "sandbox",
@@ -33,6 +16,7 @@ const DEFAULT_STATE = {
33
16
  primaryColor: "#3182F6"
34
17
  },
35
18
  networkStatus: "WIFI",
19
+ navigation: { iosSwipeGestureEnabled: null },
36
20
  permissions: {
37
21
  clipboard: "allowed",
38
22
  contacts: "allowed",
@@ -54,7 +38,7 @@ const DEFAULT_STATE = {
54
38
  accessLocation: "FINE"
55
39
  },
56
40
  safeAreaInsets: {
57
- top: 47,
41
+ top: 54,
58
42
  bottom: 34,
59
43
  left: 0,
60
44
  right: 0
@@ -94,7 +78,9 @@ const DEFAULT_STATE = {
94
78
  isLoaded: false,
95
79
  nextEvent: "loaded",
96
80
  forceNoFill: false,
97
- lastEvent: null
81
+ lastEvent: null,
82
+ rewardUnitType: "coins",
83
+ rewardAmount: 10
98
84
  },
99
85
  game: {
100
86
  profile: {
@@ -104,6 +90,7 @@ const DEFAULT_STATE = {
104
90
  leaderboardScores: []
105
91
  },
106
92
  analyticsLog: [],
93
+ sdkCallLog: [],
107
94
  deviceModes: {
108
95
  camera: "mock",
109
96
  photos: "mock",
@@ -216,6 +203,19 @@ var AitStateManager = class {
216
203
  };
217
204
  this._notify();
218
205
  }
206
+ /**
207
+ * SDK 호출 로그 추가 (ring buffer, 상한 SDK_CALL_LOG_MAX).
208
+ * `observe()`가 호출하고, proxy의 KNOWN_UNIMPLEMENTED 경로도 직접 호출한다.
209
+ */
210
+ logSdkCall(entry) {
211
+ const log = this._state.sdkCallLog;
212
+ const next = log.length >= SDK_CALL_LOG_MAX ? log.slice(1 - SDK_CALL_LOG_MAX) : log;
213
+ this._state = {
214
+ ...this._state,
215
+ sdkCallLog: [...next, entry]
216
+ };
217
+ this._notify();
218
+ }
219
219
  /** 이벤트 트리거 (backEvent, homeEvent 등) */
220
220
  trigger(event) {
221
221
  window.dispatchEvent(new CustomEvent(`__ait:${event}`));
@@ -239,16 +239,142 @@ if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
239
239
  const aitState = globalRef[SINGLETON_KEY];
240
240
  if (typeof window !== "undefined") window.__ait = aitState;
241
241
  //#endregion
242
+ //#region src/mock/observe.ts
243
+ /**
244
+ * fn을 observe로 감싼다.
245
+ *
246
+ * @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
247
+ * @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
248
+ * @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
249
+ * @returns fn과 동일한 타입의 래퍼 함수
250
+ */
251
+ function observe(apiName, fidelity, fn) {
252
+ return (...args) => {
253
+ const timestamp = Date.now();
254
+ const safeArgs = args.map((a) => safeSerialize(a));
255
+ const result = fn(...args);
256
+ if (result instanceof Promise) {
257
+ aitState.logSdkCall({
258
+ method: apiName,
259
+ args: safeArgs,
260
+ timestamp,
261
+ status: "pending",
262
+ fidelity
263
+ });
264
+ result.then((value) => {
265
+ aitState.logSdkCall({
266
+ method: apiName,
267
+ args: safeArgs,
268
+ timestamp,
269
+ status: "resolved",
270
+ result: safeSerialize(value),
271
+ fidelity
272
+ });
273
+ }, (err) => {
274
+ aitState.logSdkCall({
275
+ method: apiName,
276
+ args: safeArgs,
277
+ timestamp,
278
+ status: "rejected",
279
+ error: err instanceof Error ? err.message : String(err),
280
+ fidelity
281
+ });
282
+ });
283
+ return result;
284
+ }
285
+ aitState.logSdkCall({
286
+ method: apiName,
287
+ args: safeArgs,
288
+ timestamp,
289
+ status: "resolved",
290
+ result: safeSerialize(result),
291
+ fidelity
292
+ });
293
+ return result;
294
+ };
295
+ }
296
+ /**
297
+ * 값을 JSON-safe한 형태로 변환한다.
298
+ * - null / primitive — 그대로.
299
+ * - 함수 — `'[Function: name]'` 문자열.
300
+ * - 기타 객체 — JSON.stringify 실패 시 `'[unserializable]'`.
301
+ */
302
+ function safeSerialize(value) {
303
+ if (value === null || value === void 0) return value;
304
+ if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
305
+ if (typeof value !== "object") return value;
306
+ try {
307
+ return JSON.parse(JSON.stringify(value));
308
+ } catch {
309
+ return "[unserializable]";
310
+ }
311
+ }
312
+ //#endregion
313
+ //#region src/mock/proxy.ts
314
+ /**
315
+ * 미구현 API용 Proxy 트립와이어.
316
+ *
317
+ * 미구현 프로퍼티에 접근하면 throw한다. 이는 "devtools에서는 멀쩡히 돌지만
318
+ * 실 SDK에선 실제로 동작하는" 시나리오를 차단하기 위한 의도적 선택이다.
319
+ * mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
320
+ * 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
321
+ * 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
322
+ *
323
+ * ## KNOWN_UNIMPLEMENTED 정책
324
+ * SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
325
+ * 이 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
326
+ * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
327
+ */
328
+ const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
329
+ /**
330
+ * SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
331
+ * 새 API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
332
+ * 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
333
+ */
334
+ const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
335
+ function createMockProxy(moduleName, implementations) {
336
+ return new Proxy(implementations, { get(target, prop) {
337
+ if (typeof prop === "symbol") return void 0;
338
+ if (prop in target) return target[prop];
339
+ const name = String(prop);
340
+ if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
341
+ console.warn(`[@ait-co/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
342
+ aitState.logSdkCall({
343
+ method: `${moduleName}.${name}`,
344
+ args,
345
+ timestamp: Date.now(),
346
+ status: "resolved",
347
+ result: void 0,
348
+ fidelity: "inert"
349
+ });
350
+ };
351
+ throw new Error(`[@ait-co/devtools] ${moduleName}.${prop} is not mocked. This API may exist in @apps-in-toss/web-framework, but devtools' mock does not cover it yet. Please file an issue: ${ISSUES_URL}`);
352
+ } });
353
+ }
354
+ //#endregion
242
355
  //#region src/mock/ads/index.ts
243
356
  /**
244
357
  * 광고 mock (GoogleAdMob, TossAds, FullScreenAd)
358
+ *
359
+ * 변경 이력 (#196):
360
+ * - slot 레지스트리로 TossAds destroy/destroyAll 누수 수정 (🟡→🟢)
361
+ * - attachBanner BannerSlotCallbacks 발화 (onAdRendered/onAdImpression/onNoFill 등)
362
+ * - initialize onInitialized/onInitializationFailed 발화
363
+ * - AdMob reward 파라미터화 (state.ads.rewardUnitType/rewardAmount)
364
+ * - 모든 호출 observe()로 sdkCallLog에 기록
245
365
  */
246
366
  function withIsSupported(fn) {
247
367
  fn.isSupported = () => true;
248
368
  return fn;
249
369
  }
370
+ const _slotRegistry = /* @__PURE__ */ new Map();
371
+ let _slotCounter = 0;
372
+ function _nextSlotId(adGroupId) {
373
+ _slotCounter += 1;
374
+ return `mock-slot-${adGroupId}-${_slotCounter}`;
375
+ }
250
376
  const GoogleAdMob = createMockProxy("GoogleAdMob", {
251
- loadAppsInTossAdMob: withIsSupported((args) => {
377
+ loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
252
378
  setTimeout(() => {
253
379
  if (aitState.state.ads.forceNoFill) {
254
380
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -261,8 +387,8 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
261
387
  });
262
388
  }, 200);
263
389
  return () => {};
264
- }),
265
- showAppsInTossAdMob: withIsSupported((args) => {
390
+ })),
391
+ showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
266
392
  if (!aitState.state.ads.isLoaded) {
267
393
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
268
394
  return () => {};
@@ -271,11 +397,12 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
271
397
  setTimeout(() => args.onEvent({ type: "show" }), 100);
272
398
  setTimeout(() => args.onEvent({ type: "impression" }), 150);
273
399
  setTimeout(() => {
400
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
274
401
  args.onEvent({
275
402
  type: "userEarnedReward",
276
403
  data: {
277
- unitType: "coins",
278
- unitAmount: 10
404
+ unitType: rewardUnitType,
405
+ unitAmount: rewardAmount
279
406
  }
280
407
  });
281
408
  }, 1e3);
@@ -284,14 +411,18 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
284
411
  aitState.patch("ads", { isLoaded: false });
285
412
  }, 1500);
286
413
  return () => {};
287
- }),
288
- isAppsInTossAdMobLoaded: withIsSupported(async (_options) => aitState.state.ads.isLoaded)
414
+ })),
415
+ isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
289
416
  });
290
417
  const TossAds = createMockProxy("TossAds", {
291
- initialize: withIsSupported((_options) => {
292
- console.log("[@ait-co/devtools] TossAds.initialize (mock)");
293
- }),
294
- attach: withIsSupported((_adGroupId, target, _options) => {
418
+ initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
419
+ if (aitState.state.ads.forceNoFill) {
420
+ options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
421
+ return;
422
+ }
423
+ options.callbacks?.onInitialized?.();
424
+ })),
425
+ attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
295
426
  const el = typeof target === "string" ? document.querySelector(target) : target;
296
427
  if (el) {
297
428
  const placeholder = document.createElement("div");
@@ -299,21 +430,76 @@ const TossAds = createMockProxy("TossAds", {
299
430
  placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
300
431
  el.appendChild(placeholder);
301
432
  }
302
- }),
303
- attachBanner: withIsSupported((_adGroupId, target, _options) => {
433
+ })),
434
+ attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
304
435
  const el = typeof target === "string" ? document.querySelector(target) : target;
436
+ const slotId = _nextSlotId(adGroupId);
437
+ const placeholder = document.createElement("div");
438
+ const theme = options?.theme ?? "auto";
439
+ const variant = options?.variant ?? "card";
440
+ const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
441
+ const bg = isDark ? "#1a1a1a" : "#f0f0f0";
442
+ const textColor = isDark ? "#aaa" : "#666";
443
+ const borderColor = isDark ? "#555" : "#999";
444
+ const height = variant === "expanded" ? "120px" : "60px";
445
+ placeholder.dataset.aitSlotId = slotId;
446
+ placeholder.style.cssText = `background:${bg};border:1px dashed ${borderColor};padding:8px 12px;text-align:center;color:${textColor};font-size:12px;min-height:${height};display:flex;align-items:center;justify-content:center;`;
447
+ placeholder.textContent = `[@ait-co/devtools] Banner Ad (${variant})`;
305
448
  if (el) {
306
- const placeholder = document.createElement("div");
307
- placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:12px;text-align:center;color:#666;font-size:12px;";
308
- placeholder.textContent = "[@ait-co/devtools] Banner Ad Placeholder";
309
449
  el.appendChild(placeholder);
450
+ _slotRegistry.set(slotId, placeholder);
310
451
  }
311
- return { destroy: () => {} };
312
- }),
313
- destroy: withIsSupported((_slotId) => {}),
314
- destroyAll: withIsSupported(() => {})
452
+ const destroySlot = () => {
453
+ const registered = _slotRegistry.get(slotId);
454
+ if (registered) {
455
+ registered.remove();
456
+ _slotRegistry.delete(slotId);
457
+ }
458
+ };
459
+ setTimeout(() => {
460
+ if (aitState.state.ads.forceNoFill) {
461
+ options?.callbacks?.onNoFill?.({
462
+ slotId,
463
+ adGroupId,
464
+ adMetadata: {}
465
+ });
466
+ options?.callbacks?.onAdFailedToRender?.({
467
+ slotId,
468
+ adGroupId,
469
+ adMetadata: {},
470
+ error: {
471
+ code: 0,
472
+ message: "No fill"
473
+ }
474
+ });
475
+ return;
476
+ }
477
+ const eventPayload = {
478
+ slotId,
479
+ adGroupId,
480
+ adMetadata: {
481
+ creativeId: `mock-creative-${slotId}`,
482
+ requestId: `mock-req-${slotId}`
483
+ }
484
+ };
485
+ options?.callbacks?.onAdRendered?.(eventPayload);
486
+ options?.callbacks?.onAdImpression?.(eventPayload);
487
+ }, 100);
488
+ return { destroy: destroySlot };
489
+ })),
490
+ destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
491
+ const el = _slotRegistry.get(slotId);
492
+ if (el) {
493
+ el.remove();
494
+ _slotRegistry.delete(slotId);
495
+ }
496
+ })),
497
+ destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
498
+ for (const el of _slotRegistry.values()) el.remove();
499
+ _slotRegistry.clear();
500
+ }))
315
501
  });
316
- const loadFullScreenAd = withIsSupported((args) => {
502
+ const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
317
503
  setTimeout(() => {
318
504
  if (aitState.state.ads.forceNoFill) {
319
505
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -326,8 +512,8 @@ const loadFullScreenAd = withIsSupported((args) => {
326
512
  });
327
513
  }, 200);
328
514
  return () => {};
329
- });
330
- const showFullScreenAd = withIsSupported((args) => {
515
+ }));
516
+ const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
331
517
  if (!aitState.state.ads.isLoaded) {
332
518
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
333
519
  return () => {};
@@ -335,7 +521,7 @@ const showFullScreenAd = withIsSupported((args) => {
335
521
  setTimeout(() => args.onEvent({ type: "show" }), 100);
336
522
  setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
337
523
  return () => {};
338
- });
524
+ }));
339
525
  //#endregion
340
526
  //#region src/mock/analytics/index.ts
341
527
  /**
@@ -734,13 +920,70 @@ const fetchContacts = withPermission(_fetchContacts, "contacts");
734
920
  //#region src/mock/device/haptic.ts
735
921
  /**
736
922
  * Haptic Feedback & saveBase64Data mock
923
+ *
924
+ * generateHapticFeedback — 영역 3 (하드웨어 API 관측):
925
+ * - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
926
+ * - `typeof navigator.vibrate === 'function'` 가드 — API 없는 환경에서 throw 없이 skip.
927
+ * - sdkCallLog에 🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
928
+ * - 시그니처 불변 — __typecheck.ts의 Assert<Mock, Original> 통과.
929
+ */
930
+ /**
931
+ * HapticFeedbackType 10종 → navigator.vibrate 패턴 매핑.
932
+ * 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
737
933
  */
934
+ const HAPTIC_VIBRATE_PATTERN = {
935
+ tickWeak: 10,
936
+ tap: 20,
937
+ tickMedium: 30,
938
+ softMedium: 40,
939
+ basicWeak: 15,
940
+ basicMedium: 50,
941
+ success: [
942
+ 10,
943
+ 40,
944
+ 10
945
+ ],
946
+ error: [
947
+ 40,
948
+ 30,
949
+ 40
950
+ ],
951
+ wiggle: [
952
+ 20,
953
+ 20,
954
+ 20,
955
+ 20,
956
+ 20
957
+ ],
958
+ confetti: [
959
+ 10,
960
+ 20,
961
+ 10,
962
+ 20,
963
+ 10,
964
+ 20,
965
+ 10
966
+ ]
967
+ };
738
968
  async function generateHapticFeedback(options) {
739
- console.log(`[@ait-co/devtools] haptic: ${options.type}`);
969
+ const timestamp = Date.now();
740
970
  aitState.logAnalytics({
741
971
  type: "haptic",
742
972
  params: { hapticType: options.type }
743
973
  });
974
+ const pattern = HAPTIC_VIBRATE_PATTERN[options.type] ?? 30;
975
+ const vibrated = typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
976
+ aitState.logSdkCall({
977
+ method: "generateHapticFeedback",
978
+ args: [{ type: options.type }],
979
+ timestamp,
980
+ status: "resolved",
981
+ result: {
982
+ hapticType: options.type,
983
+ vibrated
984
+ },
985
+ fidelity: "partial"
986
+ });
744
987
  }
745
988
  async function saveBase64Data(params) {
746
989
  const a = document.createElement("a");
@@ -1108,8 +1351,9 @@ async function share(message) {
1108
1351
  async function getTossShareLink(path, _ogImageUrl) {
1109
1352
  return `https://toss.im/share/mock${path}`;
1110
1353
  }
1111
- async function setIosSwipeGestureEnabled(_options) {
1112
- console.log("[@ait-co/devtools] setIosSwipeGestureEnabled:", _options.isEnabled);
1354
+ async function setIosSwipeGestureEnabled(options) {
1355
+ console.log("[@ait-co/devtools] setIosSwipeGestureEnabled:", options.isEnabled);
1356
+ aitState.patch("navigation", { iosSwipeGestureEnabled: options.isEnabled });
1113
1357
  }
1114
1358
  async function setDeviceOrientation(options) {
1115
1359
  const current = aitState.state.viewport.orientation;
@@ -1120,17 +1364,17 @@ async function setDeviceOrientation(options) {
1120
1364
  }
1121
1365
  console.warn(`[@ait-co/devtools] setDeviceOrientation(${options.type}) ignored — Panel is forcing "${current}". Change the Viewport tab's orientation to "auto" to let the app control rotation.`);
1122
1366
  }
1123
- async function setScreenAwakeMode(options) {
1367
+ const setScreenAwakeMode = observe("setScreenAwakeMode", "inert", async (options) => {
1124
1368
  console.log("[@ait-co/devtools] setScreenAwakeMode:", options.enabled);
1125
1369
  return { enabled: options.enabled };
1126
- }
1127
- async function setSecureScreen(options) {
1370
+ });
1371
+ const setSecureScreen = observe("setSecureScreen", "inert", async (options) => {
1128
1372
  console.log("[@ait-co/devtools] setSecureScreen:", options.enabled);
1129
1373
  return { enabled: options.enabled };
1130
- }
1131
- async function requestReview() {
1374
+ });
1375
+ const requestReview = observe("requestReview", "inert", async () => {
1132
1376
  console.log("[@ait-co/devtools] requestReview called");
1133
- }
1377
+ });
1134
1378
  requestReview.isSupported = () => true;
1135
1379
  function getPlatformOS() {
1136
1380
  return aitState.state.platform;