Skip to content
Open
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
51 changes: 20 additions & 31 deletions apps/appkit-minter/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,11 @@ apps/appkit-minter/

## Test design: steps are defined in code

Each test name and its steps are authored **in code** (`test.step('…')` + the
`gaslessMeta()` labels), and the TestOps test case is auto-created/updated from the
uploaded run (matched by `fullName`). We deliberately do **not** hand-author the
case scenarios in TestOps.

Why code-defined (vs. describing steps directly in TestOps):

| | Code-defined steps (chosen) | TestOps-authored steps |
| --------------------------- | ------------------------------------------------------------------------ | ------------------------------------ |
| Source of truth | the spec — one place | the case — diverges from code |
| Drift | impossible (steps come from the run) | constant (manual sync) |
| New test | appears in TestOps after one run | must be hand-created first |
| Matches repo | yes — demo-wallet/walletkit do this (`allureId`/`suite`/`label` in code) | no |
| Cost for ~60 cases | zero extra | ~60 manual cases to write + maintain |
| Rich "expected result" rows | weaker (body steps) | richer |

The only real downside — slightly less rich step formatting — is outweighed by
zero drift across a fast-moving feature. `detail: false` in the Allure reporter
keeps the TestOps scenario clean (only our `test.step` labels; no Playwright
hook/fixture noise).
Each test's name and steps are authored in code (`test.step('…')`, with the
`feature` / `sub-suite` labels from `gaslessMeta()`), so the spec is the single
source of truth and the Allure report mirrors it with no manual step-authoring and
no drift. `detail: false` in the reporter keeps the report to just those
`test.step` labels — no Playwright hook/fixture noise.

## Testing approaches

