Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Docs

on:
push:
branches: [main]
paths: ['website/**', 'skills/**', 'docs/**']
pull_request:
paths: ['website/**', 'skills/**', 'docs/**']
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
runs-on: ubuntu-latest
env:
# Secrets are unavailable on fork PRs and may not be configured yet —
# skip deployment (and the preview comment) instead of failing the build.
HAS_CLOUDFLARE_SECRETS: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' }}
permissions:
contents: read
deployments: write
pull-requests: write
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
- run: bun install --frozen-lockfile
- run: bun scripts/sync-services.ts
- name: Check generated service pages are up to date
# git status --porcelain also catches untracked pages generated for
# newly added skills, which git diff alone would miss.
run: |
if [ -n "$(git status --porcelain -- src/content/docs/services)" ]; then
git status --short -- src/content/docs/services
echo "::error::services/*.md is stale — run 'bun run sync:services' in website/ and commit"
exit 1
fi
- run: bun run build
# Pushes to main deploy to production; PR branches get a preview
# deployment. Fork PRs are skipped (no access to secrets).
- name: Deploy to Cloudflare Pages
id: deploy
if: env.HAS_CLOUDFLARE_SECRETS == 'true'
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
workingDirectory: website
command: pages deploy dist --project-name=emulate-docs --branch=${{ github.head_ref || github.ref_name }}
- name: Comment preview URL on PR
if: github.event_name == 'pull_request' && steps.deploy.outcome == 'success'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }}
with:
script: |
const marker = '<!-- docs-preview -->'
const url = process.env.DEPLOYMENT_URL
const alias = process.env.ALIAS_URL
const lines = [
marker,
'### 📖 Docs preview deployed',
'',
`| | URL |`,
`| --- | --- |`,
`| Preview | ${url} |`,
]
if (alias && alias !== url) {
lines.push(`| Branch alias | ${alias} |`)
}
lines.push('', `_Commit: ${context.payload.pull_request.head.sha}_`)
const body = lines.join('\n')

const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number,
per_page: 100,
})
const existing = comments.find(c => c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
}
else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
}
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Local drop-in replacement services for CI and no-network sandboxes.
Fully stateful, production-fidelity API emulation for Korean services
(Kakao, Naver, Toss Payments) and BaaS platforms (Firebase, Supabase).

