Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/config-level-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@satoshai/playstacks': minor
---

Support Playwright config-level Stacks settings

Adds `PlaystacksOptions` that can be set in `playwright.config.ts`'s `use` block (`stacksPrivateKey`, `stacksNetwork`, `stacksFeeMultiplier`, etc.), eliminating the need to repeat config in every test file. File-level `testWithStacks()` overrides still take precedence.
224 changes: 187 additions & 37 deletions packages/playstacks/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type { ClarityValue } from '@stacks/transactions';

import {
type PlaystacksConfig,
type NetworkOption,
type FeeConfig,
type ConfirmationConfig,
resolveConfig,
isMnemonicConfig,
} from './config.js';
Expand Down Expand Up @@ -38,12 +41,194 @@ export interface StacksFixture {
getNonce: (address?: string) => Promise<bigint>;
}

/**
* Playwright config-level options for Stacks.
* Set these in `playwright.config.ts` `use` block.
*
* @example
* ```ts
* // playwright.config.ts
* export default defineConfig({
* use: {
* stacksPrivateKey: process.env.TEST_STX_KEY!,
* stacksNetwork: 'testnet',
* stacksFeeMultiplier: 2,
* },
* });
* ```
*/
export interface PlaystacksOptions {
/** Hex-encoded private key */
stacksPrivateKey: string | undefined;
/** BIP-39 mnemonic phrase */
stacksMnemonic: string | undefined;
/** Account index for mnemonic derivation. Default: 0 */
stacksAccountIndex: number;
/** Network to connect to */
stacksNetwork: NetworkOption | undefined;
/** Fee multiplier. Default: 1.0 */
stacksFeeMultiplier: number;
/** Max fee in microstacks. Default: 500_000 */
stacksFeeMax: number;
/** Fixed fee in microstacks. Skips estimation when set. */
stacksFeeFixed: number | undefined;
/** Tx confirmation timeout in ms. Default: 120_000 */
stacksConfirmationTimeout: number;
/** Tx confirmation poll interval in ms. Default: 2_000 */
stacksConfirmationPollInterval: number;
/** Request timeout in ms for API calls. Default: 30_000 */
stacksRequestTimeout: number;
}

export interface StacksFixtures {
stacks: StacksFixture;
}

/** Build a PlaystacksConfig from Playwright options, with file-level overrides merged on top */
function buildConfigFromOptions(
options: PlaystacksOptions,
overrides?: Partial<PlaystacksConfig>,
): PlaystacksConfig {
const network = overrides?.network ?? options.stacksNetwork;
if (!network) {
throw new Error(
'Stacks network is required. Set stacksNetwork in playwright.config.ts use block or pass network to testWithStacks().'
);
}

const fee: FeeConfig = {
multiplier: options.stacksFeeMultiplier,
maxFee: options.stacksFeeMax,
fixed: options.stacksFeeFixed,
...overrides?.fee,
};

const confirmation: ConfirmationConfig = {
timeout: options.stacksConfirmationTimeout,
pollInterval: options.stacksConfirmationPollInterval,
...overrides?.confirmation,
};

const requestTimeout = overrides?.requestTimeout ?? options.stacksRequestTimeout;

// Determine key source: file-level overrides take precedence over config-level
const privateKey = (overrides && 'privateKey' in overrides && overrides.privateKey)
? overrides.privateKey as string
: options.stacksPrivateKey;
const mnemonic = (overrides && 'mnemonic' in overrides && overrides.mnemonic)
? overrides.mnemonic as string
: options.stacksMnemonic;
const accountIndex = (overrides && 'accountIndex' in overrides && overrides.accountIndex !== undefined)
? overrides.accountIndex as number
: options.stacksAccountIndex;

if (mnemonic) {
return { mnemonic, accountIndex, network, fee, confirmation, requestTimeout };
}
if (privateKey) {
return { privateKey, network, fee, confirmation, requestTimeout };
}

throw new Error(
'Stacks private key or mnemonic is required. Set stacksPrivateKey/stacksMnemonic in playwright.config.ts use block or pass privateKey/mnemonic to testWithStacks().'
);
}

/** Resolve config (handling async mnemonic derivation) */
async function resolvePlaystacksConfig(config: PlaystacksConfig) {
if (isMnemonicConfig(config)) {
const privateKey = await derivePrivateKeyFromMnemonic(
config.mnemonic,
config.accountIndex ?? 0
);
return resolveConfig(config, privateKey);
}
return resolveConfig(config);
}

/** Create the stacks fixture from a resolved config */
function createFixture(
handler: MockProviderHandler,
resolved: ReturnType<typeof resolveConfig>,
): StacksFixture {
const network = handler.network;
return {
wallet: {
address: handler.identity.address,
publicKey: handler.identity.publicKey,
rejectNext: () => handler.rejectNext(),
lastTxId: () => handler.lastTxId,
},
waitForTx: (txid: string) =>
waitForConfirmation(network, txid, resolved.confirmation, resolved.requestTimeout),
callReadOnly: (options: ReadOnlyCallOptions) =>
callReadOnly(network, options, handler.identity.address, resolved.requestTimeout),
getBalance: (address?: string) =>
fetchBalance(network, address ?? handler.identity.address, resolved.requestTimeout),
getNonce: (address?: string) =>
fetchNonce(network, address ?? handler.identity.address, resolved.requestTimeout),
};
}