Expand Down Expand Up @@ -137,14 +122,16 @@ key + non-empty signed BoC — see `gasless-transfer.spec.ts` and `gasless-races
The `@real-send` specs (in `gasless-transfer.spec.ts` and `gasless-mint.spec.ts`)
**do broadcast** real mainnet transactions and are therefore **not run in CI** —
they exist for manual/local real-send verification (`pnpm e2e --grep "@real-send"`).
A scheduled-monitor workflow that would run them against the live relayer was
considered out of scope here (kept as a QA-side snippet, not in this repo).

Results upload to Allure TestOps (project 368). Cases are **auto-created/updated by
`fullName`** (path:line) on upload — no manual mapping. To harden the link so a
rename/move can't orphan a case, pin the AllureID **after the first run** with a
code call `await allureId(<id>)` (from `allure-js-commons`) — the title-only
`@allureId(N)` tag is decorative in allure-playwright v3 and does not link.
Results upload to Allure TestOps (project 368). Every test pins a stable case id
with a native `@allure.id=<N>` token in its title — the adapter parses it, links
the result to that exact case, and strips the token from the display name
(`await allure.id("<N>")` in code is equivalent). Because the id is a DB-level
identifier, a result stays linked to the same case across renames, file moves,
and describe/title edits — unlike the default path + describe + title match,
which orphans a case on any such change. A new test bootstraps by uploading once
(the first run auto-creates the case and TestOps assigns an id); pin that id back
in the title before any rename.

## Notes

Expand All @@ -155,7 +142,9 @@ code call `await allureId(<id>)` (from `allure-js-commons`) — the title-only
- The appkit-minter Assets list is **empty without a connected wallet**, so the
transfer modal is only reachable in two-tab specs; truly wallet-less coverage is
limited to page-load / mocked-config / mint-settings-disabled checks.
- `LowBalanceModal` (regular mint, insufficient TON) is **not yet automated**: it
needs either a real 0-TON wallet or a mocked account balance. Since CI must not
rely on a specially-funded wallet, the plan is to mock the balance endpoint
rather than add a second mnemonic — see the QA test plan.
- The account and jetton balances are mocked (`mocks/tonBalances.ts`, installed by
the two-tab fixture for every non-`@real-send` spec), so the gate needs no
specially-funded wallet and reads no live indexer for balances.
- `LowBalanceModal` (regular mint, insufficient TON) is **not yet automated**, but
the balance mock makes it reachable — a spec can drive it by mocking a low TON
balance instead of needing a real 0-TON wallet.
7 changes: 7 additions & 0 deletions apps/appkit-minter/e2e/fixtures/gaslessFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { ConfigFixture, TestFixture } from '../qa';
import { launchPersistentContext, TonConnectWidget, testWith } from '../qa';
import { captureConsole, attachConsoleOnFailure } from '../qa/diagnostics';
import { MinterPage } from '../pages/MinterPage';
import { mockTonBalances } from '../mocks/tonBalances';
import { DemoWallet } from '../wallet';

const DEFAULT_WALLET_SOURCE = process.env.E2E_WALLET_SOURCE ?? 'http://localhost:5173/';
Expand Down Expand Up @@ -40,6 +41,12 @@ export function gaslessFixture(config: ConfigFixture, slowMo = 0) {
},
app: async ({ context }, use, testInfo) => {
const app = await context.newPage();
// Hermetic account/jetton balances so the mocked gate needs no funded
// wallet. `@real-send` specs broadcast on-chain and must read the wallet's
// real balances, so they opt out.
if (!testInfo.tags.includes('@real-send')) {
await mockTonBalances(app);
}
const logs = captureConsole(app);
await app.goto(config.appUrl, { waitUntil: 'load' });
await use(app);
Expand Down
3 changes: 1 addition & 2 deletions apps/appkit-minter/e2e/mocks/gaslessRelayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import type { Page, Route } from '@playwright/test';
* them — the wallet tab is never touched.
*
* The point of mocking `/send` is twofold:
* 1. nothing is broadcast on-chain, so the test spends no funds (the lead's CI
* constraint), and
* 1. nothing is broadcast on-chain, so the gate spends no funds, and
* 2. the captured request body lets a test assert the minter formed the gasless
* message correctly — i.e. tx-formation is verified without a real send.
*
Expand Down
137 changes: 137 additions & 0 deletions apps/appkit-minter/e2e/mocks/tonBalances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Copyright (c) TonTech.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import type { Page, Route } from '@playwright/test';

/**
* Route mocks for the account-state + jetton-balance reads the minter issues for
* the connected wallet. Without these the gate depends on a live, *funded* wallet
* (real USDT) and the live indexer to populate the transfer asset list — the same
* class of external dependency the relayer mocks remove for `/v2/gasless/*`.
*
* The minter picks its indexer backend from `VITE_TON_API_PROVIDER` (default
* `toncenter`; `tonapi` when set), so both contracts are mocked and the gate is
* hermetic either way:
* - Toncenter: `GET /api/v3/addressInformation`, `GET /api/v3/jetton/wallets`
* - TonAPI: `GET /v2/blockchain/accounts/{a}`, `GET /v2/accounts/{a}/jettons`
*
* Not installed for `@real-send` specs — those broadcast on-chain and must read the
* wallet's real balances (see the fixture).
*/

/** USDT (Tether) mainnet master. The Toncenter jetton mapper keys metadata by the
* raw master and the minter's asset-row testid uses the friendly master, so both
* forms are pinned here and must resolve to the same address. */
const USDT_MASTER_FRIENDLY = 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs';
const USDT_MASTER_RAW = '0:B113A994B5024A16719F69139328EB759596C38A25F59028B146FECDC3621DFE';
/** A valid, parseable stand-in jetton-wallet address. Never broadcast (the relayer
* send is mocked); used only so the address mappers can normalize it. */
const JETTON_WALLET_RAW = '0:7C873E096984BCEB6F2169BE1C99FD6614C1C5C86B39E2A8A66C764648DC81F9';

const IMAGE = 'https://tether.to/images/logoCircle.png';

const json = (route: Route, body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });

export interface BalancesOpts {
/** Raw TON/GRAM balance in nanotons. Default 5 GRAM — comfortably above any gas floor. */
tonBalanceNano?: string;
/** Raw USDT balance (6 decimals). Default 5 USDT. `'0'` ⇒ no spendable USDT. */
usdtBalanceRaw?: string;
}

