@ait-co/devtools 0.1.32 → 0.1.34

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",
@@ -95,7 +78,9 @@ const DEFAULT_STATE = {
95
78
  isLoaded: false,
96
79
  nextEvent: "loaded",
97
80
  forceNoFill: false,
98
- lastEvent: null
81
+ lastEvent: null,
82
+ rewardUnitType: "coins",
83
+ rewardAmount: 10
99
84
  },
100
85
  game: {
101
86
  profile: {
@@ -105,6 +90,7 @@ const DEFAULT_STATE = {
105
90
  leaderboardScores: []
106
91
  },
107
92
  analyticsLog: [],
93
+ sdkCallLog: [],
108
94
  deviceModes: {
109
95
  camera: "mock",
110
96
  photos: "mock",
@@ -217,6 +203,19 @@ var AitStateManager = class {
217
203
  };
218
204
  this._notify();
219
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
+ }
220
219
  /** 이벤트 트리거 (backEvent, homeEvent 등) */
221
220
  trigger(event) {
222
221
  window.dispatchEvent(new CustomEvent(`__ait:${event}`));
@@ -240,16 +239,142 @@ if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
240
239
  const aitState = globalRef[SINGLETON_KEY];
241
240
  if (typeof window !== "undefined") window.__ait = aitState;
242
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
243
355
  //#region src/mock/ads/index.ts
244
356
  /**
245
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에 기록
246
365
  */
247
366
  function withIsSupported(fn) {
248
367
  fn.isSupported = () => true;
249
368
  return fn;
250
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
+ }
251
376
  const GoogleAdMob = createMockProxy("GoogleAdMob", {
252
- loadAppsInTossAdMob: withIsSupported((args) => {
377
+ loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
253
378
  setTimeout(() => {
254
379
  if (aitState.state.ads.forceNoFill) {
255
380
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -262,8 +387,8 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
262
387
  });
263
388
  }, 200);
264
389
  return () => {};
265
- }),
266
- showAppsInTossAdMob: withIsSupported((args) => {
390
+ })),
391
+ showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
267
392
  if (!aitState.state.ads.isLoaded) {
268
393
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
269
394
  return () => {};
@@ -272,11 +397,12 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
272
397
  setTimeout(() => args.onEvent({ type: "show" }), 100);
273
398
  setTimeout(() => args.onEvent({ type: "impression" }), 150);
274
399
  setTimeout(() => {
400
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
275
401
  args.onEvent({
276
402
  type: "userEarnedReward",
277
403
  data: {
278
- unitType: "coins",
279
- unitAmount: 10
404
+ unitType: rewardUnitType,
405
+ unitAmount: rewardAmount
280
406
  }
281
407
  });
282
408
  }, 1e3);
@@ -285,14 +411,18 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
285
411
  aitState.patch("ads", { isLoaded: false });
286
412
  }, 1500);
287
413
  return () => {};
288
- }),
289
- isAppsInTossAdMobLoaded: withIsSupported(async (_options) => aitState.state.ads.isLoaded)
414
+ })),
415
+ isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
290
416
  });
291
417
  const TossAds = createMockProxy("TossAds", {
292
- initialize: withIsSupported((_options) => {
293
- console.log("[@ait-co/devtools] TossAds.initialize (mock)");
294
- }),
295
- 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) => {
296
426
  const el = typeof target === "string" ? document.querySelector(target) : target;
297
427
  if (el) {
298
428
  const placeholder = document.createElement("div");
@@ -300,21 +430,76 @@ const TossAds = createMockProxy("TossAds", {
300
430
  placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
301
431
  el.appendChild(placeholder);
302
432
  }
303
- }),
304
- attachBanner: withIsSupported((_adGroupId, target, _options) => {
433
+ })),
434
+ attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
305
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})`;
306
448
  if (el) {
307
- const placeholder = document.createElement("div");
308
- placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:12px;text-align:center;color:#666;font-size:12px;";
309
- placeholder.textContent = "[@ait-co/devtools] Banner Ad Placeholder";
310
449
  el.appendChild(placeholder);
450
+ _slotRegistry.set(slotId, placeholder);
311
451
  }
312
- return { destroy: () => {} };
313
- }),
314
- destroy: withIsSupported((_slotId) => {}),
315
- 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
+ }))
316
501
  });
317
- const loadFullScreenAd = withIsSupported((args) => {
502
+ const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
318
503
  setTimeout(() => {
319
504
  if (aitState.state.ads.forceNoFill) {
320
505
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -327,8 +512,8 @@ const loadFullScreenAd = withIsSupported((args) => {
327
512
  });
328
513
  }, 200);
329
514
  return () => {};
330
- });
331
- const showFullScreenAd = withIsSupported((args) => {
515
+ }));
516
+ const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
332
517
  if (!aitState.state.ads.isLoaded) {
333
518
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
334
519
  return () => {};
@@ -336,7 +521,7 @@ const showFullScreenAd = withIsSupported((args) => {
336
521
  setTimeout(() => args.onEvent({ type: "show" }), 100);
337
522
  setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
338
523
  return () => {};
339
- });
524
+ }));
340
525
  //#endregion
341
526
  //#region src/mock/analytics/index.ts
342
527
  /**
@@ -735,13 +920,70 @@ const fetchContacts = withPermission(_fetchContacts, "contacts");
735
920
  //#region src/mock/device/haptic.ts
736
921
  /**
737
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. 배열: [진동, 정지, 진동, …] 교대 패턴.
738
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
+ };
739
968
  async function generateHapticFeedback(options) {
740
- console.log(`[@ait-co/devtools] haptic: ${options.type}`);
969
+ const timestamp = Date.now();
741
970
  aitState.logAnalytics({
742
971
  type: "haptic",
743
972
  params: { hapticType: options.type }
744
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
+ });
745
987
  }
746
988
  async function saveBase64Data(params) {
747
989
  const a = document.createElement("a");
@@ -1122,17 +1364,17 @@ async function setDeviceOrientation(options) {
1122
1364
  }
1123
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.`);
1124
1366
  }
1125
- async function setScreenAwakeMode(options) {
1367
+ const setScreenAwakeMode = observe("setScreenAwakeMode", "inert", async (options) => {
1126
1368
  console.log("[@ait-co/devtools] setScreenAwakeMode:", options.enabled);
1127
1369
  return { enabled: options.enabled };
1128
- }
1129
- async function setSecureScreen(options) {
1370
+ });
1371
+ const setSecureScreen = observe("setSecureScreen", "inert", async (options) => {
1130
1372
  console.log("[@ait-co/devtools] setSecureScreen:", options.enabled);
1131
1373
  return { enabled: options.enabled };
1132
- }
1133
- async function requestReview() {
1374
+ });
1375
+ const requestReview = observe("requestReview", "inert", async () => {
1134
1376
  console.log("[@ait-co/devtools] requestReview called");
1135
- }
1377
+ });
1136
1378
  requestReview.isSupported = () => true;
1137
1379
  function getPlatformOS() {
1138
1380
  return aitState.state.platform;