Forge-sim: a local Forge runtime for testing, AI agents, and local dev

Hey everyone, wanted to share the first public release of some tooling I’ve built for Forge.

It’s a simulated Forge runtime that runs your app locally with no cloud deploy. There are three ways to use it:

  • A test library for running Automated Tests. Designed specifically for CI/CD testing. Headless UIKit2 rendering, shims for most forge APIs. Drop-in configurations for vitest and jest.
  • A CLI and stdio MCP server for AI driven development that deploys and tests against a sandboxed, simulated runtime.
  • A development server that serves your app locally for more traditional browser driven development and testing.

I originally built it for a fairly complex Forge app I got tired of redeploying to Atlassian’s cloud to test the smallest change. Then I decided to build out the tool to be more general purpose. There is a lot of surface to the Forge platform, so please let me know if you try it out and run into any problems.

This is a personal project. I don’t work for Atlassian and this project is not affiliated with Atlassian in any way.

The redeploy loop is the worst part of Forge development, so this is welcome.

The thing I’d check first is where it diverges quietly, because the behaviours that cost us real debugging time are the ones a shim tends to get friendly-right. Invocations are at-least-once, and Atlassian confirmed to us that even successful ones can be delivered twice about a second apart, so anything non-idempotent passes locally and double-fires in production. KVS deletes expired keys lazily, up to 48h, so a TTL looks like a dedup window in a test and isn’t one. And jira:workflowPostFunction is hard-killed at 25s with no timeoutSeconds escape for that module type, which fails very differently from a local function that overruns.

Does it model any of that yet, or is it faithful happy path for now? Genuinely asking, since that’s what decides whether a green CI run means anything. We gave up on simulating those three and went black-box against a live instance instead, harness and its documented blind spots here if it’s useful reference.

Yes and no. I focused on the happy path but it should be possible to test some of the quirks you listed using the forge-sim API. You can easily test idempotency by firing an invocation twice in succession. Queues in forge-sim have a built-in concurrency mode but you can also add it to any invocation by doing something like

// double tap simulation one second apart
let run1 = sim.fireTrigger('avi:jira:updated:issue', payload),
await setTimeout(1000);
let run2 = sim.fireTrigger('avi:jira:updated:issue', payload),

await Promise.all([run1, run2]);

It’s not going to be exactly the same as concurrent runs in separate lambda containers but I think it should surface most concurrency/idempotency problems.

TTL’s aren’t in there because tests typically run in seconds or minutes. I can see the value in testing the behavior you point out. Again it sounds like something that could simulate using the test API if you know what conditions to expect.

Going over timeoutSeconds is logged to the console in dev mode and reported back to the caller in the MCP. For the test api, it’s recorded in the forge logs so sim.getLogs will have it. My concern with managing timeouts was that it wouldn’t be accurate locally compared to actual Forge. If a person has long running tasks that can’t be offloaded to a consumer and they’re typically going over 25s, it’s probably critical to optimize this against actual Forge. That was my thinking at least.

It’s a small community and the project is open-source. I welcome any contributions you think would be useful to you.

The double-tap example is the right shape, and your timeout reasoning is fair. Reproducing the 25s wall locally would only give people a number they can’t trust.

What I’d want isn’t accurate timing though, it’s fault injection: “kill this invocation at the point I say” rather than a simulated clock. The thing under test is whether the handler leaves half-applied state when it dies mid-flight, which is a deterministic assertion rather than a timing one. Same for TTL, no time travel needed, only “make this key vanish now” so the path that re-reads and finds nothing gets exercised.

On your realtime scoping issue, a datapoint from production in case it saves you digging. We run both planes in a Forge app and they behave as separate delivery paths: publish is only available from resolver context, so anything firing from a queue consumer, a product trigger or a scheduled job has to use publishGlobal, and in our app those events only ever arrive at subscribeGlobal. publishGlobal also doesn’t enforce app permission scopes, which is why we keep those payloads to non-sensitive metadata. I haven’t tested cross-module isolation, so I can’t speak to that half.