/**
* Mock account state + jetton balances so a connected wallet always shows a funded
* TON balance and a spendable USDT jetton, regardless of the wallet's real holdings.
*/
export async function mockTonBalances(page: Page, opts: BalancesOpts = {}): Promise<void> {
const ton = opts.tonBalanceNano ?? '5000000000';
const usdt = opts.usdtBalanceRaw ?? '5000000';

// --- Toncenter (default backend) ---
await page.route(/\/api\/v3\/addressInformation/, (route) =>
json(route, {
balance: ton,
status: 'active',
code: null,
data: null,
// Non-null tx id: the mapper parses these, so mirror the wire shape
// (base64 hash + numeric-string lt) rather than nulls.
last_transaction_hash: 'uToBwqQdeCqUK79AwRofY+YhWt/CjPRxm0UN2bxrJeA=',
last_transaction_lt: '1',
frozen_hash: null,
extra_currencies: [],
}),
);
await page.route(/\/api\/v3\/jetton\/wallets/, (route) =>
json(route, {
jetton_wallets: [
{
address: JETTON_WALLET_RAW,
balance: usdt,
owner: JETTON_WALLET_RAW,
jetton: USDT_MASTER_RAW,
last_transaction_lt: '0',
code_hash: '',
data_hash: '',
},
],
address_book: {},
// Toncenter carries jetton name/symbol/decimals in `metadata`, not on the
// wallet row — the mapper reads them from here, so it must be present.
metadata: {
[USDT_MASTER_RAW]: {
is_indexed: true,
token_info: [
{
type: 'jetton_masters',
name: 'Tether USD',
symbol: 'USDT',
description: '',
image: IMAGE,
extra: { decimals: '6' },
},
],
},
},
}),
);

// --- TonAPI (only reached when VITE_TON_API_PROVIDER=tonapi) ---
await page.route(/\/v2\/blockchain\/accounts\/[^/]+$/, (route) =>
json(route, {
address: USDT_MASTER_FRIENDLY,
balance: Number(ton),
status: 'active',
code: null,
data: null,
last_transaction_lt: 0,
last_transaction_hash: null,
frozen_hash: null,
}),
);
await page.route(/\/v2\/accounts\/[^/]+\/jettons/, (route) =>
json(route, {
balances: [
{
balance: usdt,
wallet_address: { address: JETTON_WALLET_RAW, is_scam: false, is_wallet: false },
jetton: {
address: USDT_MASTER_FRIENDLY,
name: 'Tether USD',
symbol: 'USDT',
decimals: 6,
image: IMAGE,
verification: 'whitelist',
score: 100,
},
},
],
}),
);
}
6 changes: 1 addition & 5 deletions apps/appkit-minter/e2e/qa/WalletApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,7 @@ export abstract class WalletApp {
readonly context: BrowserContext,
readonly source: string,
readonly password: string = TEST_PASSWORD,
) {
this.context = context;
this.source = source;
this.password = password;
}
) {}

get isExtension(): boolean {
return isExtensionWalletSource(this.source);
Expand Down
7 changes: 3 additions & 4 deletions apps/appkit-minter/e2e/qa/allure-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
import { feature, label } from 'allure-js-commons';

/**
* Attach grouping metadata to the current test, mirroring the demo-wallet e2e
* convention (`feature` + `sub-suite`). The top-level `Suite` custom field is left
* to allure-playwright's file-path default, so the gasless specs sit in the TestOps
* tree the same way the existing minter specs do.
* Attach grouping metadata to the current test (`feature` + `sub-suite` labels),
* matching the demo-wallet e2e convention. The top-level `Suite` custom field is
* left to allure-playwright's file-path default.
*
* @param subSuite area name, e.g. "Transfer", "Mint", "Relayer errors".
*/
Expand Down
2 changes: 1 addition & 1 deletion apps/appkit-minter/e2e/qa/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function launchPersistentContext(extensionPath: string, slowMo = 0)
args.push('--headless=new');
}

