@ait-co/devtools 0.1.19 → 0.1.20

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.
package/README.en.md ADDED
@@ -0,0 +1,863 @@
1
+ # @ait-co/devtools
2
+
3
+ [한국어](./README.md) · **English**
4
+
5
+ [![npm](https://img.shields.io/npm/v/@ait-co/devtools)](https://www.npmjs.com/package/@ait-co/devtools) [![license](https://img.shields.io/badge/license-BSD--3--Clause-blue)](./LICENSE)
6
+
7
+ ![@ait-co/devtools — SDK mock + DevTools panel for Apps In Toss mini-apps](./assets/og/image.png)
8
+
9
+ A mock library for the `@apps-in-toss/web-framework` SDK. Imports of `@apps-in-toss/web-bridge` and `@apps-in-toss/web-analytics` are also mocked.
10
+
11
+ Lets you develop and test Apps in Toss mini-apps in a **regular browser** — without the Toss app. All SDK features are simulated so you can move fast.
12
+
13
+ - **60+ SDK API mocks** — auth, payments, IAP, location, camera, storage, and more
14
+ - **Device API mode system** — switch between mock / web / prompt modes for device APIs
15
+ - **Device simulation** — iPhone/Galaxy presets + orientation toggle to simulate a mobile viewport in your desktop browser
16
+ - **Floating DevTools Panel** — control SDK state in real time from the browser (10 tabs, mock state preset library included)
17
+ - **All bundlers supported** — [unplugin](https://github.com/unjs/unplugin)-based Vite, Webpack, Rspack, esbuild, and Rollup integration
18
+
19
+ Live demo: <https://devtools.aitc.dev/> (the `e2e/fixture/` from this repo deployed to GitHub Pages as a self-contained demo).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -D @ait-co/devtools
25
+ # or
26
+ pnpm add -D @ait-co/devtools
27
+ ```
28
+
29
+ > **Supported SDK version**: `@apps-in-toss/web-framework >=2.5.0 <2.6.0` (peer, required).
30
+ >
31
+ > devtools is only verified against SDK versions within that range. Installing an out-of-range SDK version
32
+ > will cause the package manager to emit a peer warning at install time. Additionally, calling an API that
33
+ > devtools has not yet mocked will throw a runtime error — this is intentional to prevent the
34
+ > "works in devtools but fails with the real SDK" type of production incident. For missing APIs,
35
+ > please [file an issue](https://github.com/apps-in-toss-community/devtools/issues).
36
+
37
+ ## Reference consumer
38
+
39
+ [`sdk-example`](https://github.com/apps-in-toss-community/sdk-example) is the reference consumer of devtools. It's a catalog app where every SDK API can be run interactively, and the web demo is live at <https://sdk-example.aitc.dev/>. When you add a new mock, confirming that it works on the sdk-example card is the first sanity check. That said, this repo's E2E suite runs against an **internal self-contained fixture (`e2e/fixture/`)** without cloning sdk-example — so a broken sdk-example won't affect devtools CI.
40
+
41
+ ## Bundler setup
42
+
43
+ ### Vite
44
+
45
+ ```ts
46
+ // vite.config.ts (development only)
47
+ import aitDevtools from '@ait-co/devtools/unplugin';
48
+
49
+ export default {
50
+ plugins: [aitDevtools.vite()],
51
+ };
52
+ ```
53
+
54
+ > This is a development-only setup. To exclude it from production builds, see the [Production builds](#production-builds) section below.
55
+
56
+ ### Webpack / Rspack
57
+
58
+ ```js
59
+ // webpack.config.js (ESM, recommended for development only)
60
+ import aitDevtools from '@ait-co/devtools/unplugin';
61
+ config.plugins.push(aitDevtools.webpack());
62
+
63
+ // webpack.config.js (CommonJS)
64
+ const aitDevtools = require('@ait-co/devtools/unplugin');
65
+ config.plugins.push(aitDevtools.webpack());
66
+ ```
67
+
68
+ ### Next.js (Turbopack)
69
+
70
+ Turbopack does not support a plugin system, so use `resolveAlias` instead.
71
+
72
+ - You also need to alias `@apps-in-toss/web-bridge` and `@apps-in-toss/web-analytics`.
73
+ - Turbopack is generally only used with `next dev`, so no extra production guard is needed.
74
+
75
+ ```js
76
+ // next.config.js (Next.js 15+)
77
+ module.exports = {
78
+ turbo: {
79
+ resolveAlias: {
80
+ '@apps-in-toss/web-framework': '@ait-co/devtools/mock',
81
+ '@apps-in-toss/web-bridge': '@ait-co/devtools/mock',
82
+ '@apps-in-toss/web-analytics': '@ait-co/devtools/mock',
83
+ },
84
+ },
85
+ };
86
+ ```
87
+
88
+ For Next.js 14 and below, use `experimental.turbo`:
89
+
90
+ ```js
91
+ // next.config.js (Next.js 14 and below)
92
+ module.exports = {
93
+ experimental: {
94
+ turbo: {
95
+ resolveAlias: {
96
+ '@apps-in-toss/web-framework': '@ait-co/devtools/mock',
97
+ '@apps-in-toss/web-bridge': '@ait-co/devtools/mock',
98
+ '@apps-in-toss/web-analytics': '@ait-co/devtools/mock',
99
+ },
100
+ },
101
+ },
102
+ };
103
+ ```
104
+
105
+ > **Panel injection**: Turbopack does not support unplugin, so the Panel is not auto-injected. Import it directly from your entry point:
106
+ > ```ts
107
+ > // app/layout.tsx or pages/_app.tsx
108
+ > import '@ait-co/devtools/panel';
109
+ > ```
110
+
111
+ ### Next.js (Webpack)
112
+
113
+ When using Webpack mode in Next.js (`next dev` without `--turbo`, or `next build`):
114
+
115
+ ```js
116
+ // next.config.js (Webpack mode)
117
+ const aitDevtools = require('@ait-co/devtools/unplugin'); // CJS entrypoint provided
118
+
119
+ module.exports = {
120
+ webpack: (config, { dev }) => {
121
+ if (dev) {
122
+ config.plugins.push(aitDevtools.webpack());
123
+ }
124
+ return config;
125
+ },
126
+ };
127
+ ```
128
+
129
+ ### Manual alias setup
130
+
131
+ You can also configure the bundler's `resolve.alias` directly:
132
+
133
+ ```ts
134
+ // vite.config.ts
135
+ import { defineConfig } from 'vite';
136
+
137
+ export default defineConfig({
138
+ resolve: {
139
+ alias: {
140
+ '@apps-in-toss/web-framework': '@ait-co/devtools/mock',
141
+ '@apps-in-toss/web-bridge': '@ait-co/devtools/mock',
142
+ '@apps-in-toss/web-analytics': '@ait-co/devtools/mock',
143
+ },
144
+ },
145
+ });
146
+ ```
147
+
148
+ ```js
149
+ // webpack.config.js (Webpack requires absolute paths)
150
+ module.exports = {
151
+ resolve: {
152
+ alias: {
153
+ '@apps-in-toss/web-framework': require.resolve('@ait-co/devtools/mock'),
154
+ '@apps-in-toss/web-bridge': require.resolve('@ait-co/devtools/mock'),
155
+ '@apps-in-toss/web-analytics': require.resolve('@ait-co/devtools/mock'),
156
+ },
157
+ },
158
+ };
159
+ ```
160
+
161
+ > **Note**: Using manual aliases alone will not auto-inject the DevTools Panel. Add a direct import to your entry point:
162
+ > ```ts
163
+ > import '@ait-co/devtools/panel'; // add to entry point
164
+ > ```
165
+
166
+ ### Plugin options
167
+
168
+ | Option | Type | Default | Description |
169
+ |---|---|---|---|
170
+ | `panel` | `boolean` | `true` | Auto-inject the DevTools Panel |
171
+ | `forceEnable` | `boolean` | `false` | Enable devtools even in production |
172
+ | `mock` | `boolean` | `true` (dev) / `false` (prod+forceEnable) | Enable mock alias |
173
+ | `tunnel` | `boolean \| { port?: number; qr?: boolean }` | `false` | Expose the Vite dev server via a Cloudflare quick tunnel for real-device preview (see [below](#run-on-a-real-phone)). **Vite dev mode only** |
174
+
175
+ ```ts
176
+ aitDevtools.vite({ panel: false }); // mock only, no panel
177
+ aitDevtools.vite({ forceEnable: true }); // enable in production (mock OFF by default, panel ON)
178
+ aitDevtools.vite({ forceEnable: true, mock: true }); // enable mock in production too
179
+ aitDevtools.vite({ tunnel: true }); // expose dev server at *.trycloudflare.com
180
+ ```
181
+
182
+ ## Production builds
183
+
184
+ By default, the devtools plugin **automatically disables itself in production** (`NODE_ENV === 'production'` causes both the alias transform and the Panel injection to be skipped). No conditional configuration is needed to keep it safe.
185
+
186
+ To use devtools in a production build — for example in a staging environment — use the `forceEnable` option:
187
+
188
+ ```ts
189
+ aitDevtools.vite({ forceEnable: true }); // panel ON, mock OFF (monitoring only)
190
+ aitDevtools.vite({ forceEnable: true, mock: true }); // panel + mock both ON
191
+ ```
192
+
193
+ You can also conditionally exclude the plugin from your bundler config entirely:
194
+
195
+ ```ts
196
+ // vite.config.ts
197
+ import { defineConfig } from 'vite';
198
+ import aitDevtools from '@ait-co/devtools/unplugin';
199
+
200
+ export default defineConfig(({ command }) => ({
201
+ plugins: [
202
+ ...(command === 'serve' ? [aitDevtools.vite()] : []),
203
+ ],
204
+ }));
205
+ ```
206
+
207
+ ```js
208
+ // webpack.config.js (same applies to Rspack)
209
+ const aitDevtools = require('@ait-co/devtools/unplugin');
210
+ const plugins = [];
211
+ if (process.env.NODE_ENV !== 'production') {
212
+ plugins.push(aitDevtools.webpack());
213
+ }
214
+ ```
215
+
216
+ > For Next.js, see the [Next.js (Webpack)](#nextjs-webpack) and [Next.js (Turbopack)](#nextjs-turbopack) sections above.
217
+
218
+ ## Run on a real phone
219
+
220
+ When you want to view a mini-app that runs fine in desktop Chrome on an **actual phone**. The Vite dev server is exposed via a Cloudflare quick tunnel (`*.trycloudflare.com`, **no account required**), and you add a launcher PWA with a fixed URL to your phone's home screen once, then open each session's tunnel URL inside it.
221
+
222
+ Setup has three tiers:
223
+
224
+ - **Once per project** — add the option to `vite.config`, add the pnpm setting to `package.json`, and optionally add a `dev:phone` script
225
+ - **Once per phone** — add the launcher PWA to your home screen
226
+ - **Each session** — one line: `pnpm dev:phone` (or `AIT_TUNNEL=1 pnpm dev`)
227
+
228
+ ### 1. Per-project setup
229
+
230
+ (a) **Add the `tunnel` option to `vite.config.ts`** — if you're fine with cloudflared starting every time, use `tunnel: true`; if you prefer to keep it off by default and enable it explicitly, use an env gate:
231
+
232
+ ```ts
233
+ // vite.config.ts
234
+ import { defineConfig } from 'vite';
235
+ import aitDevtools from '@ait-co/devtools/unplugin';
236
+
237
+ export default defineConfig({
238
+ plugins: [
239
+ aitDevtools.vite({
240
+ tunnel: !!process.env.AIT_TUNNEL, // OFF by default, ON when AIT_TUNNEL=1
241
+ }),
242
+ ],
243
+ });
244
+ ```
245
+
246
+ > `process.env.AIT_TUNNEL` is evaluated when `vite.config.ts` is loaded (i.e. when the vite process starts). The env variable must therefore be set **before** vite launches (the `dev:phone` script in step (c) handles this automatically).
247
+
248
+ (b) **Allow the pnpm 10+ build script** — pnpm blocks dependency postinstall scripts by default for security. `cloudflared` downloads its binary (~38 MB) in postinstall, so you need to explicitly allow it:
249
+
250
+ ```json
251
+ {
252
+ "pnpm": {
253
+ "onlyBuiltDependencies": ["cloudflared"]
254
+ }
255
+ }
256
+ ```
257
+
258
+ > Without this, things still work — `tunnel.ts` lazily calls `cloudflared.install()` on first start. You will just see an "Ignored build scripts" warning on every `pnpm install`, and the binary download is deferred to the first `pnpm dev`. See [`sdk-example#60`](https://github.com/apps-in-toss-community/sdk-example/pull/60).
259
+
260
+ (c) **(Optional) `dev:phone` script** — to avoid typing the env variable each time:
261
+
262
+ ```json
263
+ {
264
+ "scripts": {
265
+ "dev": "vite",
266
+ "dev:phone": "AIT_TUNNEL=1 vite"
267
+ }
268
+ }
269
+ ```
270
+
271
+ ### 2. Per-phone setup
272
+
273
+ Open `https://devtools.aitc.dev/launcher/` on your phone and **add it to your home screen** (iOS Safari: Share → Add to Home Screen; Android Chrome: Install app). The launcher URL never changes, so this is a one-time step.
274
+
275
+ ### 3. Each session
276
+
277
+ 1. Run `pnpm dev:phone` on your desktop (or `AIT_TUNNEL=1 pnpm dev` if you skipped step 1-(c)). The terminal will print a `https://*.trycloudflare.com` URL along with an ASCII QR code.
278
+ 2. Open the launcher icon on your phone → scan the QR code with your camera (or paste the URL) → your dev app opens full-screen without an address bar.
279
+ 3. Next session, just scan the new URL. The launcher remembers the last URL and you can swap it any time with the "Rescan" button.
280
+
281
+ ### Background
282
+
283
+ > **Why go through a launcher?** The quick tunnel URL changes on every run, so installing that URL directly as a PWA gives you a dead link next session. Navigating cross-origin breaks the standalone (chrome-less) mode on both iOS and Android. → The solution is to install a launcher with a fixed URL once, and use an `<iframe>` inside it to show the day's dev app full-bleed.
284
+ >
285
+ > Quick tunnels have **no authentication**, the **URL changes on every run**, and they are **not for production use**. (If you have an account and domain, a named tunnel with a fixed hostname is possible via a future `tunnel: { hostname }` option.)
286
+ >
287
+ > The `tunnel` option only works in Vite dev mode — no tunnel is started for production builds, even with `forceEnable`. It is silently ignored for other bundlers (Webpack/Rspack, etc.). When the option is enabled, `cloudflared` and `qrcode-terminal` are loaded via dynamic import only, so they do not appear in the bundle graph when the option is off.
288
+
289
+ ### One-line setup (planned)
290
+
291
+ The per-project steps above (vite.config patch + `onlyBuiltDependencies` + `dev:phone` script) are planned to be absorbed into a single command like `/ait setup phone` in the future [`agent-plugin`](https://github.com/apps-in-toss-community/agent-plugin) (command name is tentative). Since this README serves as the spec for that automation, the manual steps will remain documented here even after automation is available.
292
+
293
+ ## Device API mode system
294
+
295
+ Device-related APIs (camera, location, clipboard, etc.) operate in three modes:
296
+
297
+ | Mode | Behavior | Use case |
298
+ |---|---|---|
299
+ | **mock** | Returns dummy data stored in `aitState` | Automated tests, fixed scenarios |
300
+ | **web** | Uses browser-native APIs (Geolocation, File API, etc.) | Testing with real device capabilities |
301
+ | **prompt** | DevTools Panel opens automatically and waits for user input (30-second timeout) | Manual QA, entering specific values |
302
+
303
+ ### API support by mode
304
+
305
+ | API | mock | web | prompt |
306
+ |---|---|---|---|
307
+ | `openCamera` | ✅ | ✅ | ✅ |
308
+ | `fetchAlbumPhotos` | ✅ | ✅ | ✅ |
309
+ | `getCurrentLocation` | ✅ | ✅ | ✅ |
310
+ | `startUpdateLocation` | ✅ | ✅ | ✅ |
311
+ | `getNetworkStatus` | ✅ | ✅ | — |
312
+ | `getClipboardText` / `setClipboardText` | ✅ | ✅ | — |
313
+
314
+ ### Setting the mode
315
+
316
+ ```js
317
+ // Change individual API modes from the console
318
+ __ait.patch('deviceModes', { camera: 'web', location: 'prompt' });
319
+
320
+ // Or use the dropdown in the Device tab of the DevTools Panel
321
+ ```
322
+
323
+ ### Managing dummy images
324
+
325
+ Camera and album APIs return dummy images in mock mode.
326
+
327
+ - **Default placeholders**: 3 auto-generated 320×240 images in blue, green, and orange
328
+ - **Custom images**: Add or remove files from the Device tab in the DevTools Panel
329
+ - **Set from console**: `__ait.patch('mockData', { images: ['data:image/png;base64,...'] })`
330
+
331
+ ## Floating DevTools Panel
332
+
333
+ When using the plugin, the panel is auto-injected into your entry point file. Click the **'AIT' button** in the bottom-right corner of the screen to toggle it.
334
+
335
+ ### 10 tabs
336
+
337
+ | Tab | Description |
338
+ |---|---|
339
+ | **Environment** | Platform OS (ios/android), app version, environment (toss/sandbox), locale, network status, Safe Area Insets |
340
+ | **Presets** | Apply/remove common QA scenarios (permission denied, offline, logged out, etc.) with one click. Save and delete user presets |
341
+ | **Viewport** | Simulate a mobile viewport using device presets (iPhone/Galaxy) + orientation toggle |
342
+ | **Permissions** | Control camera, photos, geolocation, clipboard, contacts, and microphone permission states (allowed/denied/notDetermined) |
343
+ | **Location** | Set latitude, longitude, and accuracy |
344
+ | **Device** | Switch API modes (mock/web/prompt), manage dummy images (add/remove/reset to defaults) |
345
+ | **IAP** | Choose the next purchase result (success/cancel/error, etc.), TossPay payment result, completed order history (last 5) |
346
+ | **Events** | Trigger Back/Home navigation events, toggle login state |
347
+ | **Analytics** | Real-time log viewer for recorded analytics events (last 30 entries, with timestamp/type/parameters) |
348
+ | **Storage** | View and clear items stored via the `Storage` API |
349
+
350
+ > **Prompt mode auto-open**: When an API set to prompt mode is called, the Panel automatically opens the Device tab and shows the input UI.
351
+
352
+ ### Mock state preset library (Presets tab)
353
+
354
+ When a scenario requires multiple mock keys to be in a specific state simultaneously (e.g. "IAP `NETWORK_ERROR` + payment fail when offline"), instead of setting them manually each time you can apply the whole set with one click. Applied presets show a ✓ indicator; if any key defined by the preset changes, the indicator automatically clears (keys not defined by the preset are not compared).
355
+
356
+ Built-in presets:
357
+
358
+ | ID | Meaning |
359
+ |---|---|
360
+ | `all-allowed` | All permissions allowed, WIFI, logged in, IAP success — return to baseline scenario |
361
+ | `permission-denied` | camera / photos / geolocation / contacts denied |
362
+ | `offline` | `getNetworkStatus` → OFFLINE, IAP `NETWORK_ERROR`, payment fail |
363
+ | `logged-out` | `auth.isLoggedIn=false`. Validates the login flow |
364
+ | `iap-pending` | IAP `nextResult` → `PAYMENT_PENDING` |
365
+ | `ads-no-fill` | Triggers the ad fill failure branch |
366
+
367
+ Any state you've toggled together can be saved as a preset via the "Save current as preset" button (persisted in `localStorage` with the `__ait_preset:<id>` prefix). Saved presets survive page reload and tab re-entry. Preset scope is limited to the `networkStatus / permissions / auth / iap / ads / payment` slices — unrelated state like viewport and brand is not affected.
368
+
369
+ Presets are also exported from the package:
370
+
371
+ ```ts
372
+ import { applyPreset, builtInPresets, saveUserPreset } from '@ait-co/devtools';
373
+
374
+ // Apply a built-in preset
375
+ const offline = builtInPresets.find((p) => p.id === 'offline')!;
376
+ applyPreset(offline.state);
377
+
378
+ // Save a custom preset
379
+ saveUserPreset('My QA scenario', {
380
+ networkStatus: 'OFFLINE',
381
+ permissions: { camera: 'denied' },
382
+ auth: { isLoggedIn: false },
383
+ });
384
+ ```
385
+
386
+ ### Panel mount / dispose
387
+
388
+ Importing `@ait-co/devtools/panel` mounts the panel automatically when the DOM is ready. Mounting is idempotent — even if the same page imports it multiple times or calls `mount()` again, only one toggle button will be shown.
389
+
390
+ If you need to explicitly remove the panel in HMR or SPA routing scenarios, use `disposePanel()`:
391
+
392
+ ```ts
393
+ import { disposePanel, mount } from '@ait-co/devtools/panel';
394
+
395
+ disposePanel(); // Removes the toggle, panel, injected <style>, and all listeners.
396
+ // Safe to call before mounting or to call twice.
397
+ mount(); // Re-mount from a clean state. No duplicate <style> or listeners.
398
+ ```
399
+
400
+ `disposeViewport()` is called internally as well, so any active viewport simulation is also reverted.
401
+
402
+ ## Device simulation (Viewport tab)
403
+
404
+ When developing mobile mini-apps in a desktop browser, you can validate layout against the actual device resolution, safe area, notch, home indicator, and Apps in Toss nav bar.
405
+
406
+ ### Presets (2026)
407
+
408
+ | Category | Devices |
409
+ |---|---|
410
+ | Apple | iPhone SE (3rd gen), iPhone 16e, iPhone 17, iPhone Air, iPhone 17 Pro, iPhone 17 Pro Max |
411
+ | Samsung | Galaxy S26, S26+, S26 Ultra, Z Flip7, Z Fold7 (folded / unfolded) |
412
+ | Other | Custom (enter width/height manually), None (default) |
413
+
414
+ > **Galaxy S26 series** (released 2026-03-11): CSS viewport values use measurements from [phone-simulator.com](https://www.phone-simulator.com/). Safe area insets temporarily use S25 values pending real measurements in the Toss host environment — for pixel-accurate QA, verify on a real device.
415
+ >
416
+ > iPhone 17 series was released in September 2025 and is based on actual spec.
417
+
418
+ Each preset includes:
419
+ - **CSS viewport** (portrait `width × height`)
420
+ - **DPR** (devicePixelRatio: 2, 3, 3.5, etc.)
421
+ - **Notch** type (`none` / `notch` / `dynamic-island` / `punch-hole-center`)
422
+ - **OS-level safe area insets** (status bar / home indicator / left/right insets based on notch rotation)
423
+
424
+ ### Orientation
425
+
426
+ - **auto** (default) — The Panel does not force any orientation. Calls to `setDeviceOrientation` from your app are recorded in a separate field (`appOrientation`) and used to determine the effective orientation. Repeated calls from the same app are always reflected correctly.
427
+ - **portrait / landscape** — The Panel overrides orientation. Calls to `setDeviceOrientation` from your app are ignored and logged with `console.warn`.
428
+
429
+ When switching to landscape:
430
+ - CSS viewport width and height are swapped.
431
+ - For iPhone (notch/Dynamic Island) presets, the safe area top becomes 0 and an inset appears on only one side depending on the **Notch side** toggle (left/right, default left) — matching real device behavior.
432
+ - For Android (punch-hole) presets, the status bar stays at the top.
433
+
434
+ ### Frame + notch + home indicator + Apps in Toss nav bar
435
+
436
+ When **Show frame** is toggled on:
437
+ - Border-radius + box-shadow to mimic the device bezel
438
+ - Notch / Dynamic Island / punch-hole overlay (absolutely positioned at the top of body)
439
+ - Home indicator pill (only on devices with `safeAreaBottom > 0`, positioned at the bottom of body)
440
+ - App name uses `aitState.brand.displayName` (editable in the Environment tab, auto-updates)
441
+ - The back button triggers `__ait:backEvent` and the X button calls `closeView()` — you can verify actual SDK event plumbing directly from the panel
442
+
443
+ When **Show Apps in Toss nav bar** is toggled on (default on):
444
+ - A 48px nav bar overlay simulating the Toss host's top nav bar (back / app icon+name / ⋯ / ×)
445
+ - Positioned just below the status bar, after the safe area top
446
+ - **Important**: these 48px are **not included** in `env(safe-area-inset-top)` or `SafeAreaInsets.get().top` (this matches the SDK behavior). Toss-side examples compensate using the pattern `insets.top + 48`.
447
+
448
+ ### Console manipulation
449
+
450
+ ```js
451
+ // iPhone 17 Pro portrait + frame on
452
+ __ait.patch('viewport', { preset: 'iphone-17-pro', orientation: 'auto', frame: true });
453
+
454
+ // Force landscape (app's setDeviceOrientation calls are ignored)
455
+ __ait.patch('viewport', { orientation: 'landscape' });
456
+
457
+ // Notch side in landscape (iOS default 'left')
458
+ __ait.patch('viewport', { landscapeSide: 'right' });
459
+
460
+ // Custom size (automatically clamped to 1–4096)
461
+ __ait.patch('viewport', { preset: 'custom', customWidth: 360, customHeight: 740 });
462
+
463
+ // Hide the Apps in Toss nav bar (to inspect the pure viewport)
464
+ __ait.patch('viewport', { aitNavBar: false });
465
+
466
+ // Toggle nav bar variant ('partner' = white background + icon/name, 'game' = transparent + ⋯/× only)
467
+ __ait.patch('viewport', { aitNavBarType: 'game' });
468
+
469
+ // Reset
470
+ __ait.patch('viewport', { preset: 'none' });
471
+ ```
472
+
473
+ ### Status panel
474
+
475
+ The bottom of the Viewport tab shows the currently applied values in real time:
476
+ - **CSS / physical**: `402×874@3x | 1206×2622 portrait (auto)`
477
+ - **Safe area**: `T59 R0 B34 L0`
478
+ - **AIT nav bar**: `48px (excl. SafeArea)`
479
+
480
+ ### Persistence + technical details
481
+
482
+ - State is saved to sessionStorage (`__ait_viewport`) and restored on page reload.
483
+ - Selecting a preset also updates `aitState.safeAreaInsets` → the SDK's `SafeAreaInsets.get()` / `.subscribe()` follow along.
484
+ - The viewport is applied to `document.body` via `max-width`/`max-height` + `margin:auto`. No iframe is used, so the app's JS/CSS runs as-is and DevTools remains fully accessible.
485
+ - `isolation: isolate` is applied to body so the z-index of the notch/nav bar/home indicator overlay doesn't leak outside the stacking context (the DevTools panel floats above).
486
+ - If you need to remove the viewport simulation programmatically, `disposeViewport()` is available as an export.
487
+ - User-Agent spoofing / touch event emulation / network throttling are not done (Chrome DevTools already provides these).
488
+
489
+ ### Known limitations
490
+
491
+ - **Body becomes the scroll container** — while the viewport is active, scrolling happens on `document.body` rather than `window`. `window.addEventListener('scroll', ...)` or `IntersectionObserver` attached to the root may behave differently from a real device. If your mini-app handles scrolling, verify it against `body` as well.
492
+ - **Estimated presets** — iPhone Air is labeled `(est)` (not yet released) and will be updated when it ships. Galaxy S26 series is based on published spec (phone-simulator.com measurements), but safe area values are temporarily from S25 — pixel-accurate QA should be verified on a real device.
493
+
494
+ ## `window.__ait` console API
495
+
496
+ You can control mock state directly from the browser console via `window.__ait` (or just `__ait`):
497
+
498
+ ```js
499
+ // Read current state
500
+ __ait.state // full state object
501
+ __ait.state.platform // 'ios' or 'android'
502
+ __ait.state.auth.isLoggedIn // login state
503
+ __ait.state.deviceModes // current mode for each API
504
+
505
+ // Update state (shallow merge)
506
+ __ait.update({ platform: 'android', locale: 'en-US' });
507
+ __ait.update({ networkStatus: 'OFFLINE' });
508
+
509
+ // Update nested state
510
+ __ait.patch('permissions', { camera: 'denied' });
511
+ __ait.patch('deviceModes', { location: 'web' });
512
+ __ait.patch('iap', { nextResult: 'USER_CANCELED' });
513
+
514
+ // Trigger events
515
+ __ait.trigger('backEvent');
516
+ __ait.trigger('homeEvent');
517
+
518
+ // Log an analytics event manually
519
+ __ait.logAnalytics({ type: 'click', params: { button: 'purchase' } });
520
+
521
+ // Reset state (deviceId is preserved)
522
+ __ait.reset();
523
+
524
+ // Subscribe to state changes
525
+ const unsubscribe = __ait.subscribe(() => {
526
+ console.log('state changed:', __ait.state);
527
+ });
528
+ unsubscribe(); // unsubscribe
529
+ ```
530
+
531
+ ## Mock API reference
532
+
533
+ ### Auth / login
534
+
535
+ | API | Mock behavior |
536
+ |---|---|
537
+ | `appLogin` | Returns `{ authorizationCode, referrer }` |
538
+ | `getIsTossLoginIntegratedService` | Returns state's `isTossLoginIntegrated` |
539
+ | `getUserKeyForGame` | Returns `{ hash, type: 'HASH' }` (or `undefined` when not logged in) |
540
+ | `appsInTossSignTossCert` | Console log only (no-op) |
541
+
542
+ ### Screen / navigation
543
+
544
+ | API | Mock behavior |
545
+ |---|---|
546
+ | `closeView` | Calls `window.history.back()` |
547
+ | `openURL` | Opens in a new tab via `window.open()` |
548
+ | `share` | Uses `navigator.share()` (falls back to console log if unsupported) |
549
+ | `getTossShareLink` | Returns `https://toss.im/share/mock{path}` |
550
+ | `setIosSwipeGestureEnabled` | Console log (no-op) |
551
+ | `setDeviceOrientation` | Console log (no-op) |
552
+ | `setScreenAwakeMode` | Returns `{ enabled }` |
553
+ | `setSecureScreen` | Returns `{ enabled }` |
554
+ | `requestReview` | No-op (includes `.isSupported()` method) |
555
+
556
+ ### Environment info
557
+
558
+ | API | Mock behavior |
559
+ |---|---|
560
+ | `getPlatformOS` | Returns state's platform (default: `'ios'`) |
561
+ | `getOperationalEnvironment` | Returns state's environment (default: `'sandbox'`) |
562
+ | `getTossAppVersion` | Returns state's appVersion (default: `'5.240.0'`) |
563
+ | `isMinVersionSupported` | Performs a semantic version comparison |
564
+ | `getSchemeUri` | Returns state's schemeUri or `window.location.pathname` |
565
+ | `getLocale` | Returns state's locale (default: `'ko-KR'`) |
566
+ | `getDeviceId` | Returns a persistent unique UUID stored in localStorage |
567
+ | `getGroupId` | Returns state's groupId |
568
+ | `getNetworkStatus` | Uses state or browser API depending on mode |
569
+ | `getServerTime` | Returns `Date.now()` |
570
+ | `env.getDeploymentId` | Returns state's deploymentId |
571
+ | `getAppsInTossGlobals` | Returns `{ deploymentId, brandDisplayName, brandIcon, brandPrimaryColor }` |
572
+
573
+ ### Safe Area
574
+
575
+ | API | Mock behavior |
576
+ |---|---|
577
+ | `SafeAreaInsets.get` | Returns `{ top, bottom, left: 0, right: 0 }` |
578
+ | `SafeAreaInsets.subscribe` | Calls callback on state change, returns unsubscribe function |
579
+ | `getSafeAreaInsets` | Returns the top inset value (deprecated) |
580
+
581
+ ### Device features
582
+
583
+ | API | Mock behavior |
584
+ |---|---|
585
+ | `Storage.getItem/setItem/removeItem/clearItems` | Stored in localStorage with `__ait_storage:` prefix |
586
+ | `getCurrentLocation` | Per mode: mock (state coordinates), web (Geolocation API), prompt (Panel input) |
587
+ | `startUpdateLocation` | mock (random coordinate variation), web (watchPosition), prompt (repeated input) |
588
+ | `openCamera` | mock (dummy image), web (file picker), prompt (Panel file input) |
589
+ | `fetchAlbumPhotos` | mock (dummy image array), web (multi-file select), prompt (Panel file input) |
590
+ | `fetchContacts` | Returns paginated mock contacts, supports `query.contains` search |
591
+ | `getClipboardText` / `setClipboardText` | mock (state storage) or web (Clipboard API) |
592
+ | `generateHapticFeedback` | Console log + analytics record |
593
+ | `saveBase64Data` | File download via anchor element |
594
+
595
+ ### IAP / payments
596
+
597
+ | API | Mock behavior |
598
+ |---|---|
599
+ | `IAP.createOneTimePurchaseOrder` | Simulates success/failure after a 300ms delay based on state's `nextResult` |
600
+ | `IAP.createSubscriptionPurchaseOrder` | Same flow as above |
601
+ | `IAP.getProductItemList` | Returns state's product list |
602
+ | `IAP.getPendingOrders` | Returns pending order list |
603
+ | `IAP.getCompletedOrRefundedOrders` | Returns completed/refunded order list |
604
+ | `IAP.completeProductGrant` | Moves order from pending to completed |
605
+ | `IAP.getSubscriptionInfo` | Returns active subscription mock (30-day expiry, auto-renew) |
606
+ | `checkoutPayment` | Returns state's payment result after 300ms delay (TossPay) |
607
+
608
+ **IAP purchase simulation flow:**
609
+
610
+ 1. `IAP.createOneTimePurchaseOrder()` called
611
+ 2. 300ms delay (simulates payment UI)
612
+ 3. Check `state.iap.nextResult` → if not `'success'`, call `onError`
613
+ 4. On success, run the `processProductGrant` callback → on failure, return `'PRODUCT_NOT_GRANTED_BY_PARTNER'` error
614
+ 5. On full success, record in `completedOrders` and deliver order result via `onEvent`
615
+
616
+ ### Ads
617
+
618
+ | API | Mock behavior |
619
+ |---|---|
620
+ | `GoogleAdMob.loadAppsInTossAdMob` | Emits a `loaded` event after 200ms |
621
+ | `GoogleAdMob.showAppsInTossAdMob` | Sequentially emits requested→show→impression→reward→dismissed events over 50ms–1.5s |
622
+ | `GoogleAdMob.isAppsInTossAdMobLoaded` | Returns boolean loaded state |
623
+ | `TossAds.initialize/attach/attachBanner` | Renders a gray placeholder div |
624
+ | `TossAds.destroy/destroyAll` | No-op |
625
+ | `loadFullScreenAd` / `showFullScreenAd` | Similar flow to GoogleAdMob |
626
+
627
+ ### Events
628
+
629
+ | API | Mock behavior |
630
+ |---|---|
631
+ | `graniteEvent.addEventListener` | Listens for `__ait:backEvent` and `__ait:homeEvent` custom events |
632
+ | `appsInTossEvent.addEventListener` | No-op |
633
+ | `tdsEvent.addEventListener` | Listens for `__ait:navigationAccessoryEvent` |
634
+ | `onVisibilityChangedByTransparentServiceWeb` | Delegates to `document.visibilitychange` event |
635
+
636
+ ### Analytics
637
+
638
+ | API | Mock behavior |
639
+ |---|---|
640
+ | `Analytics.screen/impression/click` | Records by type in analyticsLog, viewable in the Panel in real time |
641
+ | `eventLog` | Records custom events by `log_name`, `log_type`, and `params` |
642
+
643
+ ### Game / promotions
644
+
645
+ | API | Mock behavior |
646
+ |---|---|
647
+ | `grantPromotionReward` | Returns a timestamp-based mock key |
648
+ | `grantPromotionRewardForGame` | Same as above |
649
+ | `submitGameCenterLeaderBoardScore` | Appends score to state, returns `{ statusCode: 'SUCCESS' }` |
650
+ | `getGameCenterGameProfile` | Returns mock profile (or `PROFILE_NOT_FOUND` if absent) |
651
+ | `openGameCenterLeaderboard` | Console log (no-op) |
652
+ | `contactsViral` | Emits a close event after 500ms |
653
+
654
+ ### Permissions
655
+
656
+ | API | Mock behavior |
657
+ |---|---|
658
+ | `getPermission` | Returns state's permission status (allowed/denied/notDetermined) |
659
+ | `openPermissionDialog` | Changes status to `allowed` |
660
+ | `requestPermission` | Delegates to `openPermissionDialog` |
661
+
662
+ > Functions that require permissions (openCamera, getCurrentLocation, etc.) are wrapped with `withPermission()`, which automatically attaches `.getPermission()` and `.openPermissionDialog()` methods.
663
+
664
+ ### Partner
665
+
666
+ | API | Mock behavior |
667
+ |---|---|
668
+ | `partner.addAccessoryButton` | Console log (no-op) |
669
+ | `partner.removeAccessoryButton` | Console log (no-op) |
670
+
671
+ ## Using in tests
672
+
673
+ You can import the mock library directly in vitest/jest.
674
+
675
+ > The mock functions use browser APIs such as `window`, `document`, and `localStorage`, so a **jsdom environment** is required.
676
+ >
677
+ > ```ts
678
+ > // vitest.config.ts
679
+ > import { defineConfig } from 'vitest/config';
680
+ > export default defineConfig({ test: { environment: 'jsdom' } });
681
+ > ```
682
+
683
+ ```ts
684
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
685
+ import { appLogin, Storage, getCurrentLocation, getNetworkStatus, openCamera, IAP } from '@ait-co/devtools/mock';
686
+ import { aitState } from '@ait-co/devtools/mock';
687
+
688
+ beforeEach(() => {
689
+ aitState.reset(); // reset state before each test
690
+ });
691
+
692
+ // Auth test
693
+ it('appLogin returns an authorizationCode', async () => {
694
+ const result = await appLogin();
695
+ expect(result.authorizationCode).toBeDefined();
696
+ });
697
+
698
+ // Set state then call function
699
+ it('network status query when offline', async () => {
700
+ aitState.update({ networkStatus: 'OFFLINE' });
701
+ const status = await getNetworkStatus();
702
+ expect(status).toBe('OFFLINE');
703
+ });
704
+
705
+ // Permission denied scenario
706
+ it('throws when camera permission is denied', async () => {
707
+ aitState.patch('permissions', { camera: 'denied' });
708
+ await expect(openCamera()).rejects.toThrow();
709
+ });
710
+
711
+ // IAP failure scenario (requires fake timers)
712
+ it('calls onError when purchase is canceled', async () => {
713
+ vi.useFakeTimers();
714
+ aitState.patch('iap', { nextResult: 'USER_CANCELED' });
715
+ const onError = vi.fn();
716
+ IAP.createOneTimePurchaseOrder({
717
+ options: { sku: 'item_01', processProductGrant: async () => true },
718
+ onEvent: vi.fn(),
719
+ onError,
720
+ });
721
+ await vi.advanceTimersByTimeAsync(500);
722
+ expect(onError).toHaveBeenCalledWith({ code: 'USER_CANCELED' });
723
+ vi.useRealTimers();
724
+ });
725
+
726
+ // Storage test
727
+ it('can write and read from Storage', async () => {
728
+ await Storage.setItem('key1', 'value1');
729
+ const result = await Storage.getItem('key1');
730
+ expect(result).toBe('value1');
731
+ });
732
+ ```
733
+
734
+ ## SDK update tracking
735
+
736
+ devtools tracks [`@apps-in-toss/web-framework`](https://www.npmjs.com/package/@apps-in-toss/web-framework), and [`sdk-example`](https://github.com/apps-in-toss-community/sdk-example) tracks both the original SDK and devtools. When a new SDK version is released, the flow is: (1) devtools catches up on mock/type signatures → (2) sdk-example incorporates both new versions together. If a devtools-only PR breaks sdk-example, both are addressed together.
737
+
738
+ Three mechanisms keep the SDK changes safely tracked:
739
+
740
+ ### 1. Compile-time type verification (`__typecheck.ts`)
741
+
742
+ `src/__typecheck.ts` verifies that the major exports from the mock are type-compatible with the original SDK. If the SDK signature changes, `pnpm typecheck` will immediately produce an error.
743
+
744
+ ```ts
745
+ type Assert<TMock, TOriginal> = TMock extends TOriginal ? true : never;
746
+ type _AppLogin = Assert<typeof Mock.appLogin, typeof Original.appLogin>;
747
+ // 40+ type compatibility assertions
748
+ ```
749
+
750
+ ### 2. Proxy tripwire (runtime blocking)
751
+
752
+ `createMockProxy()` immediately throws an `Error` when an unimplemented API is accessed. This is intentional — to prevent "works in devtools but fails with the real SDK" production incidents caused by APIs that exist in the real SDK but haven't been mocked yet. Please [file an issue](https://github.com/apps-in-toss-community/devtools/issues) or add the mock yourself.
753
+
754
+ ```
755
+ [@ait-co/devtools] IAP.newMethod is not mocked. This API may exist in
756
+ @apps-in-toss/web-framework, but devtools' mock does not cover it yet.
757
+ Please file an issue: https://github.com/apps-in-toss-community/devtools/issues
758
+ ```
759
+
760
+ ### 3. Weekly GitHub Actions CI
761
+
762
+ `.github/workflows/check-sdk-update.yml` automatically runs **every Monday**:
763
+
764
+ 1. Checks for a new version of `@apps-in-toss/web-framework`
765
+ 2. Updates to the latest version and runs the type check
766
+ 3. On detecting a new version, automatically opens a GitHub Issue (including whether there are type errors)
767
+
768
+ ## Contributing
769
+
770
+ ### Adding a new API mock
771
+
772
+ 1. Implement the function in the appropriate category directory (e.g. `src/mock/device/`)
773
+ 2. Add the export to `src/mock/index.ts`
774
+ 3. Add a type compatibility assertion to `src/__typecheck.ts`
775
+ 4. Run `pnpm typecheck` to verify compatibility with the original
776
+ 5. Write tests in `src/__tests__/`
777
+
778
+ ```bash
779
+ pnpm build # build with tsup
780
+ pnpm typecheck # verify type compatibility
781
+ pnpm test # run all tests
782
+ ```
783
+
784
+ ### Pre-commit hook (optional)
785
+
786
+ Optional but recommended. After cloning, activate the standard pre-commit hook with the command below. It runs `biome check` automatically on staged files.
787
+
788
+ ```sh
789
+ git config core.hooksPath .githooks
790
+ ```
791
+
792
+ This hook is a developer convenience for catching lint issues before push. The actual enforcement layer is the CI `pnpm lint` job, so contributors who don't activate the hook will still see lint failures in their PR.
793
+
794
+ ## Troubleshooting
795
+
796
+ ### `[@ait-co/devtools] XXX.method is not mocked` error
797
+
798
+ The SDK API you're calling has not been implemented in the mock yet. devtools throws on unimplemented API access to prevent "works fine" deployments. [File an issue](https://github.com/apps-in-toss-community/devtools/issues) or add the mock yourself and try again.
799
+
800
+ ### DevTools Panel not appearing
801
+
802
+ - Check that you haven't set `panel: false` in your plugin options
803
+ - If you're using manual alias setup, add a direct import to your entry point:
804
+ ```ts
805
+ import '@ait-co/devtools/panel';
806
+ ```
807
+ - The plugin auto-injects only into entry points whose filename is `main`, `index`, `entry`, or `app` (case-insensitive). If your filename doesn't match that pattern, add `import '@ait-co/devtools/panel'` manually.
808
+
809
+ ### Subpath imports are not mocked
810
+
811
+ Subpath imports of the form `@apps-in-toss/web-framework/some-subpath` are not aliased. Only the main entry (`@apps-in-toss/web-framework`) is mocked. If you need a specific subpath mocked as well, add it manually to your bundler's `resolve.alias`.
812
+
813
+ ### Setting up with Next.js Turbopack
814
+
815
+ Since Turbopack doesn't support unplugin, use `resolveAlias` in `next.config.js` (see the [Next.js (Turbopack)](#nextjs-turbopack) section above). Import the Panel directly from your entry point:
816
+
817
+ ```ts
818
+ // app/layout.tsx or pages/_app.tsx
819
+ import '@ait-co/devtools/panel';
820
+ ```
821
+
822
+ ## Package export structure
823
+
824
+ | Import path | Purpose |
825
+ |---|---|
826
+ | `@ait-co/devtools` or `@ait-co/devtools/mock` | All mock exports (bundler alias target) |
827
+ | `@ait-co/devtools/panel` | Floating DevTools Panel (auto-mounts on import) |
828
+ | `@ait-co/devtools/unplugin` | Bundler plugin (.vite, .webpack, .rspack, .esbuild, .rollup) |
829
+
830
+ ## Telemetry
831
+
832
+ devtools uses a two-tier telemetry model.
833
+
834
+ ### Tier 0 — anonymous usage signal (ON by default, opt-out)
835
+
836
+ Sends a one-time anonymous ping per calendar day when the panel is opened.
837
+
838
+ Collected fields: `source`, `version`, `ts` — no PII, no `anon_id`. The server generates an IP+UA daily hash but never stores it.
839
+
840
+ How to opt out:
841
+ - Panel Environment tab → "Anonymous usage signal (Tier 0)" toggle OFF
842
+ - `localStorage.setItem('__ait_telemetry:t0_off', '1')` (from the browser console)
843
+ - Environment variable: `AITC_TELEMETRY=off`
844
+
845
+ ### Tier 1 — extended telemetry (OFF by default, opt-in)
846
+
847
+ A consent toast appears on first panel use. Data is only collected if you accept.
848
+
849
+ Collected fields: `panel_open`, `tab_view`, `session_duration` events + an anonymous UUID (`anon_id`).
850
+
851
+ How to opt out:
852
+ - Panel Environment tab → "Extended telemetry (Tier 1)" toggle OFF
853
+ - Delete collected data: Panel Environment tab → "Delete my data"
854
+
855
+ Privacy policy: <https://docs.aitc.dev/privacy>
856
+
857
+ ## License
858
+
859
+ BSD 3-Clause
860
+
861
+ ---
862
+
863
+ Community open-source project.