How to run real end-to-end UI tests against a deployed Forge app (2026)
Key takeaways
- Forge Custom UI renders inside a sandboxed iframe, so every Playwright locator has to be scoped through the frame.
- A REST API token can't open a browser session; a persistent Chrome profile keeps Atlassian's 2FA from re-challenging every run.
- Config video/trace capture doesn't apply to a context you launch yourself — you record it in the fixture instead.
- One on-disk profile can't be opened by two workers. Copy it per worker if you need more, but specs that write the same Jira data still belong on one.
Every Forge developer I know has shipped an app whose unit tests were green and which still fell over the first time a real user opened it in Jira. The resolver was fine. What broke was everything around it: the app renders inside a sandboxed iframe, on a page you reach through three menu clicks, behind a login that wants a second factor. None of that gets exercised by a Node test that imports your resolver and calls it directly. So you find out it's broken when someone tells you it's broken.
I got tired of that and built a harness that opens the real product in a real browser, navigates to the deployed app, and asserts on what actually renders. It drives five of our Forge apps across Jira and Confluence now, and it has caught fifteen bugs that every offline test waved through. Here's how it works, including the two things that cost me an afternoon each so you can skip them.
Prerequisites — a Forge app deployed to a dev environment you can log into, Node 18+, and Playwright (
npm i -D @playwright/test && npx playwright install chromium). You'll need the app's UUID (from your manifest'sapp.id, dropping theari:cloud:ecosystem::app/prefix, or from the Developer Console) and the environment id (from the Developer Console, or theforge deploy --verboseoutput —forge environments listshows environment names, not the id you need). This is written for Custom UI. Modern UI Kit (@forge/react) also renders through a hosted iframe, so most of it applies — but dump the frames first (there's a snippet below) and confirm where your app actually lives before you assume anything.
Why can't I just point Playwright at the app?
Auth is the first wall, and the token you already have is useless against it. A Forge REST API token talks to the REST API; it can't mint a browser session, so there's no header that makes you "logged in". You have to log in through the browser for real. And the moment a script types that login itself, Atlassian can ask for a one-time code, sent by email or from the account's second factor, and a plain script has nothing to answer it with unless you set the test account up for that (the CI section below covers how).
The token rabbit hole. I spent a while trying to make a headless login stick: export
storageState, replay it, get challenged for a code, repeat. The fix wasn't a smarter token. It was to stop throwing away the login between runs — keep the whole browser profile on disk and Atlassian stops treating you as a stranger.
The second wall is the iframe. Forge Custom UI renders your React app inside an iframe for isolation, which is documented and deliberate. Your page.getByRole(...) calls run against the host page, and the host page doesn't contain your app, so they match nothing while you stare at a button you can plainly see and wonder why it isn't found. Everything for your app's UI has to be scoped through the frame.
A persistent profile that captures its own evidence
Instead of a fresh context per run, launch a persistent context pointed at a directory you keep around, and wrap it in a fixture. Log in by hand the first time; every run after reuses the cookies and the remembered-device token Atlassian stored in that profile, so the second factor stays quiet.
The fixture is also where you turn on video and tracing, and this is the part that bit me: Playwright's use: { video, trace } config only applies to the built-in page/context fixtures. A context you launch yourself ignores it completely. So you record evidence in the launch, or you get none.
**tests/fixtures.ts**
import { test as base, chromium, type BrowserContext } from "@playwright/test";
import path from "node:path";
// Gitignore this — it holds your session cookies and the remembered-device token.
const PROFILE_DIR = path.resolve(".auth/profile");
export const test = base.extend<{ site: BrowserContext }>({
site: async ({}, use, testInfo) => {
// Headed so you can watch it. Headless works too once the profile is warm (see the CI section).
// recordVideo lives here because config `use: { video }` never reaches a context you launch.
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: false,
viewport: { width: 1440, height: 900 },
recordVideo: { dir: testInfo.outputDir },
});
await ctx.tracing.start({ screenshots: true, snapshots: true });
try {
await use(ctx);
} finally {
// Runs even when an assertion throws, so the profile lock is always released.
// pages()[0] is the bootstrap about:blank a persistent context opens with, so
// screenshot the LAST page — the one the test actually drove.
if (testInfo.status !== testInfo.expectedStatus) {
await ctx.pages().at(-1)?.screenshot({ path: path.join(testInfo.outputDir, "failure.png") });
}
await ctx.tracing.stop({ path: path.join(testInfo.outputDir, "trace.zip") });
await ctx.close();
}
},
});
export { expect } from "@playwright/test";
- Warm the profile once a throwaway script that launches the same PROFILE_DIR headed, opens your site, and sits on page.pause() while you complete SSO and the 2FA prompt by hand.
- Reuse it everywhere every spec imports this fixture, so it's already authenticated and already recording.
- Fail fast when it expires the session idles out after weeks; if a run lands on id.atlassian.com, stop and re-warm the profile instead of retrying blindly.
Deep-link straight to the app
Don't click through the product nav to reach your app; it's slow and it breaks. Forge app pages have stable deep links built from the app UUID and the environment id. Use the bare app UUID, not the full ARI. One asymmetry to know: a Confluence global page carries the module's route in the path, while a Jira global page doesn't.
**tests/deeplink.ts**
const SITE = process.env.SITE_URL!; // https://your-dev.atlassian.net
const APP = process.env.FORGE_APP_ID!; // bare UUID from manifest app.id
const ENV = process.env.FORGE_ENV_ID!; // env UUID from the Developer Console / `forge deploy --verbose`
const ROUTE = process.env.FORGE_ROUTE!; // the `route` on your confluence:globalPage module
// jira:globalPage — no route in the path
export const jiraGlobalPage = `${SITE}/jira/apps/${APP}/${ENV}`;
// confluence:globalPage — Confluence needs the module's route in the path
export const confluenceGlobalPage = `${SITE}/wiki/apps/${APP}/${ENV}/${ROUTE}`;
Issue panels and macros are the exception: their parameters get stripped, so they aren't deep-linkable. You reach those by driving the host page that contains them rather than by URL.
Drive the iframe
Now the whole pattern. Go to the deep link, grab the Custom UI frame, wait for the app's root to mount, then assert on the app like any page, just scoped to the frame.
**tests/dashboard.spec.ts**
import { test, expect } from "./fixtures";
import { jiraGlobalPage } from "./deeplink";
test("the dashboard renders and loads its data", async ({ site }) => {
const page = await site.newPage();
await page.goto(jiraGlobalPage);
// Custom UI lives in one iframe; your app mounts into #root inside it.
const app = page.frameLocator('iframe[data-testid="hosted-resources-iframe"]');
await expect(app.locator("#root")).toBeVisible();
// From here it's ordinary Playwright, scoped to `app` instead of `page`.
await expect(app.getByRole("heading", { name: "Portfolio" })).toBeVisible();
await expect(app.getByText("No data").first()).toBeHidden();
});
The one caveat about that selector.
data-testid="hosted-resources-iframe"is an internal Atlassian attribute, not a documented contract. It's held for me across Jira and Confluence through 2026, but if a run suddenly can't find the frame, don't trust me — dump the page's frames and grab whichever one hosts your#root:for (const f of page.frames()) console.log(f.name(), f.url());
The config, and the one line that scales it wrong
Two things earn a mention. Retries: live UI carries real network latency, so a couple in CI is reasonable. Workers: this is the one that will bite you. A single on-disk profile can't be opened by two Chromium processes at once — the directory is locked — so the whole suite has to run on one worker. Playwright parallelizes by default, and the first time a second worker tries to launch the same profile it just dies.
**playwright.config.ts**
import { defineConfig } from "@playwright/test";
export default defineConfig({
// One shared profile → one worker. Chromium locks the user-data-dir; parallel workers can't share it.
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 0,
});
If a single worker is too slow once you have real coverage, the way up is to copy the warm profile into a per-worker temp directory at startup so each worker gets its own lockable copy. I ran on one worker far longer than I expected to before that mattered.
Running it headless in CI: 2FA, TOTP and a copied profile
The fixture above runs headed, and in July its comment said a headless profile trips the new-device check. That was wrong. Our harness has run headless by default since 27 August: you log in once in a visible window with the password and the second factor, and after that nothing types a password again. On our account Atlassian only asks for a code when something types a password, and a saved session goes straight in. That held for a plain storageState too, even though replaying one got me challenged in July (the callout at the top). One exported on 9 October and replayed on 17 October into a fresh headless Chromium, on the machine that holds the profile, opened Jira and Confluence logged in with no code asked. Not everyone sees that. One developer on the community forum, with two-step verification turned off, reports a session saved in a headed browser and reused headless getting a code emailed anyway, so test it on your own account before you build CI on it. Since 26 September every run started with npm run app also copies the profile into a folder of its own and launches on the copy, so two of those runs never fight over one profile lock. When you copy, leave out SingletonLock, SingletonSocket, SingletonCookie and RunningChromeVersion. A copied SingletonSocket is a symlink to the socket of the Chrome that owns the original, and a stale lock makes the next launch fail. On a Mac an APFS clone (fs.cpSync with COPYFILE_FICLONE) took between 58 and 199 milliseconds in the 12 runs we have timings for, so a copy per worker costs nothing. That removes the profile lock as a reason for one worker. It doesn't remove the other one: workers writing to the same Jira data race each other, so our harness still runs full suites on one worker and allows two only for read-only smoke checks.
If your CI has no disk that survives between runs, keep the session instead of the profile, because the session is one file. People in the developer community run one login job, save the session, and hand it to the test jobs from the workflow cache or an encrypted repo; one says it stays valid for at least 24 hours. For that one login, Atlassian's support article for Marketplace partners says there is no way to bypass the emailed code, and among its alternatives is two-step verification on the test account with a TOTP code generated in the test. When you set it up, pick the authenticator app, choose "Can't see the code" and copy the key. Then generate the code with the otpauth package (SHA1, 6 digits, 30 second period) and type it in. In an August 2025 snippet the submit button had the id two-step-verification-submit. I have not run that path, because our harness logs in by hand once. Run that login once per pipeline and share the session with every worker. Developers on the forum worked out that a code only works once, so parallel workers logging in as the same user inside one 30 second window get "incorrect code", and one found that retrying made it worse. One developer also reports Confluence answering "You don't have access to this space" in headless runs right after a successful login, and puts it down to the extra redirects Confluence does.
When one app becomes several
A single app is one spec file, and honestly you can just watch it yourself. The math changes when you're maintaining a handful of them, each with its own transitions and release cadence, and "did that last change break anything in the real product" stops being a question you can answer by hand.
Live testing earns its keep on the static checks too, not just the UI: forge lint cannot see calls made through a request helper, so an app can pass lint, pass deploy, pass install and still 401 on its first write. Only something that calls the real API catches that.
I wrote up running that deploy and install from a pipeline separately: deploying a Forge app through CI/CD in Bitbucket Pipelines.
This is how we keep CogniRunner — our AI-backed Jira workflow validator, full disclosure it's one of ours — green across releases. Every rule type it ships gets driven through the real transition UI on a live site, and a failing run hands back the video plus a written note of what broke instead of a bare red X. If you want to see the kind of app this is built for, it's on the Atlassian Marketplace. The technique is the same whether the app is ours or yours.
Key takeaways
- Keeping the login between runs is the whole ballgame. Everything after it is normal Playwright.
- Scope every locator through the frame. Custom UI is a sandboxed iframe, and the host page has none of your app.
- Config use: { video, trace } doesn't reach a context you launch yourself — record it in the fixture, or you'll ship a suite that captures nothing.
Next steps — if you're newer to the platform, the LLM-powered Forge app tutorial gives you an app worth testing this way, and Dark Mode Done Right in Forge Custom UI pairs well if you want the styling to survive both product themes while you're in there.
The same harness drives LeanZero Management, and its release post covers the scheduling engine those live tests exercise, including the two-engine parity problem that made testing against a real deployment necessary.
That parity problem now has its own write-up: two scheduling engines, one schedule walks through why the two engines carry opposite off-by-one conventions, and the fixed-point harness that keeps the preview honest about what Apply will write.
I don't work for Atlassian, so treat the internal selector as "worked for me" rather than gospel. The shape of this holds regardless of which attribute names the platform uses this month. What's the bug a live test caught for you that every offline test let through?
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.