/**
* Base test with Stacks config options and fixture.
* Use with `playwright.config.ts` `use` block for shared config.
*
* @example
* ```ts
* // playwright.config.ts
* export default defineConfig({
* use: {
* stacksPrivateKey: process.env.TEST_STX_KEY!,
* stacksNetwork: 'testnet',
* },
* });
*
* // test file
* import { test, expect } from '@satoshai/playstacks';
* test('my test', async ({ stacks }) => { ... });
* ```
*/
// Cache resolved configs keyed by options to avoid re-deriving mnemonic keys,
// while supporting multiple Playwright projects with different Stacks settings.
const configCache = new Map<string, Promise<ReturnType<typeof resolveConfig>>>();

export const test = base.extend<StacksFixtures & PlaystacksOptions>({
// Options with defaults (set via playwright.config.ts `use` block)
stacksPrivateKey: [undefined, { option: true }],
stacksMnemonic: [undefined, { option: true }],
stacksAccountIndex: [0, { option: true }],
stacksNetwork: [undefined, { option: true }],
stacksFeeMultiplier: [1.0, { option: true }],
stacksFeeMax: [500_000, { option: true }],
stacksFeeFixed: [undefined, { option: true }],
stacksConfirmationTimeout: [120_000, { option: true }],
stacksConfirmationPollInterval: [2_000, { option: true }],
stacksRequestTimeout: [30_000, { option: true }],

stacks: async ({ page, stacksPrivateKey, stacksMnemonic, stacksAccountIndex, stacksNetwork, stacksFeeMultiplier, stacksFeeMax, stacksFeeFixed, stacksConfirmationTimeout, stacksConfirmationPollInterval, stacksRequestTimeout }, use) => {
const options: PlaystacksOptions = {
stacksPrivateKey, stacksMnemonic, stacksAccountIndex, stacksNetwork,
stacksFeeMultiplier, stacksFeeMax, stacksFeeFixed,
stacksConfirmationTimeout, stacksConfirmationPollInterval,
stacksRequestTimeout,
};
const cacheKey = JSON.stringify(options);
if (!configCache.has(cacheKey)) {
const config = buildConfigFromOptions(options);
configCache.set(cacheKey, resolvePlaystacksConfig(config));
}
const resolved = await configCache.get(cacheKey)!;
const handler = new MockProviderHandler(resolved);
handler.resetNonce();
await handler.install(page);
await use(createFixture(handler, resolved));
},
});

/**
* Create a Playwright test function with Stacks wallet fixtures.
* File-level config overrides any config-level settings from `playwright.config.ts`.
*
* @example
* ```ts
Expand All @@ -66,14 +251,7 @@ export function testWithStacks(config: PlaystacksConfig) {
let resolvedConfigPromise: Promise<ReturnType<typeof resolveConfig>> | null = null;

async function getResolvedConfig() {
if (isMnemonicConfig(config)) {
const privateKey = await derivePrivateKeyFromMnemonic(
config.mnemonic,
config.accountIndex ?? 0
);
return resolveConfig(config, privateKey);
}
return resolveConfig(config);
return resolvePlaystacksConfig(config);
}

return base.extend<StacksFixtures>({
Expand All @@ -83,37 +261,9 @@ export function testWithStacks(config: PlaystacksConfig) {
}
const resolved = await resolvedConfigPromise;
const handler = new MockProviderHandler(resolved);

// Reset nonce tracking for each test to avoid stale nonces
handler.resetNonce();

// Install the mock provider before any navigation
await handler.install(page);

const network = handler.network;

const fixture: StacksFixture = {
wallet: {
address: handler.identity.address,
publicKey: handler.identity.publicKey,
rejectNext: () => handler.rejectNext(),
lastTxId: () => handler.lastTxId,
},

waitForTx: (txid: string) =>
waitForConfirmation(network, txid, resolved.confirmation, resolved.requestTimeout),

callReadOnly: (options: ReadOnlyCallOptions) =>
callReadOnly(network, options, handler.identity.address, resolved.requestTimeout),

getBalance: (address?: string) =>
fetchBalance(network, address ?? handler.identity.address, resolved.requestTimeout),

getNonce: (address?: string) =>
fetchNonce(network, address ?? handler.identity.address, resolved.requestTimeout),
};

await use(fixture);
await use(createFixture(handler, resolved));
},
});
}
8 changes: 7 additions & 1 deletion packages/playstacks/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// Public API
export { testWithStacks, type StacksFixture, type StacksFixtures } from './fixtures.js';
export {
test,
testWithStacks,
type StacksFixture,
type StacksFixtures,
type PlaystacksOptions,
} from './fixtures.js';
export {
PlaystacksError,
NetworkError,
Expand Down