# End-to-end testing with Playwright  End-to-end (E2E) tests exercise your Gadget app through a real browser, covering the full stack from the frontend UI through your React Router loaders and actions and into the Gadget database. They complement unit tests by catching integration bugs that only appear when all the layers of your app are running together. This guide covers Playwright setup for two app types: * : React Router apps using email/password authentication * : Embedded Shopify admin apps authenticated via Shopify App Bridge For testing individual functions, actions, and components in isolation, see [unit and integration testing](https://docs.gadget.dev/guides/development-tools/unit-and-integration-testing). ## Set up Playwright  These steps apply to both web apps and Shopify apps. ### Install dependencies  1. Install Playwright and the dotenv helper in your project directory: ```bash yarn add --dev @playwright/test dotenv ``` 2. Install the Chromium browser binary that Playwright drives: ```bash yarn playwright install chromium ``` ### Add test files to your ignore list  3. Create or update your `.ignore` file at the root of your project to prevent local test artifacts from syncing to Gadget: ```markdown // in .ignore test-results/ playwright-report/ ``` Add these same entries to your `.gitignore` file if you are using source control for your Gadget project. ### Add a test script  4. Add a `test:e2e` script to `package.json`: ```json // in package.json { "scripts": { "test:e2e": "playwright test" } } ``` ## Web apps  This section covers E2E testing for Gadget web apps that use email/password authentication and React Router. ### Configure Playwright  5. Create a `playwright.config.ts` file at the root of your project: ```typescript import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; // Load credentials from .env.local so they're available in test files via process.env dotenv.config({ path: ".env.local" }); // Absolute path to the saved auth state -- shared between globalSetup and test files export const AUTH_STATE_PATH = path.join(process.cwd(), "e2e/.auth-state.json"); export default defineConfig({ testDir: "./e2e", fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, timeout: 120_000, reporter: process.env.CI ? "github" : "list", globalSetup: "./e2e/globalSetup.ts", globalTeardown: "./e2e/globalTeardown.ts", use: { baseURL: process.env.GADGET_APP_URL, trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], }); ``` The config options above are chosen specifically for Gadget web apps: * `timeout: 120_000`: Gadget's Vite dev server compiles assets lazily, so first navigation to a page can take 10-20 seconds. The higher timeout prevents false failures while the dev server warms up. * `fullyParallel: false`: Tests run sequentially by default, which avoids race conditions when multiple tests create and delete the same data. * `baseURL`: All `page.goto("/path")` calls resolve relative to the development environment URL. Also add the auth state file to your `.ignore` and `.gitignore` files: ```markdown // in .ignore and .gitignore e2e/.auth-state.json ``` ### Environment variables  Playwright connects to your live development environment, so it needs a few secrets. Create a `.env.local` file at the root of your project and add it to both `.ignore` and `.gitignore`: ```bash # The URL for your environment GADGET_APP_URL=https://your-app--development.gadget.app # The environment name used when constructing the API client GADGET_ENVIRONMENT=development # A Gadget API key with sufficient permissions to read/write test data # Create one in the Gadget editor under Settings → API Keys GADGET_API_KEY=gsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Email and password of the dedicated E2E test user (see next section) TEST_USER_EMAIL=e2e-test@example.com TEST_USER_PASSWORD=a-strong-test-password ``` The `GADGET_API_KEY` is used **only** in `globalSetup` and `globalTeardown` to seed and clean up data via the Internal API. Browser tests themselves authenticate as the test user, not as the API key. ### Create a dedicated test user  E2E tests need a user account to sign in with. Rather than creating a new user on every run, seed a persistent test user directly in your Gadget development environment. #### Create the user  In the Gadget editor, open the API playground and create a `user` model using the internal API: ```typescript await api.internal.user.create({ email: "e2e-test@example.com", password: "a-strong-test-password", emailVerified: true, roles: ["signed-in"], }); ``` This can also be done as part of the below. The following fields are required: * **email**: `e2e-test@example.com`, or whatever you put in `TEST_USER_EMAIL` * **password**: set a known password via the Gadget editor's `changePassword` action * **emailVerified**: `true`, required for auth to succeed * **roles**: `["signed-in"]`, or any custom roles your app requires for access #### Ensure `emailVerified` is `true`  Gadget's email/password auth requires `emailVerified: true` before sign-in succeeds. Your should verify this on every run, in case the flag was reset: ```typescript // ... after creating the API client with the API key const users = await api.user.findMany({ filter: { email: { equals: TEST_USER_EMAIL } }, first: 1, }); if (users[0] && !users[0].emailVerified) { await api.internal.user.update(users[0].id, { emailVerified: true }); } // ... rest of globalSetup ``` This guard is idempotent: it does nothing if the flag is already set. ### Global setup  The `globalSetup` script runs once before the entire test suite. It: 1. Deletes any leftover test data from previous runs. 2. Ensures the test user can sign in. 3. Signs in via a real browser and saves the session cookies and localStorage to disk. Create `e2e/globalSetup.js`: ```typescript import { chromium } from "@playwright/test"; import { Client } from "@gadget-client/your-app-slug"; import dotenv from "dotenv"; import path from "path"; dotenv.config({ path: path.resolve(process.cwd(), ".env.local") }); // Keep in sync with AUTH_STATE_PATH in playwright.config.ts const AUTH_STATE_PATH = path.join(process.cwd(), "e2e/.auth-state.json"); const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL!; const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD!; // Authenticated API client used to inspect and clean up the database const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY, }, }); export default async function globalSetup() { // 1. Clean up any test data from previous runs await api.internal.post.deleteMany({ filter: { title: { startsWith: "E2E Test" } }, }); // 2. Ensure the seeded test user has emailVerified: true so sign-in can succeed // this could be modified to create a test user const users = await api.user.findMany({ filter: { email: { equals: TEST_USER_EMAIL } }, first: 1, }); if (users[0] && !users[0].emailVerified) { await api.internal.user.update(users[0].id, { emailVerified: true }); } // 3. Sign in via the browser and save the session cookie for authenticated tests. // The test user is a pre-seeded user in the Gadget database with a known password. const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(`${process.env.GADGET_APP_URL}/sign-in`); // Wait for the React app to fully hydrate before interacting with the form. // The sign-in form is handled by useActionForm (a client-side React hook), so // submitting before hydration causes the default HTML GET submission to fire. await page.waitForLoadState("networkidle"); await page.fill("#email", TEST_USER_EMAIL); await page.fill("#password", TEST_USER_PASSWORD); await page.click('button[type="submit"]'); await page.waitForURL(`${process.env.GADGET_APP_URL}/signed-in`, { timeout: 60_000, }); // Wait for all Vite assets to finish loading. The __Host-app-session cookie is set // and rotated with every server response, so capturing state before assets load // can yield a stale or missing cookie that causes authenticated tests to fail. await page.waitForLoadState("networkidle"); // Save cookies and localStorage so authenticated tests can reuse this session await page.context().storageState({ path: AUTH_STATE_PATH }); await browser.close(); } ``` Gadget's session cookie (`__Host-app-session`) is rotated on every server response. Capturing storage state immediately after `waitForURL` can yield a stale or incomplete cookie, which causes authenticated test pages to silently redirect back to sign-in. Waiting for `networkidle` ensures all Vite asset requests have settled and the final rotated cookie is in place. ### Global teardown  The `globalTeardown` script runs once after the entire test suite completes. Use it to remove test data and the saved auth state file. Create `e2e/globalTeardown.js`: ```typescript import { Client } from "@gadget-client/your-app-slug"; import dotenv from "dotenv"; import path from "path"; import fs from "fs"; dotenv.config({ path: path.resolve(process.cwd(), ".env.local") }); const AUTH_STATE_PATH = path.join(process.cwd(), "e2e/.auth-state.json"); // the api client definition could be shared between globalSetup // and globalTeardown by exporting it from a separate module const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY, }, }); export default async function globalTeardown() { // Remove posts created during the test run await api.internal.post.deleteMany({ filter: { title: { startsWith: "E2E Test" } }, }); // Remove the saved auth state file if (fs.existsSync(AUTH_STATE_PATH)) { fs.unlinkSync(AUTH_STATE_PATH); } } ``` If you are creating a fresh user on every test run instead of using a persistent seeded user, also add `api.internal.user.deleteMany(...)` here to clean up after the run. If you use a persistent seeded user as shown in this guide, do not delete the user here. The user will be missing on the next run. ### Writing tests  Place all test files inside `e2e/`. Playwright discovers them automatically. #### Authentication tests  Auth tests exercise sign-in, sign-out, and error flows through the real browser UI. They do not use the saved auth state. They sign in fresh each time so the sign-in journey itself is tested. ```typescript import { expect, test } from "@playwright/test"; const EMAIL = process.env.TEST_USER_EMAIL!; const PASSWORD = process.env.TEST_USER_PASSWORD!; test.describe("authentication", () => { test("signs in with email and password", async ({ page }) => { await page.goto("/sign-in"); await page.waitForLoadState("networkidle"); await page.fill("#email", EMAIL); await page.fill("#password", PASSWORD); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/signed-in/); // Wait for React to hydrate and render the heading await page.waitForLoadState("networkidle"); await expect( page.getByRole("heading", { name: /you are now signed in/i }) ).toBeVisible(); }); test("shows an error for invalid credentials", async ({ page }) => { await page.goto("/sign-in"); await page.waitForLoadState("networkidle"); await page.fill("#email", EMAIL); await page.fill("#password", "wrong-password"); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/sign-in/); await expect(page.locator(".text-destructive")).toBeVisible(); }); test("signs out and redirects to the homepage", async ({ page }) => { await page.goto("/sign-in"); await page.waitForLoadState("networkidle"); await page.fill("#email", EMAIL); await page.fill("#password", PASSWORD); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/signed-in/); await page.waitForLoadState("networkidle"); await page.locator("header").getByRole("button").click(); await page.getByText("Sign out").click(); await expect(page).toHaveURL("/"); }); }); ``` #### Authenticated page tests  Tests for authenticated pages reuse the session saved by `globalSetup` via `test.use({ storageState: AUTH_STATE_PATH })`. This avoids repeating the sign-in flow in every test and keeps the suite fast. ```typescript import { expect, test } from "@playwright/test"; import { AUTH_STATE_PATH } from "../playwright.config"; // All tests in this file start already signed in test.use({ storageState: AUTH_STATE_PATH }); test.describe("posts page", () => { test("shows the posts heading and create form", async ({ page }) => { await page.goto("/posts"); await page.waitForLoadState("networkidle"); await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible(); await expect(page.getByRole("button", { name: "Create" })).toBeVisible(); }); test("creates a new post and it appears in the list as a draft", async ({ page, }) => { await page.goto("/posts"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Post"); await page.getByRole("button", { name: "Create" }).click(); const postItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post", }), }); await expect(postItem).toBeVisible(); await expect(postItem.locator('[data-testid="post-status"]')).toHaveText( "Draft" ); }); test("publishes a post and the status badge updates", async ({ page }) => { await page.goto("/posts"); await page.waitForLoadState("networkidle"); const postItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post", }), }); await expect(postItem).toBeVisible(); await postItem.getByRole("button", { name: "Publish" }).click(); const updatedItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post", }), }); await expect(updatedItem.locator('[data-testid="post-status"]')).toHaveText( "Published" ); await expect( updatedItem.getByRole("button", { name: "Publish" }) ).not.toBeVisible(); }); test("deletes a post and it disappears from the list", async ({ page }) => { await page.goto("/posts"); await page.waitForLoadState("networkidle"); const postItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post", }), }); await expect(postItem).toBeVisible(); await postItem.getByRole("button", { name: "Delete" }).click(); await expect( page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post" }) ).not.toBeVisible(); }); }); ``` #### Using `data-testid` attributes  Playwright can query elements by text, role, or CSS selector, but adding `data-testid` attributes makes selectors stable even when you rename labels or restructure markup: ```tsx {post.title} {post.published ? "Published" : "Draft"} ``` Then in your test: ```typescript const postItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post" }), }); ``` #### Interacting with forms  Always call `await page.waitForLoadState("networkidle")` before filling in or submitting any form. The reason differs depending on the form type, but the rule is the same in both cases. * **Client-side forms** using `useActionForm` or other React hooks only become active after the React app hydrates. Submitting before hydration causes the browser to fall back to a plain HTML `GET` request instead of invoking the hook. The `networkidle` wait ensures React has attached its event handlers before you interact with the form. * **Server-side forms** using native `
` tied to a React Router `action` work correctly before hydration. Gadget's Vite dev server compiles assets lazily on first navigation, so assertions made before compilation completes can fail to find rendered content. ```typescript // Client-side form (useActionForm): wait ensures hydration // in e2e/globalSetup.ts await page.goto(`${process.env.GADGET_APP_URL}/sign-in`); await page.waitForLoadState("networkidle"); await page.fill("#email", TEST_USER_EMAIL); await page.fill("#password", TEST_USER_PASSWORD); await page.click('button[type="submit"]'); // Server-side form (React Router action): wait ensures Vite has compiled assets // in e2e/posts.spec.ts await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Post"); await page.getByRole("button", { name: "Create" }).click(); ``` #### Testing validation errors  React Router server actions can return validation errors as `actionData` without redirecting. The page stays at the same URL and an error message becomes visible. Tests for this path should assert that the URL has not changed and that the error text is in the DOM. ```typescript test("shows a validation error when the title is blank", async ({ page }) => { await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); // Submit without filling in the required title field await page.getByRole("button", { name: "Create" }).click(); // The server action returns actionData with an error; the page does not redirect await expect(page).toHaveURL(/\/posts\/new/); await expect(page.getByText("Title is required")).toBeVisible(); }); ``` #### Verifying database state  UI assertions alone cannot confirm that a record was actually written to the database. When a form redirects away on success, such as a create form that navigates to the new record's detail page, there is nothing left in the UI to assert against the data itself. Use the Gadget internal API to query the database directly from within the test. Import and instantiate the API client at the top of the file or import an exiting client, and call `api.internal.*` after the redirect: ```typescript import { expect, test } from "@playwright/test"; import { Client } from "@gadget-client/your-app-slug"; import dotenv from "dotenv"; import { AUTH_STATE_PATH } from "../playwright.config"; dotenv.config({ path: ".env.local" }); // Authenticated API client used to inspect database state from within tests // can also be imported from a shared module to avoid duplication with globalSetup/globalTeardown const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY }, }); test.use({ storageState: AUTH_STATE_PATH }); test("creates a post and persists it to the database", async ({ page }) => { await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Database Post"); await page.getByRole("button", { name: "Create" }).click(); // The create form redirects to the new post's detail page on success await expect(page).toHaveURL(/\/posts\/\d+/); // Confirm the record is in the database, not just in the browser const posts = await api.internal.post.findMany({ filter: { title: { equals: "E2E Test Database Post" } }, first: 1, }); expect(posts).toHaveLength(1); expect(posts[0].title).toBe("E2E Test Database Post"); }); ``` `api.internal.*` methods bypass Gadget action logic and run directly against the database. Use them for asserting state in tests, not for setting up fixtures that your app's actions would normally validate. For seed data, use the same internal API calls in `globalSetup` so that data is created once before the test suite runs. ### Troubleshooting  #### Sign-in succeeds but authenticated pages redirect to sign-in  The auth state was captured before all Vite assets finished loading. Gadget rotates the session cookie on every server response, so if you capture `storageState` before `networkidle`, you may get a stale or incomplete cookie. **Fix:** always call `await page.waitForLoadState("networkidle")` after `waitForURL("/signed-in")` and before `context.storageState(...)`. #### Headings or components are not visible even after sign-in  Gadget's development server compiles Vite assets on demand. The first navigation to a new route can take more time while the server builds the bundle. The default Playwright assertion timeout of 5 seconds is occasionally too short for development. **Fix:** increase `timeout` in `playwright.config.ts`. 120 seconds is recommended for dev. Call `await page.waitForLoadState("networkidle")` before making assertions on page content. #### Tests interfere with each other  If tests are run against a shared development environment and database, test data created by one test can affect the next. **Fix:** * Run tests sequentially with `fullyParallel: false` in your Playwright config * Use a distinctive prefix such as `"E2E Test"` for all test-created records * Delete records matching that prefix in `globalSetup` before each run and in `globalTeardown` after each run * Run tests on a fresh environment, created just for running e2e tests ## Shopify apps  Shopify embedded admin apps authenticate via Shopify App Bridge rather than email and password. The real OAuth flow requires Shopify's servers and a real store session, making it impossible to automate with Playwright. Gadget's development server provides a built-in escape hatch that allows tests to authenticate without Shopify's OAuth flow. This is the same mechanism used by the in-editor preview. ### How mock Shopify auth works  When your app receives a request with the query parameter `gadgetmockappbridge=1`, Gadget's server middleware replaces the real Shopify App Bridge CDN script with a mock implementation. The mock script handles the App Bridge handshake that `useGadget()` and `@gadgetinc/react-shopify-app-bridge` depend on, returning `isAuthenticated: true` without contacting Shopify. A complete mock embed URL looks like: ```markdown // in mock embed URL https://your-app--development.gadget.app/? shop=your-dev-store.myshopify.com &embedded=1 &clientid=your_shopify_api_key &clientsecret=your_shopify_api_secret &id_token= &gadgetmockappbridge=1 &hmac= ``` The `id_token` is a short-lived JWT signed with the Shopify API secret using HS256. The `hmac` is an HMAC-SHA256 computed over the alphabetically-sorted query parameters, also signed with the API secret. Both are generated programmatically in a Playwright fixture before each test, so the 2-minute JWT expiration never causes issues in practice. The mock URL contains the Shopify API secret in plaintext as the `clientsecret` query parameter. Never log this URL, commit it to source control, or expose it in test output. Store the API secret only in `.env.test`, which must be gitignored. This mechanism works without logging into the Gadget editor. The `gadgetmockappbridge=1` check is a plain query-parameter condition in the Gadget server middleware. No developer session is required. ### Install additional dependencies  Shopify app tests require `jose` to sign the mock JWT, in addition to the Playwright dependencies: ```bash yarn add --dev jose ``` ### Playwright configuration  Create a `playwright.config.ts` file at the root of your project: ```typescript import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; dotenv.config({ path: path.resolve(__dirname, ".env.test") }); export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: "html", use: { trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], }); ``` Key differences from the web app config: * No `baseURL`: tests navigate to fully-qualified mock URLs that include authentication parameters * No `globalSetup` or `globalTeardown`: there is no browser-based login to perform * `fullyParallel: true`: each test generates its own independent mock URL with a fresh JWT, so tests share no session state and are naturally isolated from each other ### Setting up .env.test  Create a `.env.test` file at the root of your project and add it to both `.ignore` and `.gitignore`: ```bash // in .env.test # Copy this file to .env.test and fill in your values. # .env.test is gitignored. Never commit real secrets. # Your Gadget app's test environment URL GADGET_APP_URL=https://your-app--development.gadget.app # From shopify.app..toml (client_id) # or Shopify Dev Dashboard → Client credentials → Client ID SHOPIFY_CLIENT_ID=your_client_id_here # From Shopify Dev Dashboard → Your app → Client credentials → Client secret SHOPIFY_API_SECRET=your_api_secret_here # A dev store that has your app installed SHOPIFY_SHOP_DOMAIN=your-dev-store.myshopify.com # A Gadget API key for seeding and cleaning up test data via the internal API # Create one in the Gadget editor under Settings → API Keys GADGET_API_KEY=gsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` To find these values: * **`GADGET_APP_URL`**: your app's development URL, visible at the top of the Gadget editor * **`SHOPIFY_CLIENT_ID`**: Shopify Dev Dashboard → your app → Client credentials → Client ID. Also visible in `shopify.app.development.toml` as `client_id` * **`SHOPIFY_API_SECRET`**: Shopify Dev Dashboard → your app → Client credentials → Client secret * **`SHOPIFY_SHOP_DOMAIN`**: the domain of a development store with your app installed, for example `your-dev-store.myshopify.com`. Do not include `https://`. * **`GADGET_API_KEY`**: Gadget editor → Settings → API Keys. Create a key with sufficient permissions to delete records for your test models. The API secret signs both the mock JWT and the request HMAC. Keep it out of source control and never include it in logs or test output. ### The shopPage fixture  Instead of saving a browser session with `storageState`, Shopify app tests use a custom Playwright fixture called `shopPage`. The fixture constructs a fresh mock embed URL before each test and navigates to it, establishing authentication via the mock App Bridge. #### Build the mock URL  Create `e2e/helpers/mockShopifyAuth.js`: ```typescript import { createHmac, randomBytes } from "crypto"; import { SignJWT } from "jose"; const APP_URL = process.env.GADGET_APP_URL; const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID; /** * Builds a mock Shopify embed URL that bypasses OAuth. * * Replicates what the Gadget editor's embed preview does: * - Signs a mock Shopify session token (id_token) with the API secret * - Computes an HMAC over the sorted params * - Adds `gadgetmockappbridge=1` which tells the Gadget server to inject * the mock App Bridge CDN script instead of Shopify's real one * * The resulting URL can be navigated to directly, no Gadget login needed. * The app will authenticate via the mock App Bridge and render normally. * * ⚠️ The URL contains the API secret in plaintext. Never log or expose it. */ export async function buildMockEmbedUrl( shopDomain: string, clientSecret: string, path = "/" ): Promise { if (!APP_URL || !CLIENT_ID) { throw new Error("GADGET_APP_URL and SHOPIFY_CLIENT_ID must be set in .env.test"); } const idToken = await new SignJWT({ sid: randomBytes(8).toString("hex"), dest: `https://${shopDomain}`, }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setIssuer(`https://${shopDomain}/admin`) .setAudience(CLIENT_ID) .setSubject("123") .setExpirationTime("2m") .sign(new TextEncoder().encode(clientSecret)); const params = new URLSearchParams({ shop: shopDomain, embedded: "1", clientid: CLIENT_ID, clientsecret: clientSecret, id_token: idToken, gadgetmockappbridge: "1", }); // HMAC is computed over decoded, alphabetically sorted params (excluding hmac itself) const paramString = [...params.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([k, v]) => `${k}=${v}`) .join("&"); const hmac = createHmac("sha256", clientSecret).update(paramString).digest("hex"); params.set("hmac", hmac); return `${APP_URL}${path}?${params.toString()}`; } ``` Both `APP_URL` and `CLIENT_ID` are read from `.env.test` at runtime. The Shopify API key is not a secret. It is the same value as the `client_id` field in `shopify.app.development.toml`. #### Define the fixture  Create `e2e/fixtures.js`: ```typescript import { test as base, expect, type Page } from "@playwright/test"; import { buildMockEmbedUrl } from "./helpers/mockShopifyAuth"; type Fixtures = { /** A page that has navigated to the app with mock Shopify auth. */ shopPage: Page; }; export const test = base.extend({ shopPage: async ({ page }, use) => { const shopDomain = process.env.SHOPIFY_SHOP_DOMAIN; const clientSecret = process.env.SHOPIFY_API_SECRET; if (!shopDomain || !clientSecret) { throw new Error( "SHOPIFY_SHOP_DOMAIN and SHOPIFY_API_SECRET must be set in .env.test" ); } const url = await buildMockEmbedUrl(shopDomain, clientSecret); await page.goto(url); // Fail fast if mock auth redirected away from the app (like accounts.shopify.com). // Without this check, a heading on the Shopify login page would satisfy the wait below // and silently pass the wrong page to the test. await expect(page).toHaveURL(new RegExp(`^${process.env.GADGET_APP_URL}`), { timeout: 15_000, }); // Wait until the authenticated route has rendered. // Replace this selector with one that reliably appears on your app's index route. await page.locator("h1, h2").first().waitFor({ timeout: 15_000 }); await use(page); }, }); export { expect } from "@playwright/test"; ``` The two waits serve different purposes. The `toHaveURL` assertion confirms mock auth succeeded and the browser stayed on your app. The heading wait confirms the route has finished rendering. Replace `h1, h2` with a selector specific to your index route, for example, a named heading or a `data-testid` element, so a wrong-page render produces a clear failure rather than a silent pass. Import `test` and `expect` from `e2e/fixtures.ts` (using a relative path) in every test file instead of from `@playwright/test`. This gives every test the `shopPage` fixture automatically. #### Navigate to a specific route  Pass a `path` argument to `buildMockEmbedUrl` to navigate somewhere other than the index route: ```typescript const url = await buildMockEmbedUrl(shopDomain, clientSecret, "/some-route"); await page.goto(url); ``` ### Example tests  Place all test files inside `e2e/`. Playwright discovers them automatically. Import `test` and `expect` from your fixtures file rather than from `@playwright/test`: ```typescript import { test, expect } from "./fixtures"; ``` #### Cleaning up test data  Tests that create records must clean up before and after each run to keep the database in a known state. Use the Gadget internal API, `api.internal.*`, for cleanup. It bypasses access control and works directly with an API key. Use a distinctive string prefix for all test-created records. Define a cleanup function and call it in both `beforeEach`, which removes leftovers from an interrupted run, and `afterEach`, which removes records after a passing or failing run: ```typescript import { Client } from "@gadget-client/your-app-slug"; import { test, expect } from "./fixtures"; const TEST_PREFIX = "e2e-test-"; const api = new Client({ environment: "development", authenticationMode: { apiKey: process.env.GADGET_API_KEY! }, }); async function cleanupTestRecords() { await api.internal.allowedTag.deleteMany({ filter: { keyword: { startsWith: TEST_PREFIX } }, }); } test.beforeEach(cleanupTestRecords); test.afterEach(cleanupTestRecords); ``` Use `api.internal.` for cleanup rather than `api.`. The internal API bypasses Gadget's access control rules and runs directly against the database. Cleanup using the public API can fail with `GGT_PERMISSION_DENIED` if the `unauthenticated` role does not have delete access on the model. #### Testing AutoForm  `AutoForm` from `@gadgetinc/react/auto/polaris` generates a Polaris form from your model's fields. Key selectors to know: * `getByLabel("Field name")`: targets the input by its label. AutoForm derives the label from the field's API identifier, for example `keyword` becomes "Keyword". * `getByRole("button", { name: "Submit" })`: AutoForm's default submit button text is "Submit". * Validation errors appear as inline text in the format `" is required"` for required string fields. ```typescript test.describe("AutoForm: Add keywords", () => { test("renders with title and keyword input", async ({ shopPage }) => { await expect( shopPage.getByRole("heading", { name: "Add keywords" }) ).toBeVisible(); await expect(shopPage.getByLabel("Keyword")).toBeVisible(); }); test("shows a validation error when submitted empty", async ({ shopPage }) => { await shopPage.getByRole("button", { name: "Submit" }).click(); await expect(shopPage.getByText("Keyword is required")).toBeVisible(); }); test("creates a record and it appears in the table", async ({ shopPage }) => { const keyword = `${TEST_PREFIX}playwright`; await shopPage.getByLabel("Keyword").fill(keyword); await shopPage.getByRole("button", { name: "Submit" }).click(); // New record should appear in the AutoTable below await expect(shopPage.getByRole("cell", { name: keyword })).toBeVisible({ timeout: 15_000, }); }); }); ``` #### Testing AutoTable  `AutoTable` from `@gadgetinc/react/auto/polaris` fetches data after the initial render. The table skeleton appears immediately, but column headers and rows appear only after the data fetch completes. Key selectors to know: * Column headers use `role="columnheader"`. Pass `exact: true` when the column name is a substring of another column name, for example `"Name"` would also match `"Country name"` without `exact: true`. * Always give the first column header assertion a generous timeout. The default 5-second timeout can expire before the data fetch completes. * Row cells use `role="cell"`. Use `getByRole("cell", { name: value })` to assert a specific record is visible. ```typescript test.describe("AutoTable: Keywords", () => { test("renders the Keywords heading and column header", async ({ shopPage }) => { await expect(shopPage.getByRole("heading", { name: "Keywords" })).toBeVisible(); await expect( shopPage.getByRole("columnheader", { name: "Keyword", exact: true }) ).toBeVisible({ timeout: 15_000 }); }); test("shows a newly created record as a row", async ({ shopPage }) => { const keyword = `${TEST_PREFIX}row-check`; // Create via the form await shopPage.getByLabel("Keyword").fill(keyword); await shopPage.getByRole("button", { name: "Submit" }).click(); await expect(shopPage.getByRole("cell", { name: keyword })).toBeVisible({ timeout: 15_000, }); }); }); ``` The "creates a record and it appears in the table" and "shows a newly created record as a row" tests submit the AutoForm and then assert the result in the AutoTable. This covers the full create path: form submission, Gadget action, AutoTable re-fetch, and DOM update. #### Stable selectors with `data-testid`  Adding `data-testid` attributes makes selectors stable even when you rename labels or restructure markup: ```tsx {shop.name} ``` ```typescript await expect(shopPage.locator('[data-testid="shop-name"]')).toBeVisible(); ``` #### Querying the database in tests  Use the Gadget API client to query the database directly from within a test. Instantiate the client at the top of your test file using the `GADGET_API_KEY` from `.env.test`: ```typescript import { Client } from "@gadget-client/your-app-slug"; import { test, expect } from "./fixtures"; const api = new Client({ environment: "development", authenticationMode: { apiKey: process.env.GADGET_API_KEY! }, }); test("shop record exists in the database", async ({ shopPage }) => { const shops = await api.internal.shopifyShop.findMany({ first: 1 }); expect(shops).toHaveLength(1); }); ``` `api.internal.*` methods bypass Gadget action logic and run directly against the database. Use them for asserting state and cleaning up test records. Shopify data is scoped per shop, so your test dev store is naturally isolated from production data. ### Common issues  #### App shows "App must be viewed in the Shopify Admin"  The mock auth URL did not authenticate correctly. Check the following: * `SHOPIFY_API_SECRET` in `.env.test` matches the Client secret in your Shopify Dev Dashboard. Any mismatch causes HMAC or JWT verification to fail silently, and the app falls back to the unauthenticated state. * `SHOPIFY_SHOP_DOMAIN` is the full domain of a dev store with your app installed. Do not include `https://`. * `SHOPIFY_CLIENT_ID` in `.env.test` matches the `client_id` field in `shopify.app.development.toml`. #### Data does not appear in AutoTable  The default Playwright assertion timeout is 5 seconds. `AutoTable` makes a network request after the initial render, so assertions on column headers or rows can time out before the data arrives. **Fix:** pass an explicit `timeout` to the first column header or row assertion: ```typescript await expect(shopPage.getByRole("columnheader", { name: "Name" })).toBeVisible({ timeout: 15_000, }); ``` #### HMAC mismatch or JWT signature errors  These errors indicate that `SHOPIFY_API_SECRET` in `.env.test` does not match the secret used to register your app. Open your Shopify Dev Dashboard, navigate to the app's Client credentials page, and copy the Client secret again to make sure it is current. ## Running tests  ### Running tests locally  To run all tests, use the test script defined in `package.json`: ```bash yarn test:e2e ``` To run a specific file: ```bash yarn playwright test e2e/your-test.spec.ts ``` To run in headed mode with a visible browser window, which is useful for debugging: ```bash yarn playwright test --headed ``` To open the interactive Playwright UI: ```bash yarn playwright test --ui ``` After a failure, Playwright saves a trace to `test-results/`. Open it with: ```bash yarn playwright show-report ``` ### Running tests with CI/CD  You can run Playwright tests as part of a CI/CD pipeline. The steps below use GitHub Actions. The workflow structure is identical for both app types. The differences are the secrets passed and the test command used. #### GitHub Actions example  1. Add your secrets to your GitHub repository under **Settings** > **Secrets and variables** > **Actions**. **Web apps:** `GADGET_APP_URL`, `GADGET_ENVIRONMENT`, `GADGET_API_KEY`, `TEST_USER_EMAIL`, `TEST_USER_PASSWORD` **Shopify apps:** `GADGET_APP_URL`, `SHOPIFY_CLIENT_ID`, `SHOPIFY_API_SECRET`, `SHOPIFY_SHOP_DOMAIN`, `GADGET_API_KEY` 2. Confirm your `.gitignore` includes test artifacts. Web apps also need `e2e/.auth-state.json`. Shopify apps need `.env.test`. 3. Create `.github/workflows/e2e.yml`: Add your secrets to your GitHub repository under **Settings** > **Secrets and variables** > **Actions**. **Web apps:** `GADGET_APP_URL`, `GADGET_ENVIRONMENT`, `GADGET_API_KEY`, `TEST_USER_EMAIL`, `TEST_USER_PASSWORD` **Shopify apps:** `GADGET_APP_URL`, `SHOPIFY_CLIENT_ID`, `SHOPIFY_API_SECRET`, `SHOPIFY_SHOP_DOMAIN`, `GADGET_API_KEY` Confirm your `.gitignore` includes test artifacts. Web apps also need `e2e/.auth-state.json`. Shopify apps need `.env.test`. Create `.github/workflows/e2e.yml`: ```Web name: E2E tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: "20.x" - name: Install dependencies run: yarn --frozen-lockfile - name: Install Playwright browsers run: yarn playwright install chromium --with-deps - name: Run Playwright tests run: yarn test:e2e env: GADGET_APP_URL: ${{ secrets.GADGET_APP_URL }} GADGET_ENVIRONMENT: ${{ secrets.GADGET_ENVIRONMENT }} GADGET_API_KEY: ${{ secrets.GADGET_API_KEY }} TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} - name: Upload test report uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 7 ``` ```Shopify name: E2E tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: "20.x" - name: Install dependencies run: yarn --frozen-lockfile - name: Install Playwright browsers run: yarn playwright install chromium --with-deps - name: Run Playwright tests run: yarn test:e2e env: GADGET_APP_URL: ${{ secrets.GADGET_APP_URL }} SHOPIFY_CLIENT_ID: ${{ secrets.SHOPIFY_CLIENT_ID }} SHOPIFY_API_SECRET: ${{ secrets.SHOPIFY_API_SECRET }} SHOPIFY_SHOP_DOMAIN: ${{ secrets.SHOPIFY_SHOP_DOMAIN }} GADGET_API_KEY: ${{ secrets.GADGET_API_KEY }} - name: Upload test report uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 7 ``` The `upload-artifact` step captures the HTML test report on every run, including failures, so you can download and review it from the Actions tab.