Playwright
Config helpers and dev-wallet helpers for browser tests.
Use the playwright subpath to wire up your Playwright config and to drive the dev wallet from your
specs.
import { defineConfig } from '@playwright/test';
import {
devstackPlaywrightBaseConfig,
devstackPlaywrightProjects,
devstackPlaywrightUse,
} from '@mysten-incubation/devstack/playwright';
const stack = 'e2e' as const;
export default defineConfig({
...devstackPlaywrightBaseConfig(),
use: devstackPlaywrightUse({ stack }),
projects: devstackPlaywrightProjects(),
});These helpers run a single worker (workers: 1, fullyParallel: false), default testDir to
./tests/browser, and point Playwright's baseURL at the app endpoint read from the devstack
manifest. Before the manifest exists, baseURL falls back to the conventional dev-server route on
port 5175; pass your own baseURL to devstackPlaywrightUse(...) if your stack serves the app
elsewhere.
The stack is booted by a globalSetup module — not by Playwright's webServer. webServer
shells out (pnpm dev) and force-kills the supervisor on teardown before its container drain
finishes, orphaning containers; the in-process globalSetup boot drains cleanly. By default
devstackPlaywrightBaseConfig() wires the built-in setup
(@mysten-incubation/devstack/playwright/global-setup), which boots the stack named by
DEVSTACK_STACK (the test:browser script sets DEVSTACK_STACK=e2e) and tears it down on
completion. If a matching stack is already running — for example your pnpm dev stack — set
DEVSTACK_TEST_REUSE=1 and the setup attaches to it instead of booting and tearing down its own.
To require specific endpoints before any spec runs, author your own thin global-setup module and point the config at it:
import { buildGlobalSetup } from '@mysten-incubation/devstack/playwright';
export default buildGlobalSetup({
requireEndpoints: ['app', 'wallet'],
});import { defineConfig } from '@playwright/test';
import {
devstackPlaywrightBaseConfig,
devstackPlaywrightProjects,
devstackPlaywrightUse,
} from '@mysten-incubation/devstack/playwright';
const stack = 'e2e' as const;
export default defineConfig({
...devstackPlaywrightBaseConfig({ globalSetup: './tests/browser/global-setup.ts' }),
use: devstackPlaywrightUse({ stack }),
projects: devstackPlaywrightProjects(),
});The spec-side helpers let you drive the dev wallet and read the stack:
connectAs(page, name)— switch the dev wallet to a named account from your config.selectAccount(page, name)— the lower-level switchconnectAswraps (same signature).switchNetwork(page, network)— switch the app's active dApp Kit network.createWalletAdapter— get a wallet adapter to inspect accounts (for example, to list them).loadStackManifest/readStackContext— read the booted stack's manifest and context (endpoints and ids) from a spec.
import { expect, test } from '@playwright/test';
import { connectAs, createWalletAdapter } from '@mysten-incubation/devstack/playwright';
test('connects alice', async ({ page }) => {
await page.goto('/');
await connectAs(page, 'alice');
const wallet = createWalletAdapter();
const accounts = await wallet.listAccounts();
expect(accounts.some((entry) => entry.name === 'alice')).toBe(true);
});Exposing the account switcher to tests
connectAs switches accounts without clicking through the wallet UI by driving your dApp Kit
instance through a dev-only test bridge. Register your dApp Kit instance once, DEV-only, in your
app's src/dapp-kit.ts:
import { createDAppKit } from '@mysten/dapp-kit-react';
import { registerDAppKitForTesting } from '@mysten-incubation/devstack/dapp-kit';
export const dAppKit = createDAppKit({
/* …networks, defaultNetwork, createClient… */
});
// DEV-only: a production build strips this branch (the dev wallet is never
// injected), so the app ships with no test surface.
if (import.meta.env.DEV) {
registerDAppKitForTesting(dAppKit);
}registerDAppKitForTesting(dAppKit) connects through dApp Kit's public API
(connectWallet/switchAccount/switchNetwork) — there is no localStorage seeding and no
hand-written global. Account names resolve to the dev wallet's accounts by label (the devstack
account name). This is the same wiring every example app uses.
Asserting a signed transaction
Clicking a button and watching the UI update tells you the app re-rendered — not that a transaction was signed and landed on chain. To prove the round-trip, sign through the dev wallet explicitly and then read the result straight off the stack's RPC.
createWalletAdapter() returns an HTTP client over the dev wallet's endpoint. signTransaction
signs and executes a serialized transaction as a named account and returns the executed digest;
loadStackManifest().endpoint('sui-rpc') resolves the live stack's fullnode so you can read the
object the transaction created.
import { Transaction } from '@mysten/sui/transactions';
import { SuiClient } from '@mysten/sui/client';
import { expect, test } from '@playwright/test';
import { createWalletAdapter, loadStackManifest } from '@mysten-incubation/devstack/playwright';
import { config } from '@generated/config.js';
test('signs a create-counter transaction and reads it back on chain', async ({ page }) => {
// Loading the app populates the wallet endpoint in the manifest the helpers read.
await page.goto('/');
const net = config.forNetwork(config.defaultNetwork);
const wallet = createWalletAdapter();
const rpc = new SuiClient({ url: loadStackManifest().endpoint('sui-rpc')! });
// Build the same transaction the app's "Create counter" button would.
const tx = new Transaction();
tx.moveCall({ target: `${net.mvrOverrides.packages['@local/counter']}::counter::create` });
const txBytesBase64 = Buffer.from(await tx.build({ client: rpc })).toString('base64');
// Sign + execute through the dev wallet as `alice` (DEVSTACK_AUTO_APPROVE=1, no UI step).
const { digest } = await wallet.signTransaction({ accountName: 'alice', txBytesBase64 });
// Read the on-chain effects of THAT digest — proof it was signed and executed.
const { effects } = await rpc.waitForTransaction({
digest,
options: { showEffects: true },
});
expect(effects?.status.status).toBe('success');
const created = effects?.created ?? [];
expect(created.length).toBeGreaterThan(0);
// And read the created object's content back.
const counterId = created[0]!.reference.objectId;
const object = await rpc.getObject({ id: counterId, options: { showContent: true } });
expect(object.data?.content).toBeTruthy();
});signTransaction POSTs the serialized bytes to the dev wallet, which holds the keys and signs
server-side — the wallet adapter never sees the keypair. Because the assertions read effects and
the object content for the returned digest off the live RPC, the test fails if the app never
actually executed, not just if the UI didn't change.