@ait-co/devtools 0.2.0 → 0.2.2

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.
@@ -25685,6 +25685,34 @@ function useT() {
25685
25685
  return t;
25686
25686
  }
25687
25687
  //#endregion
25688
+ //#region src/mock/throttle-registry.ts
25689
+ /**
25690
+ * throttle 레지스트리 (devtools#834).
25691
+ *
25692
+ * 메서드별 마지막 *허용* 호출 시각만 담는 leaf 모듈이다. `state.ts`가 `reset()`에서
25693
+ * 이걸 비워야 하는데, 로직 본체(`throttle.ts`)는 거꾸로 `state.ts`를 읽는다 —
25694
+ * 그래서 **의존이 없는 이 파일로 레지스트리를 분리해** 순환 import를 피한다.
25695
+ *
25696
+ * `state.ts`의 `AitStateManager`와 같은 이유로 `globalThis` 싱글턴이어야 한다 (#836):
25697
+ * `tsdown.config.ts`가 mock/panel/unplugin entry를 각각 self-contained로 빌드하므로,
25698
+ * 소비자가 두 entry를 동시에 import하면 이 모듈이 entry당 하나씩 복제된다. Map을
25699
+ * 모듈 지역 변수로 두면 `aitState.reset()`이 비우는 Map(싱글턴을 먼저 생성한 번들의 것)과
25700
+ * `throttleErrorFor`가 읽는 Map(mock 번들의 것)이 갈려, 리셋 직후 첫 호출이 낡은
25701
+ * 타임스탬프 때문에 거부된다 — 이 파일이 막으려던 바로 그 증상이다.
25702
+ */
25703
+ const SINGLETON_KEY$1 = "__aitDevtoolsThrottleRegistry__";
25704
+ const globalRef$1 = globalThis;
25705
+ if (!globalRef$1[SINGLETON_KEY$1]) globalRef$1[SINGLETON_KEY$1] = /* @__PURE__ */ new Map();
25706
+ /** 메서드별 마지막 *허용* 호출 시각(ms). 페이지 안의 모든 entry가 공유한다. */
25707
+ const lastAllowedAt = globalRef$1[SINGLETON_KEY$1];
25708
+ /**
25709
+ * throttle 레지스트리를 비운다. `aitState.reset()`이 호출한다 — 상태를 되돌렸는데
25710
+ * 직전 세션의 호출 시각이 남아 있으면 첫 호출이 이유 없이 거부된다.
25711
+ */
25712
+ function resetThrottleRegistry() {
25713
+ lastAllowedAt.clear();
25714
+ }
25715
+ //#endregion
25688
25716
  //#region src/mock/state.ts
25689
25717
  /** SDK 호출 로그 ring buffer 상한 */
25690
25718
  const SDK_CALL_LOG_MAX = 200;
@@ -25914,6 +25942,7 @@ var AitStateManager = class {
25914
25942
  ...structuredClone(DEFAULT_STATE),
25915
25943
  deviceId
25916
25944
  };
25945
+ resetThrottleRegistry();
25917
25946
  this._notify();
25918
25947
  }
