forge-sim lets you write fast, deterministic tests for your Forge app (resolvers, triggers, queues, KVS, SQL, and product APIs) without a cloud deploy or an Atlassian site.
For the complete sim.* surface this guide draws on, see the programmatic API reference.
- Bundler Configuration
- Getting Started
- Core Concepts
- Testing Patterns
- Tips
- Debugging Tests
- Common Gotchas
Read this first because your tests won't work without it.
Forge apps import packages like @forge/api, @forge/resolver, @forge/kvs, etc. In production, these are provided by the Forge runtime. In tests, forge-sim provides shim modules that redirect those imports to the simulator. Your test runner needs to know about these shims.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
// Aliases use forge-sim's "./shims/*" subpath exports; no path math needed.
// Works on every Node version that supports exports maps (>=14.13).
export default defineConfig({
resolve: {
alias: {
'@forge/resolver': 'forge-sim/shims/forge-resolver',
'@forge/api': 'forge-sim/shims/forge-api',
'@forge/kvs': 'forge-sim/shims/forge-kvs',
'@forge/events': 'forge-sim/shims/forge-events',
'@forge/llm': 'forge-sim/shims/forge-llm',
'@forge/react': 'forge-sim/shims/forge-react',
'@forge/bridge': 'forge-sim/shims/forge-bridge',
'@forge/jira-bridge': 'forge-sim/shims/forge-jira-bridge',
'@forge/confluence-bridge': 'forge-sim/shims/forge-confluence-bridge',
'@forge/dashboards-bridge': 'forge-sim/shims/forge-dashboards-bridge',
'@forge/realtime': 'forge-sim/shims/forge-realtime',
'@forge/object-store': 'forge-sim/shims/forge-object-store',
},
},
test: {
testTimeout: 30_000,
hookTimeout: 60_000,
},
});Note: You only need aliases for the
@forge/*packages your app actually imports. The full list is shown above for reference.
// jest.config.js (or webpack.config.js resolve.alias)
// Aliases use forge-sim's "./shims/*" subpath exports; no path math needed.
module.exports = {
// Jest
moduleNameMapper: {
'^@forge/resolver$': 'forge-sim/shims/forge-resolver',
'^@forge/api$': 'forge-sim/shims/forge-api',
'^@forge/kvs$': 'forge-sim/shims/forge-kvs',
'^@forge/events$': 'forge-sim/shims/forge-events',
'^@forge/llm$': 'forge-sim/shims/forge-llm',
'^@forge/react$': 'forge-sim/shims/forge-react',
'^@forge/bridge$': 'forge-sim/shims/forge-bridge',
'^@forge/jira-bridge$': 'forge-sim/shims/forge-jira-bridge',
'^@forge/confluence-bridge$': 'forge-sim/shims/forge-confluence-bridge',
'^@forge/dashboards-bridge$': 'forge-sim/shims/forge-dashboards-bridge',
'^@forge/realtime$': 'forge-sim/shims/forge-realtime',
'^@forge/object-store$': 'forge-sim/shims/forge-object-store',
},
// Webpack (resolve.alias section)
// resolve: {
// alias: {
// '@forge/resolver': 'forge-sim/shims/forge-resolver',
// '@forge/api': 'forge-sim/shims/forge-api',
// // ... same pattern
// },
// },
};
⚠️ forge-sim is ESM-only.require('forge-sim')throwsERR_PACKAGE_PATH_NOT_EXPORTED; there is no CommonJS build. Useimport(or dynamicimport()from CJS code). For Jest specifically, run in ESM mode ("type": "module"in package.json, orextensionsToTreatAsEsm+node --experimental-vm-modules); a default CJS Jest config will fail to load forge-sim regardless of the mapper entries above. Vitest is ESM-native and needs nothing extra.
If you're running tests with plain Node (no Vitest, no Jest, no bundler), sim.deploy() automatically registers loader hooks via module.register(). No configuration needed:
node my-test-script.js # deploy() registers the shims automaticallyforge-sim uses Node.js module loader hooks to intercept @forge/* imports and redirect them to simulator shims. This works in plain Node.
However, bundler-based test runners (Vitest, Jest, webpack) use their own module resolution pipelines and bypass Node's loader hooks entirely. The alias/mapper config tells the bundler where to find the shim modules.
@forge/sqlnote: There is no shim for@forge/sql. The real@forge/sqlpackage communicates with the simulator through a runtime hook (global.__forge_fetch__), which forge-sim installs automatically when you callcreateSimulator(). No alias needed for SQL.
npm install --save-dev forge-sim vitestimport { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createSimulator, type ForgeSimulator } from 'forge-sim';
import { resolve } from 'node:path';
describe('My Forge App', () => {
let sim: ForgeSimulator;
beforeAll(async () => {
sim = createSimulator();
await sim.deploy(resolve(import.meta.dirname, '..'));
});
afterAll(async () => {
await sim.stop();
});
it('creates a thing', async () => {
const result = await sim.invoke('createItem', { title: 'Hello' });
expect(result.success).toBe(true);
});
});deploy() reads your manifest.yml, imports your handlers, runs any scheduled triggers (like migrations), and wires everything up.
forge-sim reads your standard, unmodified manifest.yml. The format is Atlassian's, and the authoritative documentation is the Forge manifest reference. If you're new to Forge manifests, start there.
What is forge-sim's job is how each module type maps to the sim API that exercises it:
| Manifest module | Exercise with | Guide section |
|---|---|---|
UI modules (jira:issuePanel, macros, …) |
sim.ui.render('<key>') / sim.invoke('<resolver fn>', …) |
UIKit 2 Rendering |
trigger |
sim.fireTrigger('avi:jira:created:issue', payload) |
Trigger Tests |
scheduledTrigger |
sim.fireScheduledTrigger('<key>') (every scheduled trigger also fires once at deploy() — opt out with fireScheduledTriggers: false) |
Trigger Tests |
consumer |
sim.queue.push('<queue>', { body }) or Queue.push() in app code |
Queue / Consumer Tests |
webtrigger |
sim.fireWebTrigger('<key>', request) |
Trigger Tests |
A few sim-specific notes:
app.idcan be any string locally — no registration needed.permissionsare parsed but not enforced (no permission failures locally); keep them accurate so the app deploys cleanly to real Forge.resourcespointing at JSX/TSX are compiled on the fly.
Creates a fresh simulator instance. Call once per test suite (or per test if you need isolation).
import { createSimulator } from 'forge-sim';
// Defaults — clean slate
const sim = createSimulator();
// With pre-seeded storage
const sim = createSimulator({
initialStorage: {
'config:theme': { mode: 'dark' },
},
});
// With mock context (accountId, cloudId, etc.)
const sim = createSimulator({
context: {
accountId: 'user-123',
cloudId: 'cloud-abc',
},
});Retrieves the active simulator instance from anywhere. Useful in shared test utilities:
import { getSimulator } from 'forge-sim';
// In a test helper
function seedTestData() {
const sim = getSimulator();
return sim.kvs.set('testKey', { value: 42 });
}Loads your manifest, imports handler modules, fires scheduled triggers, and returns a summary:
const result = await sim.deploy('./my-forge-app');
result.loadedFunctions; // ['myResolver', 'migrationFn', 'onIssueFn']
result.loadedResources; // ['main', 'admin-page']
result.errors; // [{ functionKey, error }]; empty if all goodIf your app uses Forge SQL, start the embedded MySQL before deploying (migrations often run on deploy):
await sim.sql.start();
await sim.deploy('./my-app');CI note: the first
sim.sql.start()on a machine downloads the MySQL binary (a few hundred MB, cached in the OS temp dir undermysqlmsn/binaries): the one place a network connection is needed. Subsequent runs use the cache; on ephemeral CI runners, cache that directory to avoid re-downloading. Everything else runs fully offline. All simulator servers (MySQL included) bind to loopback only and never accept remote connections.
Calls a resolver function, the same way the Forge UI bridge does:
const result = await sim.invoke('getItems', { page: 1 });
// result is whatever your resolver returnsThe first argument is the function key defined in your Forge resolver (via resolver.define('getItems', ...)), not the manifest function key.
Test your resolver handlers through sim.invoke():
it('creates and retrieves an item', async () => {
const created = await sim.invoke('createItem', {
title: 'Test Item',
priority: 'high',
});
expect(created.success).toBe(true);
expect(created.id).toBeDefined();
const fetched = await sim.invoke('getItem', { id: created.id });
expect(fetched.title).toBe('Test Item');
});
it('returns error for missing item', async () => {
const result = await sim.invoke('getItem', { id: 'nonexistent' });
expect(result.error).toBe('Not found');
});If your app uses @forge/sql, start the embedded database first:
let sim: ForgeSimulator;
beforeAll(async () => {
sim = createSimulator();
await sim.sql.start(); // Starts embedded MySQL
await sim.deploy('./my-app'); // Runs migrations if your app has them
});
afterAll(async () => {
await sim.stop(); // Stops MySQL + cleans up
});
it('queries data correctly', async () => {
await sim.invoke('createRecord', { name: 'Alice', score: 95 });
await sim.invoke('createRecord', { name: 'Bob', score: 87 });
const result = await sim.invoke('getTopScorers', { limit: 10 });
expect(result.records).toHaveLength(2);
expect(result.records[0].name).toBe('Alice');
});
// You can also query SQL directly for assertions
it('schema has the right tables', async () => {
const tables = await sim.sql.query('SHOW TABLES');
const names = tables.map(r => Object.values(r)[0]);
expect(names).toContain('users');
expect(names).toContain('scores');
});Read and write KVS directly for setup/assertions:
it('stores user preferences', async () => {
await sim.invoke('savePreferences', {
theme: 'dark',
language: 'en',
});
// Assert directly against KVS
const stored = await sim.kvs.get('prefs:user-123');
expect(stored.theme).toBe('dark');
});
it('uses pre-seeded config', async () => {
// Seed before invoking
await sim.kvs.set('config:feature-flags', {
newDashboard: true,
betaSearch: false,
});
const result = await sim.invoke('getFeatureFlags');
expect(result.newDashboard).toBe(true);
});Fire trigger events and assert on side effects:
it('handles issue created event', async () => {
const results = await sim.fireTrigger('avi:jira:created:issue', {
issue: {
key: 'TEST-1',
fields: { summary: 'Bug report', issuetype: { name: 'Bug' } },
},
});
// fireTrigger returns an array, one result per matching handler
expect(results).toHaveLength(1);
// Check side effects (e.g., trigger stored something)
const log = await sim.kvs.get('audit:TEST-1');
expect(log).toBeDefined();
});Push to queues directly and verify consumer processing:
it('consumer processes queue jobs', async () => {
// Push directly to a queue defined in your manifest
await sim.queue.push('emailQueue', {
body: { to: 'user@example.com', subject: 'Hello' },
});
// In forge-sim, consumers fire synchronously after push
// Assert on whatever the consumer does (KVS write, SQL insert, etc.)
const sent = await sim.kvs.get('email:sent:latest');
expect(sent.to).toBe('user@example.com');
});By default, consumers run sequentially: push() processes events one at a time and resolves after every consumer has finished. That is deliberate. Deterministic ordering surfaces fan-out bugs that real Forge's async queues would race-cover.
Real Forge processes queue events concurrently, though, so consumer code with read-modify-write patterns can hide race conditions the sequential default never triggers. forge-sim has a two-part rig for flushing these out:
- Concurrent mode: pass
queueMode: 'concurrent'tocreateSimulator(), or flip it at runtime withsim.queue.setMode('concurrent'). Events within a push are processed in parallel. Per-eventconcurrencykeys are still honored as shared semaphores across queues, matching the Forge spec. - KVS latency:
sim.kvs.setLatency(true)makes every KVS operation yield a macrotask before completing (pass a number for a random delay between 0 and that many milliseconds instead;falseturns it off). Without this, in-memory KVS calls complete so fast that concurrent consumers rarely interleave. With it, read-modify-write windows actually open up.
it('counter consumer survives concurrent delivery', async () => {
sim.queue.setMode('concurrent');
sim.kvs.setLatency(true); // yield between KVS ops so interleavings happen
await sim.queue.push('counterQueue', [
{ body: { amount: 1 } },
{ body: { amount: 1 } },
{ body: { amount: 1 } },
]);
// A consumer doing get() -> add -> set() loses updates here.
const total = await sim.kvs.get('counter');
expect(total).toBe(3);
});Even in concurrent mode, push() resolves only after all events in the job settle, so the test keeps a deterministic finish line: no sleeps, no polling.
Recommended split: keep the sequential default for functional tests, then add one focused concurrent + latency test per consumer that mutates shared state.
Mock Jira, Confluence, or Bitbucket API responses:
beforeAll(() => {
sim.mockProductRoutes('jira', {
'GET /rest/api/3/myself': {
accountId: 'user-123',
displayName: 'Test User',
},
'POST /rest/api/3/search/jql': {
total: 5,
issues: [
{ key: 'TEST-1', fields: { summary: 'Issue 1' } },
],
},
});
});
it('fetches current user from Jira', async () => {
const result = await sim.invoke('getCurrentUser');
expect(result.displayName).toBe('Test User');
});Update mocks mid-test to simulate changing conditions:
it('handles empty search results', async () => {
sim.mockProductRoutes('jira', {
'POST /rest/api/3/search/jql': { total: 0, issues: [] },
});
const result = await sim.invoke('searchIssues', { jql: 'project = EMPTY' });
expect(result.issues).toHaveLength(0);
});Calls to mockProductRoutes() merge: routes accumulate across calls as if
they'd all been passed in one call, and re-registering the same
"METHOD /path" key updates that route's response in place, so the mid-test
update above changes only the search route and leaves GET /rest/api/3/myself
intact. Matching is first-match-wins in registration order with prefix path
matching. Wipe everything with sim.reset().
A bare object as a route value always means "200 OK with this body". To test
failure handling (rate limits, 404s, permission errors), wrap the route value
with mockResponse(status, body?, headers?):
import { mockResponse } from 'forge-sim';
sim.mockProductRoutes('jira', {
// Plain body → 200 OK (the common case, unchanged)
'GET /rest/api/3/myself': { accountId: 'user-123' },
// Explicit status / headers / empty body
'PUT /rest/api/3/issue/FAIL-1': mockResponse(500, { error: 'boom' }),
'POST /rest/api/3/search/jql': mockResponse(429, { msg: 'slow down' }, { 'Retry-After': '60' }),
'DELETE /rest/api/3/version/10001': mockResponse(204),
});Just like real Forge, requestJira() does not throw on non-2xx; your app
code sees res.ok === false and res.status === 429, so you're testing the
error path your resolver actually has (or is missing):
it('surfaces the rate limit to the caller', async () => {
const result = await sim.invoke('searchIssues', { jql: 'x' });
expect(result).toEqual({ error: 'Jira rate limited us, try again later' });
});Function route values can return a mockResponse(...) too, so a route can
succeed on one call and fail on the next:
sim.mockProductRoutes('jira', {
'PUT /rest/api/3/issue/:key': (path: string) =>
path.endsWith('FAIL-1') ? mockResponse(500, { error: 'oops' }) : { ok: true },
});mockResponse() returns a plain tagged object, so it survives JSON
serialization. Over the MCP boundary (where you can't import the factory),
construct the literal shape directly:
{ "__forgeSimMockResponse": true, "status": 500, "body": { "error": "boom" } }If your app calls @forge/llm to talk to Claude, mock the responses with sim.llm.mockResponse() so tests stay offline and burn zero tokens.
import { describe, it, expect, beforeEach } from 'vitest';
import { createSimulator, type ForgeSimulator } from 'forge-sim';
let sim: ForgeSimulator;
beforeEach(async () => {
sim = createSimulator();
await sim.deploy('./my-app');
});
it('summarizes an issue', async () => {
// Queue an LLM response; consumed FIFO on the next chat() call
sim.llm.mockResponse({ content: 'A 2-sentence summary of the issue.' });
const result = await sim.invoke('summarizeIssue', { issueKey: 'PROJ-1' });
expect(result.summary).toBe('A 2-sentence summary of the issue.');
});For multi-turn agent flows or repeated calls, queue several mocks at once:
sim.llm.mockResponses(
{ content: 'First response' },
{ content: 'Second response' },
{ content: 'Third response' },
);Responses are consumed FIFO: mockResponses(a, b, c) is equivalent to three separate mockResponse() calls.
sim.llm.getHistory() returns the full { prompt, response } list of calls the simulator has handled. Use it to assert on prompt content, tool calls, model choice, or that no call happened at all:
it('does not call the LLM when the cache is warm', async () => {
// ... seed the cache ...
await sim.invoke('summarizeIssue', { issueKey: 'PROJ-1' });
expect(sim.llm.getHistory()).toHaveLength(0);
});
it('feeds the issue title into the prompt', async () => {
sim.llm.mockResponse({ content: 'ok' });
await sim.invoke('summarizeIssue', { issueKey: 'PROJ-1' });
const calls = sim.llm.getHistory();
expect(calls).toHaveLength(1);
expect(JSON.stringify(calls[0].prompt.messages)).toContain('Fix login bug');
});If your app uses Claude's tool-use API, mock the tool call directly:
sim.llm.mockResponse({
content: 'I need to look that up.',
tool_calls: [
{
id: 'call_1',
type: 'function',
index: 0,
function: { name: 'searchIssues', arguments: { query: 'auth bugs' } },
},
],
finish_reason: 'tool_use',
});sim.reset() clears queued mocks and history along with everything else. For finer control:
sim.llm.reset(); // clears mocks + history, leaves the rest of the sim aloneIf no mock is queued and ANTHROPIC_API_KEY is set (via env or forge-sim auth --llm), the simulator forwards the call to the real Anthropic API. In CI you almost always want this to be impossible: either always queue a mock before every chat(), or unset the env var. When both are absent, chat() throws LlmApiError(NO_API_KEY) instead of silently doing nothing.
forge-sim includes a headless UIKit renderer. Your app's JSX runs through the same @forge/react reconciler that Forge uses, producing a ForgeDoc, a JSON tree representing the rendered UI. You can inspect it, query it, simulate interactions, and assert on it. No browser needed.
it('renders the issue panel', async () => {
// Render a UI module from your manifest (by module key).
// `issueKey` is a top-level option — it fetches the issue (mock or real)
// and hydrates the module context (extension.issue, project, …).
// Same for `projectKey`, `contentId`, `spaceKey`. Use the `context`
// option only for raw context-field overrides (accountId, locale, …).
await sim.ui.render('issue-panel', { issueKey: 'PROJ-42' });
// Wait for async data to load (e.g., useEffect → invoke → re-render)
const doc = await sim.ui.waitForContent('issue-panel', 'PROJ-42');
// Extract all text from the rendered tree
const text = sim.ui.getTextContent(doc);
expect(text).toContain('PROJ-42');
expect(text).toContain('Fix the bug');
});The ForgeDoc is a simple tree of { type, props, children } nodes. Use the built-in query helpers:
it('renders a button and a badge', async () => {
await sim.ui.render('my-panel');
const doc = sim.ui.getForgeDoc('my-panel')!;
// Find all components by type
const buttons = sim.ui.findByType(doc, 'Button');
expect(buttons).toHaveLength(2);
// Find a specific button by its text content
const saveBtn = sim.ui.findByTypeAndText(doc, 'Button', 'Save');
expect(saveBtn.props.appearance).toBe('primary');
// Find by type; works for any UIKit component
const badges = sim.ui.findByType(doc, 'Badge');
expect(badges[0].props.appearance).toBe('added');
});Click buttons, change form values, and assert on the re-rendered UI:
it('toggles theme on button click', async () => {
await sim.ui.render('settings-page');
const doc = await sim.ui.waitForContent('settings-page', 'Theme: light');
// Find the toggle button and click it
const toggleBtn = sim.ui.findByTypeAndText(doc, 'Button', 'Toggle');
sim.ui.interact(toggleBtn, 'onClick');
// Wait for re-render after state change
const updated = await sim.ui.waitForContent('settings-page', 'Theme: dark');
expect(sim.ui.getTextContent(updated)).toContain('Theme: dark');
});
// Or use the shorthand: find + interact + get updated doc in one call
it('shorthand: interactWith', async () => {
await sim.ui.render('settings-page');
await sim.ui.waitForContent('settings-page', 'Theme: light');
const { updatedDoc } = await sim.ui.interactWith('Button', {
matchText: 'Toggle',
event: 'onClick',
});
expect(sim.ui.getTextContent(updatedDoc!)).toContain('Theme: dark');
});Each module gets its own ForgeDoc tree. Render multiple modules and assert independently:
it('renders two panels without cross-contamination', async () => {
await sim.ui.render('issue-panel', { issueKey: 'TEST-1' });
await sim.ui.render('admin-panel');
await sim.ui.waitForContent('issue-panel', 'TEST-1');
await sim.ui.waitForContent('admin-panel', 'Admin');
const issueDoc = sim.ui.getForgeDoc('issue-panel')!;
const adminDoc = sim.ui.getForgeDoc('admin-panel')!;
// Content is isolated
expect(sim.ui.getTextContent(issueDoc)).not.toContain('Admin');
expect(sim.ui.getTextContent(adminDoc)).not.toContain('TEST-1');
// But they share KVS
await sim.kvs.set('shared-key', 'hello');
const val = await sim.kvs.get('shared-key');
expect(val).toBe('hello');
});When a test fails and you need to see the UI tree:
it('debug example', async () => {
await sim.ui.render('my-panel');
const doc = await sim.ui.waitForContent('my-panel', 'Ready');
// Prints a readable tree to console
console.log(sim.ui.prettyPrint(doc));
// <Root>
// <Stack space="space.200">
// <Text>
// <String text="Ready" />
// </Text>
// <Button appearance="primary">
// <String text="Save" />
// </Button>
// </Stack>
// </Root>
});| Method | Description |
|---|---|
render(moduleKey, options?) |
Render a UIKit module. Options: { issueKey?, projectKey?, contentId?, spaceKey?, context?, extension?, macroConfig? } — see module contexts |
getForgeDoc(moduleKey?) |
Get the current ForgeDoc tree. Omit key for most recent render. |
waitForRender() |
Wait for the next render from any module. |
waitForContent(moduleKey, text) |
Wait until rendered text includes the given string and the UI settles (renders quiet + no pending invokes). The returned doc is safe to interact with immediately. |
settle(moduleKey?, options?) |
Wait until the UI stops re-rendering and no resolver invokes are in flight; returns the latest doc. Use after a manual render() before fillField/interact. |
fillField(moduleKey, name, value) |
Fire the onChange a user would: input event for Textfield/TextArea, { label, value } option object for Select (arrays for isMulti). |
submitForm(moduleKey, values?) |
Fill fields by name (optional), then fire the <Form> onSubmit with a synthetic event. Validation applies as in production — a blocked submit leaves errors visible in the tree. Returns the onSubmit result. |
findByType(doc, type) |
Find all nodes of a component type (e.g., 'Button', 'Text'). |
findByTypeAndText(doc, type, text?) |
Find a node by type and optional text content. |
getTextContent(doc) |
Extract all text from a ForgeDoc subtree. |
interact(node, event, ...args) |
Simulate an event (e.g., 'onClick') on a ForgeDoc node. |
interactWith(type, options?) |
Find + interact + return updated doc in one call. |
prettyPrint(doc) |
Pretty-print the tree for debugging. |
refresh(moduleKey) |
Re-render a module with its last context. |
getRenderedModules() |
List all module keys that have been rendered. |
reset() |
Clear the most recent render. |
resetAll() |
Clear all rendered modules. |
onSubmit(listener) |
Listen for view.submit() calls. Callback: (moduleKey, payload) => void. |
onClose(listener) |
Listen for view.close() calls. Callback: (moduleKey, payload) => void. |
onRefresh(listener) |
Listen for view.refresh() calls. Callback: (moduleKey, payload) => void. |
In real Forge, view.submit(), view.close(), and view.refresh() (from @forge/bridge) are handled by the host product (Jira, Confluence). In forge-sim's headless mode, these emit events you can listen for in tests.
This is particularly useful for custom fields, workflow modules, and any module with multiple views (edit → submit → view transition).
forge-sim's manifest parser splits custom fields into sub-module keys automatically:
jira:customFieldTypekeypriority-score→priority-score--viewandpriority-score--edit
Each sub-module renders independently with its own ForgeDoc:
it('renders view and edit sub-modules', async () => {
await sim.ui.render('priority-score--view', { issueKey: 'PROJ-1' });
const viewDoc = sim.ui.getForgeDoc('priority-score--view')!;
expect(sim.ui.getTextContent(viewDoc)).toContain('42');
await sim.ui.render('priority-score--edit', { issueKey: 'PROJ-1' });
const editDoc = sim.ui.getForgeDoc('priority-score--edit')!;
expect(sim.ui.getTextContent(editDoc)).toContain('Edit');
// Both coexist: isolated ForgeDoc trees, shared KVS/SQL
expect(sim.ui.getRenderedModules()).toEqual(
expect.arrayContaining(['priority-score--view', 'priority-score--edit'])
);
});When your edit view calls view.submit(payload), capture the payload with onSubmit:
it('edit view submits the new value', async () => {
let submitted: any;
sim.ui.onSubmit((moduleKey, payload) => {
submitted = payload;
});
await sim.ui.render('priority-score--edit');
const doc = sim.ui.getForgeDoc('priority-score--edit')!;
// Fill in the form and save
const saveBtn = sim.ui.findByTypeAndText(doc, 'Button', 'Save');
await sim.ui.interact(saveBtn, 'onClick');
expect(submitted).toEqual({ value: 99 });
});forge-sim doesn't auto-transition between views (that's the host product's job). Instead, you orchestrate it yourself. Capture the submit, then re-render the view with the new value:
it('submit from edit updates the view', async () => {
let submittedPayload: any;
sim.ui.onSubmit((_key, payload) => {
submittedPayload = payload;
});
// 1. Render view — shows current value
await sim.ui.render('priority-score--view');
expect(sim.ui.getTextContent(sim.ui.getForgeDoc('priority-score--view')!))
.toContain('Current value: 42');
// 2. Render edit and submit
await sim.ui.render('priority-score--edit');
const editDoc = sim.ui.getForgeDoc('priority-score--edit')!;
await sim.ui.interact(
sim.ui.findByTypeAndText(editDoc, 'Button', 'Save'),
'onClick',
);
// 3. Capture the payload and re-render view with new context
expect(submittedPayload).toEqual({ value: 99 });
await sim.ui.render('priority-score--view', {
context: { fieldValue: submittedPayload.value },
});
});Same pattern for view.close() and view.refresh():
sim.ui.onClose((moduleKey, payload) => {
console.log(`${moduleKey} closed with`, payload);
});
sim.ui.onRefresh((moduleKey, payload) => {
console.log(`${moduleKey} refreshed with`, payload);
});Each listener returns an unbind function:
const unbind = sim.ui.onSubmit((moduleKey, payload) => {
// ...
});
// ... later
unbind(); // stop listeningOne simulator per describe block. Create in beforeAll, stop in afterAll. This keeps tests isolated while sharing the deploy overhead.
Use sim.sql.start() only when needed. It launches an embedded MySQL process. If your app doesn't use @forge/sql, skip it; tests will be faster.
Mock product APIs before deploy. If your app's scheduled triggers hit Jira/Confluence on startup, set up mocks first:
beforeAll(async () => {
sim = createSimulator();
sim.mockProductRoutes('jira', { /* ... */ });
await sim.sql.start();
await sim.deploy('./my-app'); // Safe — API calls during deploy hit mocks
});Assert on internals. Unlike production Forge, you have direct access to KVS, SQL, and queues. Use them for assertions instead of only testing through resolvers:
// Instead of just checking the resolver response...
const result = await sim.invoke('deleteItem', { id: '123' });
expect(result.success).toBe(true);
// ...also verify the actual state
const item = await sim.kvs.get('item:123');
expect(item).toBeUndefined();sim.stop() cleans up everything. It stops the MySQL process, clears state, and resets the global simulator reference. Always call it in afterAll.
Breakpoints work in your test files and in your app source: forge-sim's loader transpiles .ts/.tsx/.jsx app modules with inline source maps, so you can step from a test straight into the resolver it invokes.
The zero-config way is VS Code's JavaScript Debug Terminal (Command Palette → "Debug: JavaScript Debug Terminal"). Open one, set breakpoints, run the tests in it:
npx vitest run # whole suite
npx vitest run board.test.ts -t 'adds a card' # one testThe debugger auto-attaches to vitest's worker processes, so this works with the default parallel pool.
For an F5 launch configuration, run vitest through Node directly and disable file parallelism so execution stays in one steppable process:
{
"name": "Debug vitest",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": ["run", "--no-file-parallelism"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
}The Vitest VS Code extension also works; it adds per-test run/debug buttons in the editor gutter using the same attach mechanism.
Two notes:
- Timeouts still count wall-clock time. Sitting at a breakpoint past
testTimeoutfails the test when you resume. Bump the timeout invitest.config.ts(or pass--testTimeout=0) for debug sessions. - Debugging the dev server instead of tests? See Local development § Debugging.
The things that have eaten the most debugging time. Check here first when a test does something weird.
sim.ui.render() only awaits the initial reconcile. If your component fetches data in a useEffect and re-renders when it lands, the rendered tree from render() is the pre-fetch state (<Text>Loading…</Text> or similar). Fix: chase it with sim.ui.waitForContent(moduleKey, expectedText), which waits for the text and for the UI to settle (renders quiet, zero pending invokes), so the doc it returns is safe to fillField/interact with immediately. If you rendered manually and don't have a text landmark, await sim.ui.settle(moduleKey) does the same settling without the text match. Same applies to the MCP forge_ui_render tool; use forge_ui_wait_for to settle. See renderer.md § Server-mode useEffect and async state.
Every scheduled trigger fires once during sim.deploy(). This mirrors real Forge (each scheduled trigger starts ~5 minutes after deployment, and redeploys reset them all), and it's what runs migration triggers before your tests touch the database. Two consequences:
- If a scheduled handler calls
requestJira(), set up mocks before deploy:
sim = createSimulator();
sim.mockProductRoutes('jira', { /* … */ }); // ← before deploy
await sim.sql.start();
await sim.deploy('./my-app'); // safe now- If a scheduled job has side effects you don't want on every deploy (daily digest, outbound webhook), opt out and fire it explicitly:
await sim.deploy('./my-app', { fireScheduledTriggers: false });
// …later, when the test wants it:
await sim.fireScheduledTrigger('daily-digest');If your app runs migrations from a scheduled trigger, sim.sql.start() must run before sim.deploy(): scheduled triggers fire during deploy, so otherwise the migration trigger fires against a non-existent database and fails. Always:
await sim.sql.start();
await sim.deploy('./my-app');Unlike @forge/api / @forge/kvs / etc., there is no forge-sim/shims/forge-sql alias. The real @forge/sql package talks to the simulator through a runtime hook (global.__forge_fetch__) that createSimulator() installs automatically. If you add it to your vitest.config.ts alias map, you'll get module-resolution errors. Just leave it out.
sim.reset() is total: KVS, SQL, queues, resolvers, logs, LLM mocks, the lot, including module wiring (consumer registrations, entity schemas), so you must re-deploy() after a reset. If your tests share a sim across it() blocks via beforeAll/afterAll, calling reset() between tests can wipe the mock product routes you set up in beforeAll. Either re-mock in beforeEach, or use targeted resets like sim.llm.reset() / sim.kvs.clear() / sim.queue.clear() that leave the rest alone.
The targeted clear() methods are safe beforeEach hygiene by design: they wipe runtime data but preserve module wiring. sim.queue.clear() keeps consumers registered; sim.kvs.clear() keeps entity schemas. Wiring only changes at deploy() and reset().
ForgeDoc serializes function props as { __fn__: '<id>' } tokens, and each render produces fresh IDs. Don't snapshot a tree expecting onClick IDs to be stable, and don't compare two ForgeDocs structurally if either has handlers. See renderer.md § Function serialization.
Gotchas that live in the browser rather than the test runner (Atlaskit theming, React.StrictMode) are in Local development § Common gotchas. MCP-specific gotchas (stale daemon after rebuild) are in MCP server § Common gotchas.
Every TypeScript code block in README.md and docs/**/*.md is typechecked against the live source API (src/__tests__/docs-examples-typecheck.test.ts), and yaml manifest blocks are parsed for unknown module types. New blocks are guarded automatically: a bare ```ts fence is checked by default.
Three fence flags control this (they go after the language token, and are invisible in rendered markdown):
| Fence | Meaning |
|---|---|
```ts |
Typechecked (the default) |
```ts no-check |
Skipped — for output shapes, type-signature listings, and pseudo-code |
```ts run=<file>#<region> |
Must match a // #region <name> of a real test file that executes in the normal suite |
Fragment blocks (no imports) compile inside an ambient world where sim, createSimulator, route, WhereConditions, etc. are already declared, the context a reader carries between blocks. Blocks with imports must be copy-paste runnable as-is.
To make an example executable, add it as a // #region in a test under src/__tests__/docs-examples/ (or in the fixture app at src/__tests__/fixtures/docs-sample-app/), then flag the doc block with run=<file>#<region>; the path resolves relative to src/__tests__/. docs-examples-sync.test.ts keeps the two in lockstep (whitespace-normalized), so the example in the docs is exactly the code that ran.