{"id":49751314,"url":"https://github.com/apps-in-toss-community/devtools","last_synced_at":"2026-06-14T06:01:26.281Z","repository":{"id":349637189,"uuid":"1203228238","full_name":"apps-in-toss-community/devtools","owner":"apps-in-toss-community","description":"Development tools for Apps in Toss (앱인토스) mini-apps — mock SDK, floating devtools panel, and universal bundler plugin","archived":false,"fork":false,"pushed_at":"2026-06-09T07:42:21.000Z","size":6082,"stargazers_count":1,"open_issues_count":9,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-09T08:26:02.954Z","etag":null,"topics":["apps-in-toss","devtools","mini-app","mock","sdk","toss","typescript","unplugin"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/ait-devtools","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"bsd-3-clause","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/apps-in-toss-community.png","metadata":{"files":{"readme":"README.en.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-06T21:06:19.000Z","updated_at":"2026-06-09T07:39:31.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/apps-in-toss-community/devtools","commit_stats":null,"previous_names":["davedev42/ait-devtools","apps-in-toss-community/devtools"],"tags_count":54,"template":false,"template_full_name":null,"purl":"pkg:github/apps-in-toss-community/devtools","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apps-in-toss-community%2Fdevtools","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apps-in-toss-community%2Fdevtools/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apps-in-toss-community%2Fdevtools/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apps-in-toss-community%2Fdevtools/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/apps-in-toss-community","download_url":"https://codeload.github.com/apps-in-toss-community/devtools/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apps-in-toss-community%2Fdevtools/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34136152,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-10T02:00:07.152Z","response_time":89,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["apps-in-toss","devtools","mini-app","mock","sdk","toss","typescript","unplugin"],"created_at":"2026-05-10T10:56:54.261Z","updated_at":"2026-06-14T06:01:26.266Z","avatar_url":"https://github.com/apps-in-toss-community.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @ait-co/devtools\n\n[한국어](./README.md) · **English**\n\n[![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)\n\n![@ait-co/devtools — SDK mock + DevTools panel for Apps In Toss mini-apps](./assets/og/image.png)\n\nA mock library for the `@apps-in-toss/web-framework` SDK. Imports of `@apps-in-toss/webview-bridge` are intercepted by the unplugin too (only the high-level SDK functions are exposed — bridge primitives are not). (2.x packages `@apps-in-toss/web-bridge` and `@apps-in-toss/web-analytics` are supported for back-compat.)\n\nLets 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.\n\n- **60+ SDK API mocks** — auth, payments, IAP, location, camera, storage, and more\n- **Device API mode system** — switch between mock / web / prompt modes for device APIs\n- **Device simulation** — iPhone/Galaxy presets + orientation toggle to simulate a mobile viewport in your desktop browser\n- **Floating DevTools Panel** — control SDK state in real time from the browser (12 tabs, mock state preset library included)\n- **All bundlers supported** — [unplugin](https://github.com/unjs/unplugin)-based Vite, Webpack, Rspack, esbuild, and Rollup integration\n\nLive demo: \u003chttps://devtools.aitc.dev/\u003e (the `e2e/fixture/` from this repo deployed to GitHub Pages as a self-contained demo).\n\n## 15-second quickstart — pick your environment\n\nThere are four runtime environments. Pick the card that fits your situation and follow the link to the detailed scenario doc.\n\n---\n\n**Environment 1 — Local browser** (fastest, HMR on)\n\nDevelop with the mock SDK + DevTools panel in desktop Chrome. No Toss app or phone needed.\n\n```bash\npnpm add -D @ait-co/devtools\n# add the unplugin to vite.config.ts → pnpm dev\n```\n\nDevTools panel: click the **AIT** button in the bottom-right corner. Details: [`docs/scenarios/env-1.md`](./docs/scenarios/env-1.md)\n\n---\n\n**Environment 2 — Real-device PWA** (real WebKit engine, HMR on, no Toss review required)\n\nPreview your mini-app on a real phone using Safari/WebKit. Install the launcher PWA once, then scan a QR code each session.\n\n```bash\n# add the tunnel option to vite.config.ts, then:\npnpm dev:phone          # same as AIT_TUNNEL=1 pnpm dev\n# QR appears in the terminal → scan with your phone camera → opens in the launcher PWA\n```\n\nWith `tunnel: { cdp: true }`, a single QR scan opens both the screen preview and on-device CDP — inspect the real WebKit DOM, console, and exceptions from your MCP host (`call_sdk` still hits the mock on environment 2; the real SDK lives on environments 3·4).\n\nOne-time prerequisite: add `https://devtools.aitc.dev/launcher/` to your phone's home screen. Details: [`docs/scenarios/env-2.md`](./docs/scenarios/env-2.md)\n\n---\n\n**Environment 3 — intoss-private** (Toss WebView, HMR off, debug only)\n\nLoad a dogfood bundle in the real Toss app WebView and debug it via the MCP relay.\n\n```bash\ndevtools-mcp              # start MCP server → QR printed in terminal\n# ait build \u0026\u0026 ait deploy --scheme-only\n# call build_attach_url → scan QR → Toss app loads bundle + relay attaches\n```\n\nNo HMR (Toss WebView cold-load only). Details: [`docs/scenarios/env-3.md`](./docs/scenarios/env-3.md)\n\n---\n\n**Environment 4 — Live deployed app** (passed review, HMR off, read-only debug)\n\nAttach a relay to a live OPENED app to observe runtime behavior.\n\n```bash\ndevtools-mcp   # start MCP server\n# In Claude Code: start_debug({mode: 'relay-live', confirm: true})  ← arms LIVE guard\n# call build_attach_url → scan QR → live app loads + relay attaches\n# call_sdk / evaluate: confirm: true required (LIVE guard — real users affected)\n```\n\n`start_debug({mode: 'relay-live', confirm: true})` arms the LIVE guard in-session. Details: [`docs/scenarios/env-4.md`](./docs/scenarios/env-4.md)\n\n---\n\n## On-device debugging in one line\n\nTo enable on-device CDP debugging in environments 2, 3, and 4, add **one line** to your mini-app entry (`main.tsx` or equivalent):\n\n```ts\n// main.tsx (or the top of your mini-app entry)\nimport '@ait-co/devtools/in-app/auto';\n```\n\nWhat this single line does:\n\n- **Self-gate**: if neither `?debug=1` nor `?relay=` is in the URL, and it is not a DEV build, the entry does nothing. The chunk stays dormant and has no impact on a normal production load.\n- **Attach**: when the gate passes, calls `maybeAttach()` to inject the Chii `target.js` script (Layer B/C gate semantics are fully preserved).\n- **SDK bridge**: installs `window.__sdk` / `window.__sdkCall` so an agent can drive any SDK API directly over the CDP relay via `Runtime.evaluate`. Silently skipped if `@apps-in-toss/web-framework` is not available.\n- **Types**: provides `Window.__sdk` / `__sdkCall` global type declarations automatically — no separate `globals.d.ts` needed in your project.\n\nFor environments 3 and 4 (intoss-private relay), the relay QR deep-link carries `?debug=1\u0026relay=\u003cwss\u003e` query params, so this one line is all the wiring you need. Environment 2 (PWA, `tunnel: { cdp: true }`) works the same way.\n\n\u003e For dogfood builds with TOTP authentication, inject `__DEBUG_TOTP_SECRET__` via your build define and use `@ait-co/devtools/in-app` directly with `evaluateDebugGate({ verifyTotpCode })` + `maybeAttach()`. `in-app/auto` does not inject a TOTP verifier, so Layer C3 is disabled.\n\n## Five common problems\n\n**\"QR window doesn't open\"**\n\nEither `build_attach_url` wasn't called first, or the MCP server is running in a headless environment where no browser can be opened. The tool result always includes a text QR — scan it directly with your phone camera. On a local GUI machine, the dashboard opens automatically in the browser.\n\n**\"Page not attached\" — list_pages returns an empty array**\n\nNo page has joined the relay yet. Re-enter via `build_attach_url` → QR scan on your phone. When the MCP error message reads \"page not attached — run build_attach_url then scan QR\", this is the case.\n\n**\"Tunnel down\" — no response or timeout**\n\nA cloudflared quick tunnel can drop after a few hours. Restart the `devtools-mcp` process to get a new tunnel URL, then scan the new QR. (Related: [#290](https://github.com/apps-in-toss-community/devtools/issues/290))\n\n**\"Page crash\" — list_pages shows a non-null crashDetectedAt**\n\nThe page on the phone died (OOM, JS exception, or native bridge crash). Relaunch the app, then re-attach via `build_attach_url` → QR scan. (Related: [#265](https://github.com/apps-in-toss-community/devtools/issues/265))\n\n**\"SDK not available\" — window.__sdkCall not injected**\n\nWhen `call_sdk` returns `ok: false, error: \"window.__sdkCall is not available\"`, the SDK bridge has not been installed. Check that `import '@ait-co/devtools/in-app/auto'` is present at the top of your mini-app entry — see the \"On-device debugging in one line\" section above. This error is the expected result in environment 2 (PWA). (Related: [#285](https://github.com/apps-in-toss-community/devtools/issues/285))\n\n**\"QR scanned but auth rejected\" — TOTP code expired**\n\nWhen `AIT_DEBUG_TOTP_SECRET` is set, `build_attach_url` automatically splices the current one-time TOTP code (`at=`) into the returned `attachUrl`. Each code covers a 30-second step, and the relay accepts ±6 steps (~3 min) of backwards skew. Scanning more than ~3 minutes after `totp.expiresAt` causes the relay to reject the request. Fix: call `build_attach_url` again to get a fresh URL and QR.\n\n---\n\n## Install\n\n```bash\nnpm install -D @ait-co/devtools\n# or\npnpm add -D @ait-co/devtools\n```\n\n### Two channels — stable and beta\n\ndevtools runs two npm dist-tags off the same code at once. Pick the channel that matches your web-framework version.\n\n| Channel | Install | web-framework peer |\n|---|---|---|\n| **stable** (`latest`, default) | `pnpm add -D @ait-co/devtools` | `\u003e=2.6.0 \u003c2.7.0` (2.x) |\n| **beta** | `pnpm add -D @ait-co/devtools@beta` | `\u003e=3.0.0-beta \u003c4.0.0` (3.0 line) |\n\n- On web-framework **2.x**, the default install (stable) is all you need.\n- On the web-framework **3.0.0-beta** pre-release, install the `@beta` channel. It is a snapshot auto-published on every main push (`0.0.0-beta-\u003cdatetime\u003e-\u003csha\u003e`), so the versions are hard to pin — install with the `@beta` tag.\n- Both channels keep the web-framework peer `optional`, so MCP-only debugging users are never forced to pull the SDK.\n\nWhen 3.0 ships GA, the stable `latest` peer moves up to the 3.0 line and the beta channel is retired. Calling an API that devtools has not yet mocked will throw a runtime error — please [file an issue](https://github.com/apps-in-toss-community/devtools/issues) for missing APIs.\n\n## Reference consumer\n\n[`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 \u003chttps://sdk-example.aitc.dev/\u003e. 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.\n\n## Bundler setup\n\n### Vite\n\n```ts\n// vite.config.ts (development only)\nimport aitDevtools from '@ait-co/devtools/unplugin';\n\nexport default {\n  plugins: [aitDevtools.vite()],\n};\n```\n\n\u003e This is a development-only setup. To exclude it from production builds, see the [Production builds](#production-builds) section below.\n\n### Webpack / Rspack\n\n```js\n// webpack.config.js (ESM, recommended for development only)\nimport aitDevtools from '@ait-co/devtools/unplugin';\nconfig.plugins.push(aitDevtools.webpack());\n\n// webpack.config.js (CommonJS)\nconst aitDevtools = require('@ait-co/devtools/unplugin');\nconfig.plugins.push(aitDevtools.webpack());\n```\n\n### Next.js (Turbopack)\n\nTurbopack does not support a plugin system, so use `resolveAlias` instead.\n\n- Aliasing `@apps-in-toss/web-framework` alone is enough. Every SDK call goes through this package, so replacing it with the mock drops the whole web-framework module from the graph, and its internal `@apps-in-toss/webview-bridge` imports disappear with it.\n- Turbopack is generally only used with `next dev`, so no extra production guard is needed.\n\n```js\n// next.config.js (Next.js 15+, web-framework 3.0+)\nmodule.exports = {\n  turbo: {\n    resolveAlias: {\n      '@apps-in-toss/web-framework': '@ait-co/devtools/mock',\n    },\n  },\n};\n```\n\nFor Next.js 14 and below, use `experimental.turbo`:\n\n```js\n// next.config.js (Next.js 14 and below, web-framework 3.0+)\nmodule.exports = {\n  experimental: {\n    turbo: {\n      resolveAlias: {\n        '@apps-in-toss/web-framework': '@ait-co/devtools/mock',\n      },\n    },\n  },\n};\n```\n\n\u003e **Panel injection**: Turbopack does not support unplugin, so the Panel is not auto-injected. Import it directly from your entry point:\n\u003e ```ts\n\u003e // app/layout.tsx or pages/_app.tsx\n\u003e import '@ait-co/devtools/panel';\n\u003e ```\n\n### Next.js (Webpack)\n\nWhen using Webpack mode in Next.js (`next dev` without `--turbo`, or `next build`):\n\n```js\n// next.config.js (Webpack mode)\nconst aitDevtools = require('@ait-co/devtools/unplugin'); // CJS entrypoint provided\n\nmodule.exports = {\n  webpack: (config, { dev }) =\u003e {\n    if (dev) {\n      config.plugins.push(aitDevtools.webpack());\n    }\n    return config;\n  },\n};\n```\n\n### Manual alias setup\n\nYou can also configure the bundler's `resolve.alias` directly:\n\n```ts\n// vite.config.ts (web-framework 3.0+)\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  resolve: {\n    alias: {\n      '@apps-in-toss/web-framework': '@ait-co/devtools/mock',\n    },\n  },\n});\n```\n\n```js\n// webpack.config.js (Webpack requires absolute paths, web-framework 3.0+)\nmodule.exports = {\n  resolve: {\n    alias: {\n      '@apps-in-toss/web-framework': require.resolve('@ait-co/devtools/mock'),\n    },\n  },\n};\n```\n\n\u003e **Note**: Using manual aliases alone will not auto-inject the DevTools Panel. Add a direct import to your entry point:\n\u003e ```ts\n\u003e import '@ait-co/devtools/panel'; // add to entry point\n\u003e ```\n\n### Plugin options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `panel` | `boolean` | `true` | Auto-inject the DevTools Panel |\n| `forceEnable` | `boolean` | `false` | Enable devtools even in production |\n| `mock` | `boolean` | `true` (dev) / `false` (prod+forceEnable) | Enable mock alias |\n| `mcp` | `boolean` | `false` | Add an MCP state endpoint to the Vite dev server (Vite only — see [MCP Server](#mcp-server)) |\n| `tunnel` | `boolean \\| { port?: number; qr?: boolean; cdp?: boolean }` | `false` | Expose the Vite dev server via a Cloudflare quick tunnel for real-device preview (see [below](#run-on-a-real-phone)). `cdp: true` also wires on-device CDP debugging for environment 2 (PWA). **Vite dev mode only** |\n\n```ts\naitDevtools.vite({ panel: false }); // mock only, no panel\naitDevtools.vite({ forceEnable: true }); // enable in production (mock OFF by default, panel ON)\naitDevtools.vite({ forceEnable: true, mock: true }); // enable mock in production too\naitDevtools.vite({ mcp: true }); // enable MCP endpoint for AI agents\naitDevtools.vite({ tunnel: true }); // expose dev server at *.trycloudflare.com\naitDevtools.vite({ tunnel: { cdp: true } }); // real-device preview + on-device CDP debugging\n```\n\n## Production builds\n\nBy 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.\n\nTo use devtools in a production build — for example in a staging environment — use the `forceEnable` option:\n\n```ts\naitDevtools.vite({ forceEnable: true }); // panel ON, mock OFF (monitoring only)\naitDevtools.vite({ forceEnable: true, mock: true }); // panel + mock both ON\n```\n\nYou can also conditionally exclude the plugin from your bundler config entirely:\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite';\nimport aitDevtools from '@ait-co/devtools/unplugin';\n\nexport default defineConfig(({ command }) =\u003e ({\n  plugins: [\n    ...(command === 'serve' ? [aitDevtools.vite()] : []),\n  ],\n}));\n```\n\n```js\n// webpack.config.js (same applies to Rspack)\nconst aitDevtools = require('@ait-co/devtools/unplugin');\nconst plugins = [];\nif (process.env.NODE_ENV !== 'production') {\n  plugins.push(aitDevtools.webpack());\n}\n```\n\n\u003e For Next.js, see the [Next.js (Webpack)](#nextjs-webpack) and [Next.js (Turbopack)](#nextjs-turbopack) sections above.\n\n## Run on a real phone\n\nWhen 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.\n\nSetup has three tiers:\n\n- **Once per project** — add the option to `vite.config`, add the pnpm setting to `package.json`, and optionally add a `dev:phone` script\n- **Once per phone** — add the launcher PWA to your home screen\n- **Each session** — one line: `pnpm dev:phone` (or `AIT_TUNNEL=1 pnpm dev`)\n\n### 1. Per-project setup\n\n(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:\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite';\nimport aitDevtools from '@ait-co/devtools/unplugin';\n\nexport default defineConfig({\n  plugins: [\n    aitDevtools.vite({\n      tunnel: !!process.env.AIT_TUNNEL, // OFF by default, ON when AIT_TUNNEL=1\n    }),\n  ],\n});\n```\n\n\u003e `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).\n\n\u003e To also enable on-device CDP debugging, pass the object form: `tunnel: process.env.AIT_TUNNEL ? { cdp: true } : false`. A Chii relay then starts alongside the HTTP tunnel, so a single QR scan opens both the screen preview and a CDP attach. Connect your AI host MCP to that relay to inspect the real WebKit DOM, console, exceptions, and `measure_safe_area` (`call_sdk` still hits the mock on environment 2).\n\n(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:\n\n```json\n{\n  \"pnpm\": {\n    \"onlyBuiltDependencies\": [\"cloudflared\"]\n  }\n}\n```\n\n\u003e 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).\n\n(c) **(Optional) `dev:phone` script** — to avoid typing the env variable each time:\n\n```json\n{\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"dev:phone\": \"AIT_TUNNEL=1 vite\"\n  }\n}\n```\n\n### 2. Per-phone setup (required)\n\nOpen `https://devtools.aitc.dev/launcher/` on your phone and **add it to your home screen**. The launcher shows an \"Install launcher to your phone\" button that triggers the platform-native install flow automatically — Android Chrome gets the in-app install prompt, iOS Safari gets a Share → Add to Home Screen illustration, and Firefox / Samsung Internet get a manual instruction card. The launcher URL never changes, so this is a one-time step per phone.\n\nThe launcher **only works when launched as an installed PWA from the home screen**. Opening it in a regular browser tab shows only the install hint — the URL input and scanner are hidden. The chrome-less standalone display is the whole point of the launcher shell, and a regular tab can't provide that.\n\n### 3. Each session\n\n1. 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.\n2. Scan the QR code with your phone's camera (or with the \"Scan QR\" button inside the launcher). The QR encodes a `https://devtools.aitc.dev/launcher/?url=\u003ctunnel\u003e` deep-link, so the launcher PWA opens and auto-enters the day's dev app full-screen — no paste step required.\n3. Next session, just scan the new QR. The launcher remembers the last URL and you can swap it any time with the \"Rescan\" button.\n\n\u003e Whether the OS camera routes the QR straight into the installed launcher PWA (instead of a regular browser tab) is most reliable on Android Chrome; iOS Safari versions may fall back to a normal tab. In that case, open the launcher from its home-screen icon and use its in-page \"Scan QR\" button.\n\n### Background\n\n\u003e **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 `\u003ciframe\u003e` inside it to show the day's dev app full-bleed.\n\u003e\n\u003e 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.)\n\u003e\n\u003e 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.\n\n### One-line setup (planned)\n\nThe 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.\n\n## Device API mode system\n\nDevice-related APIs (camera, location, clipboard, etc.) operate in three modes:\n\n| Mode | Behavior | Use case |\n|---|---|---|\n| **mock** | Returns dummy data stored in `aitState` | Automated tests, fixed scenarios |\n| **web** | Uses browser-native APIs (Geolocation, File API, etc.) | Testing with real device capabilities |\n| **prompt** | DevTools Panel opens automatically and waits for user input (30-second timeout) | Manual QA, entering specific values |\n\n### API support by mode\n\n| API | mock | web | prompt |\n|---|---|---|---|\n| `openCamera` | ✅ | ✅ | ✅ |\n| `fetchAlbumPhotos` | ✅ | ✅ | ✅ |\n| `getCurrentLocation` | ✅ | ✅ | ✅ |\n| `startUpdateLocation` | ✅ | ✅ | ✅ |\n| `getNetworkStatus` | ✅ | ✅ | — |\n| `getClipboardText` / `setClipboardText` | ✅ | ✅ | — |\n\n### Setting the mode\n\n```js\n// Change individual API modes from the console\n__ait.patch('deviceModes', { camera: 'web', location: 'prompt' });\n\n// Or use the dropdown in the Device tab of the DevTools Panel\n```\n\n### Managing dummy images\n\nCamera and album APIs return dummy images in mock mode.\n\n- **Default placeholders**: 3 auto-generated 320×240 images in blue, green, and orange\n- **Custom images**: Add or remove files from the Device tab in the DevTools Panel\n- **Set from console**: `__ait.patch('mockData', { images: ['data:image/png;base64,...'] })`\n\n## Floating DevTools Panel\n\nWhen 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.\n\n### 12 tabs\n\n| Tab | Description |\n|---|---|\n| **Environment** | Platform OS (ios/android), app version, environment (toss/sandbox), locale, network status, Safe Area Insets |\n| **Presets** | Apply/remove common QA scenarios (permission denied, offline, logged out, etc.) with one click. Save and delete user presets |\n| **Viewport** | Simulate a mobile viewport using device presets (iPhone/Galaxy) + orientation toggle |\n| **Permissions** | Control camera, photos, geolocation, clipboard, contacts, and microphone permission states (allowed/denied/notDetermined) |\n| **Notifications** | Choose the next result of the notification-consent flow (new agreement / already agreed / rejected) |\n| **Location** | Set latitude, longitude, and accuracy |\n| **Device** | Switch API modes (mock/web/prompt), manage dummy images (add/remove/reset to defaults) |\n| **IAP** | Choose the next purchase result (success/cancel/error, etc.), TossPay payment result, completed order history (last 5) |\n| **Ads** | Trigger full-screen ad load/show and view the last ad event log |\n| **Events** | Trigger Back/Home navigation events, toggle login state |\n| **Analytics** | Real-time log viewer for recorded analytics events (last 30 entries, with timestamp/type/parameters) |\n| **Storage** | View and clear items stored via the `Storage` API |\n\n\u003e **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.\n\n### Mock state preset library (Presets tab)\n\nWhen 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).\n\nBuilt-in presets:\n\n| ID | Meaning |\n|---|---|\n| `all-allowed` | All permissions allowed, WIFI, logged in, IAP success — return to baseline scenario |\n| `permission-denied` | camera / photos / geolocation / contacts denied |\n| `offline` | `getNetworkStatus` → OFFLINE, IAP `NETWORK_ERROR`, payment fail |\n| `logged-out` | `auth.isLoggedIn=false`. Validates the login flow |\n| `iap-pending` | IAP `nextResult` → `PAYMENT_PENDING` |\n| `ads-no-fill` | Triggers the ad fill failure branch |\n\nAny 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:\u003cid\u003e` 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.\n\nPresets are also exported from the package:\n\n```ts\nimport { applyPreset, builtInPresets, saveUserPreset } from '@ait-co/devtools';\n\n// Apply a built-in preset\nconst offline = builtInPresets.find((p) =\u003e p.id === 'offline')!;\napplyPreset(offline.state);\n\n// Save a custom preset\nsaveUserPreset('My QA scenario', {\n  networkStatus: 'OFFLINE',\n  permissions: { camera: 'denied' },\n  auth: { isLoggedIn: false },\n});\n```\n\n### Panel mount / dispose\n\nImporting `@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.\n\nIf you need to explicitly remove the panel in HMR or SPA routing scenarios, use `disposePanel()`:\n\n```ts\nimport { disposePanel, mount } from '@ait-co/devtools/panel';\n\ndisposePanel();  // Removes the toggle, panel, injected \u003cstyle\u003e, and all listeners.\n                  // Safe to call before mounting or to call twice.\nmount();          // Re-mount from a clean state. No duplicate \u003cstyle\u003e or listeners.\n```\n\n`disposeViewport()` is called internally as well, so any active viewport simulation is also reverted.\n\n## Device simulation (Viewport tab)\n\nWhen 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.\n\n### Presets (2026)\n\n| Category | Devices |\n|---|---|\n| Apple | iPhone SE (3rd gen), iPhone 16e, iPhone 17, iPhone Air, iPhone 17 Pro, iPhone 17 Pro Max |\n| Samsung | Galaxy S26, S26+, S26 Ultra, Z Flip7, Z Fold7 (folded / unfolded) |\n| Other | Custom (enter width/height manually), None (default) |\n\n\u003e **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.\n\u003e\n\u003e iPhone 17 series was released in September 2025 and is based on actual spec.\n\nEach preset includes:\n- **CSS viewport** (portrait `width × height`)\n- **DPR** (devicePixelRatio: 2, 3, 3.5, etc.)\n- **Notch** type (`none` / `notch` / `dynamic-island` / `punch-hole-center`)\n- **Notch inset** — the OS notch / Dynamic Island offset. Device-specific. In portrait this does *not* reach the mini-app's top inset (it's only used for the landscape side inset and to position the visual notch overlay).\n- **Nav bar height** — the Toss host's top nav bar. Device-independent (`54px` for a `partner` WebView). For a `partner` app this height *is* `SafeAreaInsets.get().top`.\n- **Home-indicator inset** — the bottom safe-area inset (home indicator), device-specific.\n\n### Orientation\n\n- **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.\n- **portrait / landscape** — The Panel overrides orientation. Calls to `setDeviceOrientation` from your app are ignored and logged with `console.warn`.\n\nWhen switching to landscape:\n- CSS viewport width and height are swapped.\n- 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.\n- For Android (punch-hole) presets, the status bar stays at the top.\n\n### Frame + notch + home indicator + Apps in Toss nav bar\n\nWhen **Show frame** is toggled on:\n- Border-radius + box-shadow to mimic the device bezel\n- Notch / Dynamic Island / punch-hole overlay — drawn in the status-bar area *above* the WebView (body), because on a real device the OS notch sits outside the WebView viewport (that's why `env(safe-area-inset-top)` is 0).\n- Home indicator pill (only on devices with `safeAreaBottom \u003e 0`, positioned at the bottom of body)\n- App name uses `aitState.brand.displayName` (editable in the Environment tab, auto-updates)\n- The back button triggers `__ait:backEvent` and the X button calls `closeView()` — you can verify actual SDK event plumbing directly from the panel\n\nWhen **Show Apps in Toss nav bar** is toggled on (default on):\n- A 54px nav bar overlay simulating the Toss host's top nav bar. Its shape depends on `Nav bar type`:\n  - `partner` (default for non-games): white background + back / app icon+name / ⋯ / ×. Pushes content down by the nav bar height.\n  - `game`: transparent background, ⋯ / × only. Floats over the game canvas without pushing content — an in-game screen is full-screen per the [launch checklist](https://developers-apps-in-toss.toss.im/checklist/app-game.html).\n- The nav bar sits at the **top (0)** of the WebView (body) coordinate space. On a real device the OS notch is outside the WebView (in the status bar above), so `env(safe-area-inset-top)` is 0 and content starts right below the nav bar (= `SafeAreaInsets.get().top`) — the simulator reproduces this stack (notch status bar → nav bar → content).\n- For a `partner` WebView this nav bar height **is** `SafeAreaInsets.get().top`. Relay measurement of an iPhone 15 Pro (sandbox, portrait) showed `env(safe-area-inset-top)` = 0 (the OS notch stays outside the WebView viewport) and `SafeAreaInsets.get().top` = 54 px — i.e. the SDK top inset reports the host nav bar, not the notch. So a `partner` app lays out using `insets.top` alone. A `game` WebView is a transparent overlay that does not push content (top 0). Measured on iOS `partner`; Android values are provisional and `external` is not simulated.\n\n### Console manipulation\n\n```js\n// iPhone 17 Pro portrait + frame on\n__ait.patch('viewport', { preset: 'iphone-17-pro', orientation: 'auto', frame: true });\n\n// Force landscape (app's setDeviceOrientation calls are ignored)\n__ait.patch('viewport', { orientation: 'landscape' });\n\n// Notch side in landscape (iOS default 'left')\n__ait.patch('viewport', { landscapeSide: 'right' });\n\n// Custom size (automatically clamped to 1–4096)\n__ait.patch('viewport', { preset: 'custom', customWidth: 360, customHeight: 740 });\n\n// Hide the Apps in Toss nav bar (to inspect the pure viewport)\n__ait.patch('viewport', { aitNavBar: false });\n\n// Toggle nav bar variant ('partner' = white background + icon/name, 'game' = transparent + ⋯/× only)\n__ait.patch('viewport', { aitNavBarType: 'game' });\n\n// Reset\n__ait.patch('viewport', { preset: 'none' });\n```\n\n### Status panel\n\nThe bottom of the Viewport tab shows the currently applied values in real time:\n- **CSS / physical**: `402×874@3x | 1206×2622 portrait (auto)`\n- **Safe area**: `T54 R0 B34 L0` (portrait `partner` — top is the nav bar height, not the notch)\n- **AIT nav bar**: `54px → SafeArea top · partner`\n\n### Persistence + technical details\n\n- State is saved to sessionStorage (`__ait_viewport`) and restored on page reload.\n- Selecting a preset also updates `aitState.safeAreaInsets` → the SDK's `SafeAreaInsets.get()` / `.subscribe()` follow along.\n- 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.\n- `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).\n- If you need to remove the viewport simulation programmatically, `disposeViewport()` is available as an export.\n- User-Agent spoofing / touch event emulation / network throttling are not done (Chrome DevTools already provides these).\n\n### Known limitations\n\n- **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.\n- **Estimated safe area** — 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.\n\n## `window.__ait` console API\n\nYou can control mock state directly from the browser console via `window.__ait` (or just `__ait`):\n\n```js\n// Read current state\n__ait.state                    // full state object\n__ait.state.platform           // 'ios' or 'android'\n__ait.state.auth.isLoggedIn    // login state\n__ait.state.deviceModes        // current mode for each API\n\n// Update state (shallow merge)\n__ait.update({ platform: 'android', locale: 'en-US' });\n__ait.update({ networkStatus: 'OFFLINE' });\n\n// Update nested state\n__ait.patch('permissions', { camera: 'denied' });\n__ait.patch('deviceModes', { location: 'web' });\n__ait.patch('iap', { nextResult: 'USER_CANCELED' });\n\n// Trigger events\n__ait.trigger('backEvent');\n__ait.trigger('homeEvent');\n\n// Log an analytics event manually\n__ait.logAnalytics({ type: 'click', params: { button: 'purchase' } });\n\n// Reset state (deviceId is preserved)\n__ait.reset();\n\n// Subscribe to state changes\nconst unsubscribe = __ait.subscribe(() =\u003e {\n  console.log('state changed:', __ait.state);\n});\nunsubscribe(); // unsubscribe\n```\n\n## Mock API reference\n\n### Auth / login\n\n| API | Mock behavior |\n|---|---|\n| `appLogin` | Returns `{ authorizationCode, referrer }` |\n| `getIsTossLoginIntegratedService` | Returns state's `isTossLoginIntegrated` |\n| `getUserKeyForGame` | Returns `{ hash, type: 'HASH' }` (or `undefined` when not logged in) |\n| `appsInTossSignTossCert` | Console log only (no-op) |\n\n### Screen / navigation\n\n| API | Mock behavior |\n|---|---|\n| `closeView` | Calls `window.history.back()` |\n| `openURL` | Opens in a new tab via `window.open()` |\n| `share` | Uses `navigator.share()` (falls back to console log if unsupported) |\n| `getTossShareLink` | Returns `https://toss.im/share/mock{path}` |\n| `setIosSwipeGestureEnabled` | Console log (no-op) |\n| `setDeviceOrientation` | Console log (no-op) |\n| `setScreenAwakeMode` | Returns `{ enabled }` |\n| `setSecureScreen` | Returns `{ enabled }` |\n| `requestReview` | No-op (includes `.isSupported()` method) |\n\n### Environment info\n\n| API | Mock behavior |\n|---|---|\n| `getPlatformOS` | Returns state's platform (default: `'ios'`) |\n| `getOperationalEnvironment` | Returns state's environment (default: `'sandbox'`) |\n| `getTossAppVersion` | Returns state's appVersion (default: `'5.240.0'`) |\n| `isMinVersionSupported` | Performs a semantic version comparison |\n| `getSchemeUri` | Returns state's schemeUri or `window.location.pathname` |\n| `getLocale` | Returns state's locale (default: `'ko-KR'`) |\n| `getDeviceId` | Returns a persistent unique UUID stored in localStorage |\n| `getGroupId` | Returns state's groupId |\n| `getNetworkStatus` | Uses state or browser API depending on mode |\n| `getServerTime` | Returns `Date.now()` |\n| `env.getDeploymentId` | Returns state's deploymentId |\n| `getAppsInTossGlobals` | Returns `{ deploymentId, brandDisplayName, brandIcon, brandPrimaryColor }` |\n\n### Safe Area\n\n| API | Mock behavior |\n|---|---|\n| `SafeAreaInsets.get` | Returns `{ top, bottom, left: 0, right: 0 }` |\n| `SafeAreaInsets.subscribe` | Calls callback on state change, returns unsubscribe function |\n| `getSafeAreaInsets` | Returns the top inset value (deprecated) |\n\n### Device features\n\n| API | Mock behavior |\n|---|---|\n| `Storage.getItem/setItem/removeItem/clearItems` | Stored in localStorage with `__ait_storage:` prefix |\n| `getCurrentLocation` | Per mode: mock (state coordinates), web (Geolocation API), prompt (Panel input) |\n| `startUpdateLocation` | mock (random coordinate variation), web (watchPosition), prompt (repeated input) |\n| `openCamera` | mock (dummy image), web (file picker), prompt (Panel file input) |\n| `fetchAlbumPhotos` | mock (dummy image array), web (multi-file select), prompt (Panel file input) |\n| `fetchContacts` | Returns paginated mock contacts, supports `query.contains` search |\n| `getClipboardText` / `setClipboardText` | mock (state storage) or web (Clipboard API) |\n| `generateHapticFeedback` | Console log + analytics record |\n| `saveBase64Data` | File download via anchor element |\n\n### IAP / payments\n\n| API | Mock behavior |\n|---|---|\n| `IAP.createOneTimePurchaseOrder` | Simulates success/failure after a 300ms delay based on state's `nextResult` |\n| `IAP.createSubscriptionPurchaseOrder` | Same flow as above |\n| `IAP.getProductItemList` | Returns state's product list |\n| `IAP.getPendingOrders` | Returns pending order list |\n| `IAP.getCompletedOrRefundedOrders` | Returns completed/refunded order list |\n| `IAP.completeProductGrant` | Moves order from pending to completed |\n| `IAP.getSubscriptionInfo` | Returns active subscription mock (30-day expiry, auto-renew) |\n| `checkoutPayment` | Returns state's payment result after 300ms delay (TossPay) |\n\n**IAP purchase simulation flow:**\n\n1. `IAP.createOneTimePurchaseOrder()` called\n2. 300ms delay (simulates payment UI)\n3. Check `state.iap.nextResult` → if not `'success'`, call `onError`\n4. On success, run the `processProductGrant` callback → on failure, return `'PRODUCT_NOT_GRANTED_BY_PARTNER'` error\n5. On full success, record in `completedOrders` and deliver order result via `onEvent`\n\n### Ads\n\n| API | Mock behavior |\n|---|---|\n| `GoogleAdMob.loadAppsInTossAdMob` | Emits a `loaded` event after 200ms |\n| `GoogleAdMob.showAppsInTossAdMob` | Sequentially emits requested→show→impression→reward→dismissed events over 50ms–1.5s |\n| `GoogleAdMob.isAppsInTossAdMobLoaded` | Returns boolean loaded state |\n| `TossAds.initialize/attach/attachBanner` | Renders a gray placeholder div |\n| `TossAds.destroy/destroyAll` | No-op |\n| `loadFullScreenAd` / `showFullScreenAd` | Similar flow to GoogleAdMob |\n\n### Events\n\n| API | Mock behavior |\n|---|---|\n| `graniteEvent.addEventListener` | Listens for `__ait:backEvent` and `__ait:homeEvent` custom events |\n| `appsInTossEvent.addEventListener` | No-op |\n| `tdsEvent.addEventListener` | Listens for `__ait:navigationAccessoryEvent` |\n| `onVisibilityChangedByTransparentServiceWeb` | Delegates to `document.visibilitychange` event |\n\n### Analytics\n\n| API | Mock behavior |\n|---|---|\n| `Analytics.screen/impression/click` | Records by type in analyticsLog, viewable in the Panel in real time |\n| `eventLog` | Records custom events by `log_name`, `log_type`, and `params` |\n\n### Game / promotions\n\n| API | Mock behavior |\n|---|---|\n| `grantPromotionReward` | Returns a timestamp-based mock key |\n| `grantPromotionRewardForGame` | Same as above |\n| `submitGameCenterLeaderBoardScore` | Appends score to state, returns `{ statusCode: 'SUCCESS' }` |\n| `getGameCenterGameProfile` | Returns mock profile (or `PROFILE_NOT_FOUND` if absent) |\n| `openGameCenterLeaderboard` | Console log (no-op) |\n| `contactsViral` | Emits a close event after 500ms |\n\n### Permissions\n\n| API | Mock behavior |\n|---|---|\n| `getPermission` | Returns state's permission status (allowed/denied/notDetermined) |\n| `openPermissionDialog` | Changes status to `allowed` |\n| `requestPermission` | Delegates to `openPermissionDialog` |\n\n\u003e Functions that require permissions (openCamera, getCurrentLocation, etc.) are wrapped with `withPermission()`, which automatically attaches `.getPermission()` and `.openPermissionDialog()` methods.\n\n### Partner\n\n| API | Mock behavior |\n|---|---|\n| `partner.addAccessoryButton` | Console log (no-op) |\n| `partner.removeAccessoryButton` | Console log (no-op) |\n\n## Using in tests\n\nYou can import the mock library directly in vitest/jest.\n\n\u003e The mock functions use browser APIs such as `window`, `document`, and `localStorage`, so a **jsdom environment** is required.\n\u003e\n\u003e ```ts\n\u003e // vitest.config.ts\n\u003e import { defineConfig } from 'vitest/config';\n\u003e export default defineConfig({ test: { environment: 'jsdom' } });\n\u003e ```\n\n```ts\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { appLogin, Storage, getCurrentLocation, getNetworkStatus, openCamera, IAP } from '@ait-co/devtools/mock';\nimport { aitState } from '@ait-co/devtools/mock';\n\nbeforeEach(() =\u003e {\n  aitState.reset(); // reset state before each test\n});\n\n// Auth test\nit('appLogin returns an authorizationCode', async () =\u003e {\n  const result = await appLogin();\n  expect(result.authorizationCode).toBeDefined();\n});\n\n// Set state then call function\nit('network status query when offline', async () =\u003e {\n  aitState.update({ networkStatus: 'OFFLINE' });\n  const status = await getNetworkStatus();\n  expect(status).toBe('OFFLINE');\n});\n\n// Permission denied scenario\nit('throws when camera permission is denied', async () =\u003e {\n  aitState.patch('permissions', { camera: 'denied' });\n  await expect(openCamera()).rejects.toThrow();\n});\n\n// IAP failure scenario (requires fake timers)\nit('calls onError when purchase is canceled', async () =\u003e {\n  vi.useFakeTimers();\n  aitState.patch('iap', { nextResult: 'USER_CANCELED' });\n  const onError = vi.fn();\n  IAP.createOneTimePurchaseOrder({\n    options: { sku: 'item_01', processProductGrant: async () =\u003e true },\n    onEvent: vi.fn(),\n    onError,\n  });\n  await vi.advanceTimersByTimeAsync(500);\n  expect(onError).toHaveBeenCalledWith({ code: 'USER_CANCELED' });\n  vi.useRealTimers();\n});\n\n// Storage test\nit('can write and read from Storage', async () =\u003e {\n  await Storage.setItem('key1', 'value1');\n  const result = await Storage.getItem('key1');\n  expect(result).toBe('value1');\n});\n```\n\n## SDK update tracking\n\ndevtools 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.\n\nThree mechanisms keep the SDK changes safely tracked:\n\n### 1. Compile-time type verification (`__typecheck.ts`)\n\n`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.\n\n```ts\ntype Assert\u003cTMock, TOriginal\u003e = TMock extends TOriginal ? true : never;\ntype _AppLogin = Assert\u003ctypeof Mock.appLogin, typeof Original.appLogin\u003e;\n// 40+ type compatibility assertions\n```\n\n### 2. Proxy tripwire (runtime blocking)\n\n`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.\n\n```\n[@ait-co/devtools] IAP.newMethod is not mocked. This API may exist in\n@apps-in-toss/web-framework, but devtools' mock does not cover it yet.\nPlease file an issue: https://github.com/apps-in-toss-community/devtools/issues\n```\n\n### 3. Weekly GitHub Actions CI\n\n`.github/workflows/check-sdk-update.yml` automatically runs **every Monday**:\n\n1. Checks for a new version of `@apps-in-toss/web-framework`\n2. Updates to the latest version and runs the type check\n3. On detecting a new version, automatically opens a GitHub Issue (including whether there are type errors)\n\n## Fidelity QA\n\n`scripts/fidelity-qa/` automatically measures SDK API fidelity between the mock and a real-device relay session.\n\n```bash\npnpm qa:fidelity --runner=mock           # mock-only (CI default, regression detection)\npnpm qa:fidelity --runner=relay          # requires attached device (devtools MCP)\npnpm qa:fidelity --runner=both --diff    # run both + print diff\npnpm qa:fidelity --include-writes        # include Storage write cycle (off by default)\npnpm qa:fidelity --output=results.json  # write JSON results to file\n```\n\nCI runs `pnpm qa:fidelity --runner=mock` automatically (exits 0 on a clean state).\n\n**Diff labels**:\n\n- `MATCH` — mock and relay values are equal\n- `EXPECTED_MISMATCH` — known difference registered in `scripts/fidelity-qa/whitelist.json` (e.g. jsdom UA vs real WebView UA)\n- `UNEXPECTED` — mismatch not in whitelist → exits 1 (potential regression)\n\n**Updating the whitelist**: when an intentional difference is found during a relay session, add `{ \"id\": \"\u003cprobe-id\u003e\", \"reason\": \"\u003cexplanation\u003e\" }` to `scripts/fidelity-qa/whitelist.json`.\n\nThe relay runner is currently a stub (CDP Runtime.evaluate implementation is a follow-up in devtools#261).\n\n## Contributing\n\n### Adding a new API mock\n\n1. Implement the function in the appropriate category directory (e.g. `src/mock/device/`)\n2. Add the export to `src/mock/index.ts`\n3. Add a type compatibility assertion to `src/__typecheck.ts`\n4. Run `pnpm typecheck` to verify compatibility with the original\n5. Write tests in `src/__tests__/`\n\n```bash\npnpm build       # build with tsdown\npnpm typecheck   # verify type compatibility\npnpm test        # run all tests\n```\n\n### Pre-commit hook (optional)\n\nOptional but recommended. After cloning, activate the standard pre-commit hook with the command below. It runs `biome check` automatically on staged files.\n\n```sh\ngit config core.hooksPath .githooks\n```\n\nThis 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.\n\n## Troubleshooting\n\n### `[@ait-co/devtools] XXX.method is not mocked` error\n\nThe 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.\n\n### DevTools Panel not appearing\n\n- Check that you haven't set `panel: false` in your plugin options\n- If you're using manual alias setup, add a direct import to your entry point:\n  ```ts\n  import '@ait-co/devtools/panel';\n  ```\n- 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.\n\n### Subpath imports are not mocked\n\nSubpath 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`.\n\n### Setting up with Next.js Turbopack\n\nSince 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:\n\n```ts\n// app/layout.tsx or pages/_app.tsx\nimport '@ait-co/devtools/panel';\n```\n\n## MCP Server\n\nAI coding agents (Claude Code, Cursor, etc.) can observe a running mini-app directly via [MCP (Model Context Protocol)](https://modelcontextprotocol.io/). A single `devtools-mcp` binary provides two modes.\n\nA local browser (env 1) and a phone Toss WebView (env 2/3) both speak CDP, so every tool works identically in both environments — the only difference is the attach strategy (`--target=relay` vs `--target=local`).\n\n| Mode + target | Invocation | Env vars | Target | Tools |\n|---|---|---|---|---|\n| `--target=mobile` (env 2) | `devtools-mcp` → `start_debug({mode:'relay-sandbox'})` | `AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL` | Real-device Safari/WebKit PWA (external Chii relay + cloudflared tunnel, env 2) | console/network/page + DOM/snapshot/screenshot |\n| `--mode=debug --target=relay` (default, env 3) | `devtools-mcp` → `start_debug({mode: 'relay-staging'})` | — | Dogfood bundle on a phone (CDP/Chii relay + cloudflared tunnel, env 3) | same + `AIT.*` |\n| `--mode=debug --target=relay` LIVE (env 4) | `devtools-mcp` → `start_debug({mode: 'relay-live', confirm: true})` | — (env 4 LIVE guard) | Live deployed app (env 4) — `call_sdk`/`evaluate` require `confirm: true` | same |\n| `--mode=debug --target=local` (env 1) | `devtools-mcp --target=local` | `MCP_ENV=mock` (auto) | Local Chromium launched by the MCP server (CDP direct-attach, no relay needed, env 1) | same |\n| `--mode=dev` | `devtools-mcp --mode=dev` | `MCP_ENV=mock` (auto) | Mock state from a running Vite dev server (AIT.* only, no CDP) | `AIT.*` (+ `devtools_get_mock_state` alias) |\n\n`--target=local` opens `AIT_DEVTOOLS_URL` (default `http://localhost:5173`) and attaches directly to a local Chromium — no relay or tunnel required. `--mode=dev` reads the mock-state HTTP endpoint of the Vite dev server and does not provide CDP tools. Switch environments in-session with `start_debug(mode)`: `relay-sandbox` (env 2 PWA), `relay-staging` (env 3 dogfood), `relay-live` (env 4, arms LIVE guard — `confirm: true` required), `local-browser` (env 1).\n\n#### Environment 2 (real-device PWA CDP) — `--target=mobile`\n\nDebug on a real phone using Safari/WebKit without Toss review. The Vite dev server with [`tunnel:{cdp:true}`](#tunnel-option) brings up both an app HTTP tunnel and a Chii relay tunnel. The MCP server attaches to that relay and provides `build_attach_url` → launcher QR.\n\n**Setup procedure:**\n\n1. Start the Vite dev server in CDP tunnel mode:\n   ```bash\n   AIT_TUNNEL_CDP=1 pnpm exec vite --config e2e/fixture/vite.config.ts\n   ```\n   The terminal banner prints two URLs:\n   - **App HTTP tunnel** `https://\u003cA\u003e.trycloudflare.com` → set as `AIT_TUNNEL_BASE_URL`\n   - **Relay wss tunnel** `wss://\u003cB\u003e.trycloudflare.com` → set `AIT_RELAY_BASE_URL` to its `https://` form\n\n2. Start the MCP server in mobile mode (separate terminal):\n   ```json\n   {\n     \"mcpServers\": {\n       \"ait-debug\": {\n         \"command\": \"npx\",\n         \"args\": [\"-y\", \"@ait-co/devtools\", \"devtools-mcp\"],\n         \"env\": {\n           \"AIT_RELAY_BASE_URL\": \"https://\u003cB\u003e.trycloudflare.com\",\n           \"AIT_TUNNEL_BASE_URL\": \"https://\u003cA\u003e.trycloudflare.com\"\n         }\n       }\n     }\n   }\n   ```\n\n3. In a Claude Code session:\n   ```\n   start_debug({mode: 'relay-sandbox'})\n   build_attach_url()\n   ```\n   Scan the QR with your phone camera. The launcher PWA opens the app in a frame and injects Chii target.js.\n\n4. `list_pages()` → expect one page. Use `take_screenshot()` and other CDP tools.\n\n**Env 2 fidelity boundary**: uses the mock SDK (`call_sdk` hits the mock). For real SDK fidelity, move to env 3. CDP runs on the real WebKit engine, so DOM, console, and screenshot reflect the real device screen.\n\n**Local-PC verification**: `e2e/launcher-cdp.test.ts` automates node-side relay startup (`startChiiRelay({port:0})`) and launcher param forwarding (Playwright). Browser-side Chii target.js injection is not automated in CI due to the localhost host gate (Layer B1) and ws:// vs wss:// constraints — completed by the manual procedure above on a real device with a trycloudflare.com hostname.\n\n### Debug mode (CDP via Chii)\n\nFor a step-by-step walkthrough of the on-device relay debug loop (dogfood build → QR scan → relay attach) including common failure recovery, see **[`docs/dogfood-relay-loop.md`](./docs/dogfood-relay-loop.md)** (Korean). For crash triage — `list_pages.crashDetectedAt`, iOS Console.app `.ips` analysis, and the redact procedure — see **[`docs/crash-triage.md`](./docs/crash-triage.md)** (Korean).\n\nRead-only tools only. Tools are registered in two tiers based on attach state — before attach, only the bootstrap tools (`build_attach_url`, `list_pages`) are visible; once a relay/local page attaches, the attach-dependent tools are registered dynamically in the same session via `notifications/tools/list_changed` (no session restart needed). The phone attach roundtrip is fully wired; all that remains is a single on-device acceptance run. The tool layer is CI-verified via a mockable injectable CDP connection / AIT source.\n\nRunning `devtools-mcp` as a stdio server starts a local Chii relay on an OS-assigned port and opens a cloudflared quick tunnel, printing a public `wss://*.trycloudflare.com` URL and a QR code in the terminal (secrets/auth codes are never printed). When the phone enters the dogfood entry point, the in-app attach UI connects to the relay with that URL, and the agent reads console/network/page state via `chrome-devtools-mcp`-compatible tools — diagnosing regressions without anyone watching the phone.\n\nEnvironments 3 and 4 (intoss-private relay) — start `devtools-mcp` as-is, then enter via `start_debug(mode)`:\n\n```json\n{\n  \"mcpServers\": {\n    \"ait-debug\": {\n      \"command\": \"pnpm\",\n      \"args\": [\"exec\", \"devtools-mcp\"]\n    }\n  }\n}\n```\n\n- Environment 3 (dogfood relay): `start_debug({mode: 'relay-staging'})`\n- Environment 4 (LIVE relay, LIVE guard enabled): `start_debug({mode: 'relay-live', confirm: true})`\n\n**`start_debug(mode)` is the single in-session entry path.** `MCP_ENV=relay-live` remains only as a deprecated alias that seeds `liveIntent` at boot — in a new session, enter via `start_debug({mode: 'relay-live', confirm: true})`.\n\n| Tool | CDP / AIT backing | Description |\n|---|---|---|\n| `list_console_messages` | `Runtime.consoleAPICalled` | Recent console.log/warn/error messages (level, text, timestamp, args) |\n| `list_network_requests` | `Network.requestWillBeSent` + `responseReceived` | Recent XHR/fetch requests (url, method, status, timing) |\n| `list_pages` | Chii relay target list | Attached pages + tunnel status + wss URL |\n| `build_attach_url` | (pure synthesis) | Splices `debug=1` + the relay URL into an `ait deploy --scheme-only` URL, prints a QR. Scanning the QR with the phone camera is the single entry path for env 2/3 (requires `list_pages` first) |\n| `get_dom_document` | `DOM.getDocument` | DOM tree read (structural/layout regression diagnosis) |\n| `take_snapshot` | `DOMSnapshot.captureSnapshot` | Page snapshot (documents + interned strings, visual regression) |\n| `take_screenshot` | `Page.captureScreenshot` | Page PNG screenshot (returned as an MCP image content block) |\n| `measure_safe_area` | `Runtime.evaluate` | Runs a safe-area probe on the attached page → returns normalized safe-area insets, viewport geometry, DPR, and User-Agent. Read-only. Use in a relay session to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires attach (`list_pages` first) |\n| `evaluate` | `Runtime.evaluate` | Evaluates an arbitrary JS expression on the attached page (returnByValue) and returns the result. **Not read-only** — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires attach |\n| `call_sdk` | `window.__sdkCall` bridge (via `Runtime.evaluate`) | Calls a dogfood SDK method via the `window.__sdkCall` bridge (exported by `@apps-in-toss/web-framework` in `__DEBUG_BUILD__` bundles only). **Not read-only** — SDK calls have side effects (navigation, payments, permissions, etc.). Hits the real SDK on env 3/4, mock SDK on env 1. Env 2 (PWA) does not inject the SDK — not available there. On env 4, `confirm: true` is required (LIVE guard). Requires attach. Returns `{ok,value}` / `{ok,error}` |\n| `AIT.getSdkCallHistory` | AIT domain | SDK call trace (method, args, result/error, timestamp) |\n| `AIT.getMockState` | AIT domain | Mock state snapshot (`window.__ait`) |\n| `AIT.getOperationalEnvironment` | AIT domain | `getOperationalEnvironment()` + SDK version |\n\n`AIT.*` covers what raw CDP cannot; the same MCP server forwards it alongside CDP. In debug mode the in-app side answers over the Chii channel.\n\n### Dev mode (mock state)\n\n`devtools-mcp --mode=dev` reads the mock state from a running browser. It shares the same `AIT.*` tool surface as debug mode.\n\n#### Architecture\n\n```\nBrowser (aitState)\n  └─ POST /api/ait-devtools/state (auto-pushed by the panel on every state change)\n       └─ Vite dev server (unplugin with mcp: true)\n            └─ GET /api/ait-devtools/state\n                 └─ MCP stdio server (dist/mcp/server.js)\n                      └─ AI agent (AIT.getMockState tool)\n```\n\n#### Setup\n\n**1. Add `mcp: true` to the Vite plugin**\n\n```ts\n// vite.config.ts\nimport aitDevtools from '@ait-co/devtools/unplugin';\n\nexport default {\n  plugins: [aitDevtools.vite({ mcp: true })],\n};\n```\n\n**2. Configure your MCP client (e.g. Claude Code `.claude/settings.json`)**\n\n```json\n{\n  \"mcpServers\": {\n    \"ait-devtools\": {\n      \"command\": \"pnpm\",\n      \"args\": [\"exec\", \"devtools-mcp\", \"--mode=dev\"],\n      \"env\": {\n        \"AIT_DEVTOOLS_URL\": \"http://localhost:5173\"\n      }\n    }\n  }\n}\n```\n\n`AIT_DEVTOOLS_URL` defaults to `http://localhost:5173` — you can omit it if you're using the default port.\n\n**3. Open the app in your browser, then call the tool from your AI agent**\n\n```\n\u003e AIT.getMockState\n```\n\nReturns the full current mock state (permissions, location, auth, network, IAP, etc.) as JSON.\n\n| Tool | Description |\n|---|---|\n| `AIT.getMockState` | Returns the current `AitDevtoolsState` snapshot (read-only) |\n| `AIT.getOperationalEnvironment` | Environment + version derived from the mock state's `environment` + `appVersion` |\n| `AIT.getSdkCallHistory` | Empty in dev mode (the HTTP endpoint records no trace) |\n| `devtools_get_mock_state` | Backward-compatible alias of `AIT.getMockState` (prefer `AIT.getMockState` in new configs) |\n\n## Package export structure\n\n| Import path | Purpose |\n|---|---|\n| `@ait-co/devtools` or `@ait-co/devtools/mock` | All mock exports (bundler alias target) |\n| `@ait-co/devtools/panel` | Floating DevTools Panel (auto-mounts on import) |\n| `@ait-co/devtools/unplugin` | Bundler plugin (.vite, .webpack, .rspack, .esbuild, .rollup) |\n| `@ait-co/devtools/mcp/server` | Dev-mode MCP stdio server function (Node.js) |\n| `@ait-co/devtools/mcp/cli` | `devtools-mcp` bin entry point (debug / dev mode, Node.js) |\n| `@ait-co/devtools/in-app` | In-app debug attach — runtime gate (layers B/C) + Chii target.js injection. The consumer wraps the import in `if (__DEBUG_BUILD__)` so it is DCE'd from release builds — dogfood builds only |\n| `@ait-co/devtools/in-app/auto` | Self-gating side-effect entry — a single `import '@ait-co/devtools/in-app/auto'` line wires attach + SDK bridge. Active only when `?debug=1` / `?relay=` are in the URL or it is a DEV build; stays dormant on normal production loads. See the [section above](#on-device-debugging-in-one-line) |\n\n## Telemetry\n\ndevtools uses a two-tier telemetry model.\n\n### Tier 0 — anonymous usage signal (ON by default, opt-out)\n\nSends a one-time anonymous ping per calendar day when the panel is opened.\n\nCollected fields: `source`, `version`, `ts` — no PII, no `anon_id`. The server generates an IP+UA daily hash but never stores it.\n\nHow to opt out:\n- Panel Environment tab → \"Anonymous usage signal (Tier 0)\" toggle OFF\n- `localStorage.setItem('__ait_telemetry:t0_off', '1')` (from the browser console)\n- Environment variable: `AITC_TELEMETRY=off`\n\n### Tier 1 — extended telemetry (OFF by default, opt-in)\n\nA consent toast appears on first panel use. Data is only collected if you accept.\n\nCollected fields: `panel_open`, `tab_view`, `session_duration` events + an anonymous UUID (`anon_id`).\n\nHow to opt out:\n- Panel Environment tab → \"Extended telemetry (Tier 1)\" toggle OFF\n- Delete collected data: Panel Environment tab → \"Delete my data\"\n\nPrivacy policy: \u003chttps://docs.aitc.dev/privacy\u003e\n\n## License\n\nBSD 3-Clause\n\n---\n\nCommunity open-source project.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fapps-in-toss-community%2Fdevtools","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fapps-in-toss-community%2Fdevtools","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fapps-in-toss-community%2Fdevtools/lists"}