25919
25948
  _notify() {
@@ -26331,7 +26360,9 @@ function waitForPromptResponse(type) {
26331
26360
  * - 3.x 라인: 같은 실패가 "맨 Error"(위 envelope 필드 없음)로 평탄화된다 —
26332
26361
  * sdk-example#284 "패턴 ① 오류 envelope 평탄화" 표.
26333
26362
  *
26334
- * `src/test-runner/bridge-stub.ts`의 `NativeBridgeErrorShape`/`makeNativeError`가
26363
+ * `@ait-co/debugger`의 `packages/debugger/src/test-runner/bridge-stub.ts`(분리
26364
+ * 이 repo의 `src/test-runner/bridge-stub.ts`)에 있는
26365
+ * `NativeBridgeErrorShape`/`makeNativeError`가
26335
26366
  * 같은 shape을 이미 CI 러너 쪽에서 재현하고 있다(devtools#740) — 이 모듈은 그
26336
26367
  * shape을 mock 소비자 표면(`src/mock/**`)에 대해 재사용 가능한 형태로 옮긴 것이다.
26337
26368
  * 두 모듈은 레이어가 다르다: test-runner 쪽은 env3 blocking-call 인터셉터(빌드에
@@ -26709,6 +26740,50 @@ const _fetchAlbumItems = async (options) => {
26709
26740
  const _fetchAlbumItemsWithPermission = withPermission(_fetchAlbumItems, "photos");
26710
26741
  Object.assign(_fetchAlbumItemsWithPermission, { isSupported: () => true });
26711
26742
  //#endregion
26743
+ //#region src/mock/throttle.ts
26744
+ /**
26745
+ * THROTTLED 시뮬레이션 (devtools#834 — #770 §3 분리).
26746
+ *
26747
+ * 실기기 네이티브 브리지는 같은 메서드를 짧은 간격으로 연타하면
26748
+ * `APP_BRIDGE_THROTTLED`로 거부한다. 이 모듈은 그 per-method rate limit을
26749
+ * env1(mock)에서 **opt-in으로** 재현한다 — 다이얼(`failureModes.throttled`)이
26750
+ * 미설정이면 아무 일도 하지 않으므로 zero behavior change다.
26751
+ *
26752
+ * 설계 메모 두 가지:
26753
+ *
26754
+ * 1. **거부된 호출은 창을 갱신하지 않는다.** 마지막 *허용된* 호출 시각만 기록하므로,
26755
+ * `intervalMs` 안에서 계속 연타해도 창이 밀려나지 않고 최초 허용 시점 기준으로
26756
+ * 풀린다. 네이티브 rate limit의 통상 동작이자, 연타 중 영원히 막히는(창이 계속
26757
+ * 갱신되는) 반-패턴을 피한다.
26758
+ * 2. **`observe()`에 걸지 않는다.** `observe()`는 `fn`을 호출하기 *전에* 감싸므로
26759
+ * 거기서 throw하면 원래 Promise를 반환하는 API가 **동기 throw**로 바뀐다.
26760
+ * `threwSync`는 env1↔env3 동치 diff의 관측 축이라 그 차이가 곧 가짜 불일치가 된다.
26761
+ * 그래서 각 mock 구현 본문 안(= 그 API 자신의 sync/async 계약 안)에 수동 삽입한다.
26762
+ */
26763
+ /**
26764
+ * `method`가 지금 throttle에 걸리면 네이티브 실패 에러를 돌려주고, 아니면
26765
+ * 호출 시각을 기록한 뒤 `undefined`를 돌려준다.
26766
+ *
26767
+ * 반환된 에러를 **호출부가 자신의 계약대로** 흘려보낸다 — async mock은 `throw`
26768
+ * (= reject), 콜백형 mock은 `onError(err)`. 그래서 이 함수 자체는 던지지 않는다.
26769
+ */
26770
+ function throttleErrorFor(method) {
26771
+ const dial = aitState.state.failureModes.throttled;
26772
+ if (!dial?.methods?.includes(method)) return void 0;
26773
+ const now = Date.now();
26774
+ const prev = lastAllowedAt.get(method);
26775
+ if (prev !== void 0 && now - prev < dial.intervalMs) return buildNativeError("APP_BRIDGE_THROTTLED");
26776
+ lastAllowedAt.set(method, now);
26777
+ }
26778
+ /**
26779
+ * `throttleErrorFor`의 throw 버전 — async mock 본문에서 한 줄로 쓴다.
26780
+ * async 함수 안에서 던지므로 호출자에게는 rejection으로 도달한다.
26781
+ */
26782
+ function checkThrottle(method) {
26783
+ const err = throttleErrorFor(method);
26784
+ if (err) throw err;
26785
+ }
26786
+ //#endregion
26712
26787
  //#region src/mock/device/clipboard.ts
26713
26788
  /**
26714
26789
  * Clipboard mock
@@ -26716,6 +26791,7 @@ Object.assign(_fetchAlbumItemsWithPermission, { isSupported: () => true });
26716
26791
  */
26717
26792
  const _getClipboardText = async () => {
26718
26793
  checkPermission("clipboard", "getClipboardText");
26794
+ checkThrottle("getClipboardText");
26719
26795
  if (aitState.state.deviceModes.clipboard === "mock") return aitState.state.mockData.clipboardText;
26720
26796
  try {
26721
26797
  return await navigator.clipboard.readText();
@@ -26726,6 +26802,7 @@ const _getClipboardText = async () => {
26726
26802
  withPermission(_getClipboardText, "clipboard");
26727
26803
  const _setClipboardText = async (text) => {
26728
26804
  checkPermission("clipboard", "setClipboardText");
26805
+ checkThrottle("setClipboardText");
26729
26806
  if (aitState.state.deviceModes.clipboard === "mock") {
26730
26807
  aitState.patch("mockData", { clipboardText: text });
26731
26808
  return { text };
@@ -26877,6 +26954,7 @@ async function getCurrentLocationPrompt() {
26877
26954
  }
26878
26955
  const _getCurrentLocation = async (_options) => {
26879
26956
  checkPermission("geolocation", "getCurrentLocation");
26957
+ checkThrottle("getCurrentLocation");
26880
26958
  const mode = aitState.state.deviceModes.location;
26881
26959
  if (mode === "web") return getCurrentLocationWeb();
26882
26960
  if (mode === "prompt") return getCurrentLocationPrompt();
@@ -27344,6 +27422,11 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
27344
27422
  args.onError(buildNativeError(failureCode));
27345
27423
  return;
27346
27424
  }
27425
+ const throttleError = throttleErrorFor("loadAppsInTossAdMob");
27426
+ if (throttleError) {
27427
+ args.onError(throttleError);
27428
+ return;
27429
+ }
27347
27430
  if (aitState.state.ads.forceNoFill) {
27348
27431
  args.onError(/* @__PURE__ */ new Error("No fill"));
27349
27432
  return;
@@ -27472,6 +27555,11 @@ const loadFullScreenAd = observe("loadFullScreenAd", "faithful", (args) => {
27472
27555
  args.onError(buildNativeError(failureCode));
27473
27556
  return;
27474
27557
  }
27558
+ const throttleError = throttleErrorFor("loadFullScreenAd");
27559
+ if (throttleError) {
27560
+ args.onError(throttleError);
27561
+ return;
27562
+ }
27475
27563
  if (aitState.state.ads.forceNoFill) {
27476
27564
  args.onError(/* @__PURE__ */ new Error("No fill"));
27477
27565
  return;
@@ -30193,7 +30281,7 @@ function Panel() {
30193
30281
  color: "#666",
30194
30282
  fontWeight: 400
30195
30283
  },
30196
- children: ["v", "0.2.0"]
30284
+ children: ["v", "0.2.2"]
30197
30285
  }),
30198
30286
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
30199
30287
  type: "button",