**Documentation: [emulate.pleaseai.dev](https://emulate.pleaseai.dev)**

Built on the architecture of [vercel-labs/emulate](https://github.com/vercel-labs/emulate),
using [`@emulators/core`](https://www.npmjs.com/package/@emulators/core).

Expand Down Expand Up @@ -48,8 +50,16 @@ bun packages/emulate/dist/index.js --service kakao,tosspayments

# Generate a seed config file
bun packages/emulate/dist/index.js init

# Serve over HTTPS via portless (requires `portless proxy start`)
bun packages/emulate/dist/index.js --portless
```

With `--portless`, each service registers a [portless](https://github.com/vercel-labs/portless)
alias and gets a trusted HTTPS URL like `https://kakao.emulate.localhost`.
For other proxies, `--base-url` / `EMULATE_BASE_URL` support `{service}`
interpolation (e.g. `--base-url "https://{service}.myproxy.test"`).

When an `emulate.config.yaml` (or `--seed <file>`) is present, the emulators
start pre-seeded with app keys, users, and table data. Only the services
present in the config are started.
Expand Down
30 changes: 15 additions & 15 deletions docs/EMULATOR-CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,27 +140,27 @@ seedFromConfig.
Send Requests directly through `app.fetch` without binding a port:

```ts
import { describe, it, expect, beforeEach } from "bun:test";
import { createServer } from "@emulators/core";
import { kakaoPlugin } from "../index.js";
import { createServer } from '@emulators/core'
import { describe, expect, it } from 'bun:test'
import { kakaoPlugin } from '../index.js'

function makeApp() {
const { app, store } = createServer(kakaoPlugin, { port: 4000 });
kakaoPlugin.seed?.(store, "http://localhost:4000");
return { app, store };
const { app, store } = createServer(kakaoPlugin, { port: 4000 })
kakaoPlugin.seed?.(store, 'http://localhost:4000')
return { app, store }
}

it("issues token for authorization code", async () => {
const { app } = makeApp();
it('issues token for authorization code', async () => {
const { app } = makeApp()
const res = await app.fetch(
new Request("http://localhost:4000/oauth/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", code, client_id }),
new Request('http://localhost:4000/oauth/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id }),
}),
);
expect(res.status).toBe(200);
});
)
expect(res.status).toBe(200)
})
```

- Cover the happy path plus the main error paths (bad code/token/key, missing
Expand Down
4 changes: 2 additions & 2 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import pleaseai from '@pleaseai/eslint-config'

export default pleaseai(
{
ignores: ['docs/**'],
ignores: ['docs/**', 'website/src/content/**', 'website/.astro/**', 'website/dist/**'],
},
{
// The CLI legitimately prints to stdout.
files: ['packages/emulate/src/index.ts', 'packages/emulate/src/commands/**'],
files: ['packages/emulate/src/index.ts', 'packages/emulate/src/commands/**', 'packages/emulate/src/portless.ts'],
rules: {
'no-console': 'off',
},
Expand Down
92 changes: 92 additions & 0 deletions packages/emulate/src/__tests__/base-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import process from 'node:process'
import { afterAll, beforeEach, describe, expect, it } from 'bun:test'
import { resolveBaseUrl } from '../base-url.js'
import { defaultBasePort, validateBaseUrlOptions } from '../start-options.js'

const savedEnv = {
EMULATE_BASE_URL: process.env.EMULATE_BASE_URL,
PORTLESS_URL: process.env.PORTLESS_URL,
EMULATE_PORT: process.env.EMULATE_PORT,
PORT: process.env.PORT,
}

// Clear before each test (not just after) so ambient shell/CI env vars
// cannot leak into the first test case.
beforeEach(() => {
delete process.env.EMULATE_BASE_URL
delete process.env.PORTLESS_URL
delete process.env.EMULATE_PORT
delete process.env.PORT
})

afterAll(() => {
for (const [name, value] of Object.entries(savedEnv)) {
if (value !== undefined) {
Object.assign(process.env, { [name]: value })
}
}
})
Comment on lines +6 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

afterAll doesn't clear env vars that were originally unset.

Only vars with an original defined value get restored; vars that started undefined (e.g. EMULATE_PORT/PORT in most shells) are left with whatever the last test set them to, since nothing calls delete for the undefined case. This can leak stray PORT/EMULATE_PORT values into other test files sharing the same worker process.

💚 Proposed fix
 afterAll(() => {
   for (const [name, value] of Object.entries(savedEnv)) {
     if (value !== undefined) {
       Object.assign(process.env, { [name]: value })
     }
+    else {
+      delete process.env[name]
+    }
   }
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const savedEnv = {
EMULATE_BASE_URL: process.env.EMULATE_BASE_URL,
PORTLESS_URL: process.env.PORTLESS_URL,
EMULATE_PORT: process.env.EMULATE_PORT,
PORT: process.env.PORT,
}
// Clear before each test (not just after) so ambient shell/CI env vars
// cannot leak into the first test case.
beforeEach(() => {
delete process.env.EMULATE_BASE_URL
delete process.env.PORTLESS_URL
delete process.env.EMULATE_PORT
delete process.env.PORT
})
afterAll(() => {
for (const [name, value] of Object.entries(savedEnv)) {
if (value !== undefined) {
Object.assign(process.env, { [name]: value })
}
}
})
const savedEnv = {
EMULATE_BASE_URL: process.env.EMULATE_BASE_URL,
PORTLESS_URL: process.env.PORTLESS_URL,
EMULATE_PORT: process.env.EMULATE_PORT,
PORT: process.env.PORT,
}
// Clear before each test (not just after) so ambient shell/CI env vars
// cannot leak into the first test case.
beforeEach(() => {
delete process.env.EMULATE_BASE_URL
delete process.env.PORTLESS_URL
delete process.env.EMULATE_PORT
delete process.env.PORT
})
afterAll(() => {
for (const [name, value] of Object.entries(savedEnv)) {
if (value !== undefined) {
Object.assign(process.env, { [name]: value })
} else {
delete process.env[name]
}
}
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/emulate/src/__tests__/base-url.test.ts` around lines 6 - 28, The
cleanup in `afterAll` only restores `savedEnv` entries that were originally
defined, so any env var that started unset (like `EMULATE_PORT` or `PORT`) can
remain polluted after the tests. Update the `afterAll` restore logic in
`base-url.test.ts` to explicitly delete `process.env` keys whose saved value is
`undefined`, and restore only the defined ones, using the existing `savedEnv`
object and env var names already captured at the top of the test file.


describe('resolveBaseUrl', () => {
it('defaults to localhost with the port', () => {
expect(resolveBaseUrl({ service: 'kakao', port: 4000 })).toBe('http://localhost:4000')
})

it('prefers seed baseUrl over everything else', () => {
process.env.EMULATE_BASE_URL = 'https://env.test'
expect(
resolveBaseUrl({ service: 'kakao', port: 4000, baseUrl: 'https://flag.test', seedBaseUrl: 'https://seed.test/' }),
).toBe('https://seed.test')
})

it('uses the explicit baseUrl over env vars', () => {
process.env.EMULATE_BASE_URL = 'https://env.test'
expect(resolveBaseUrl({ service: 'kakao', port: 4000, baseUrl: 'https://flag.test' })).toBe('https://flag.test')
})

it('falls back to EMULATE_BASE_URL, then PORTLESS_URL', () => {
process.env.PORTLESS_URL = 'https://{service}.emulate.localhost'
expect(resolveBaseUrl({ service: 'naver', port: 4001 })).toBe('https://naver.emulate.localhost')

process.env.EMULATE_BASE_URL = 'https://{service}.myproxy.test'
expect(resolveBaseUrl({ service: 'naver', port: 4001 })).toBe('https://naver.myproxy.test')
})

it('interpolates {service} in explicit baseUrl', () => {
expect(resolveBaseUrl({ service: 'supabase', port: 4004, baseUrl: 'https://{service}.proxy.test' })).toBe(
'https://supabase.proxy.test',
)
})
})

describe('validateBaseUrlOptions', () => {
it('rejects --portless together with --base-url', () => {
expect(validateBaseUrlOptions({ portless: true, baseUrl: 'https://x.test' }, 1)).toContain('mutually exclusive')
})

it('requires {service} for multi-service --base-url', () => {
expect(validateBaseUrlOptions({ baseUrl: 'https://x.test' }, 2)).toContain('{service} placeholder')
expect(validateBaseUrlOptions({ baseUrl: 'https://{service}.x.test' }, 2)).toBeNull()
})

it('accepts a literal --base-url for a single service', () => {
expect(validateBaseUrlOptions({ baseUrl: 'https://x.test' }, 1)).toBeNull()
})
})

describe('defaultBasePort', () => {
it('defaults to 4000 without env vars', () => {
expect(defaultBasePort()).toBe(4000)
})

it('prefers EMULATE_PORT over PORT and ignores non-numeric values', () => {
process.env.PORT = '5000'
expect(defaultBasePort()).toBe(5000)

process.env.EMULATE_PORT = '6000'
expect(defaultBasePort()).toBe(6000)

process.env.EMULATE_PORT = 'not-a-port'
expect(defaultBasePort()).toBe(5000)
})
})
Loading