slowMo = isCi ? 0 : (parseInt(process.env.E2E_SLOW_MO || '0') ?? slowMo);
slowMo = isCi ? 0 : parseInt(process.env.E2E_SLOW_MO || '0', 10) || slowMo;
const browserContext = await chromium.launchPersistentContext('', {
args,
headless: false,
Expand Down
10 changes: 7 additions & 3 deletions apps/appkit-minter/e2e/specs/gasless-availability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { USDT_MASTER } from '../mocks/gaslessRelayer';

// --- wallet-less: Assets empty state renders without crashing ---
base.describe('Availability (no wallet)', () => {
base('Assets list shows empty state without a connected wallet (no crash)', async ({ page }) => {
base('Assets list shows empty state without a connected wallet (no crash) @allure.id=9849', async ({ page }) => {
await gaslessMeta('Availability');
const pageErrors: string[] = [];
await base.step('Open the jettons list without a connected wallet', async () => {
Expand All @@ -46,7 +46,7 @@ const test = testWithGaslessFixture({
});

test.describe('Availability (two-tab wallet)', () => {
test('No Gasless block in the GRAM transfer modal', async ({ minter, widget, wallet }) => {
test('No Gasless block in the GRAM transfer modal @allure.id=9983', async ({ minter, widget, wallet }) => {
await gaslessMeta('Availability');
await test.step('Connect wallet and open GRAM transfer', async () => {
await connectWallet({ widget, wallet });
Expand All @@ -57,7 +57,11 @@ test.describe('Availability (two-tab wallet)', () => {
});
});

test('Jetton transfer — Gasless checkbox enabled for a SignMessage wallet', async ({ minter, widget, wallet }) => {
test('Jetton transfer — Gasless checkbox enabled for a SignMessage wallet @allure.id=9869', async ({
minter,
widget,
wallet,
}) => {
await gaslessMeta('Availability');
await test.step('Connect wallet and open USDT transfer', async () => {
await connectWallet({ widget, wallet });
Expand Down
4 changes: 2 additions & 2 deletions apps/appkit-minter/e2e/specs/gasless-error-scenarios.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ test.describe('Relayer error rendering (no wallet)', () => {
// Load-time facet of hostile config handling: a hostile config response must not
// crash the app or execute script on boot. The render-sink facet (relayer error
// shown as escaped text) is covered with a connected wallet in gasless-security.spec.ts.
test('Hostile config response (HTTP 400) does not crash the app or execute script (load without wallet)', async ({
test('Hostile config response (HTTP 400) does not crash the app or execute script (load without wallet) @allure.id=9856', async ({
page,
}) => {
await gaslessMeta('Config errors');
Expand Down Expand Up @@ -61,7 +61,7 @@ test.describe('Relayer error rendering (no wallet)', () => {
});
});

test('App loads with a config that has no supported assets (no crash)', async ({ page }) => {
test('App loads with a config that has no supported assets (no crash) @allure.id=9859', async ({ page }) => {
await gaslessMeta('Config errors');
await test.step('Mock config with a valid relay address and empty gas_jettons', async () => {
// NB: relay_address must be a checksum-valid friendly address, else the
Expand Down
4 changes: 2 additions & 2 deletions apps/appkit-minter/e2e/specs/gasless-input-edges.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const test = testWithGaslessFixture({
});

test.describe('Transfer-form input edge cases (two-tab wallet, mocked relayer)', () => {
test('Invalid recipient address is rejected — error shown, send blocked', async ({
test('Invalid recipient address is rejected — error shown, send blocked @allure.id=9873', async ({
app,
minter,
widget,
Expand All @@ -46,7 +46,7 @@ test.describe('Transfer-form input edge cases (two-tab wallet, mocked relayer)',
});
});

test('Empty amount blocks Send; filling a valid amount unblocks it (comment with special characters does not interfere)', async ({
test('Empty amount blocks Send; filling a valid amount unblocks it (comment with special characters does not interfere) @allure.id=9857', async ({
app,
minter,
widget,
Expand Down
Loading
Loading