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
63 changes: 63 additions & 0 deletions .github/workflows/e2e-wallet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Tier-2 browser-wallet signing e2e (Playwright + mock provider on anvil).
#
# INFORMATIONAL / NOT A REQUIRED GATE (initially). This job drives a headless
# chromium against the real bridge page with a viem-backed mock window.ethereum
# that signs+broadcasts to an ephemeral anvil, exercising the full
# connect -> sign -> receipt loop with zero MetaMask. Keep it OFF the required
# status checks in branch protection until it has proven stable across a few
# weeks of PRs, then promote it to required.
#
# Distinct filename from the synced cross-repo e2e.yml (the /run-e2e cucumber
# pipeline) so the two never collide.
name: E2E Wallet (Tier 2)

on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches:
- v0.40
- v0.40-dev

jobs:
e2e-wallet:
name: Browser-wallet e2e (anvil lanes)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install libsecret runtime
run: sudo apt-get update && sudo apt-get install -y libsecret-1-0

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Build the project
run: npm run build

- name: Install Foundry (anvil)
uses: foundry-rs/foundry-toolchain@v1

- name: Install Playwright chromium
run: npx playwright install --with-deps chromium

- name: Run Tier-2 e2e (anvil lanes)
run: npm run test:e2e

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
if-no-files-found: ignore
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@ node_modules
dist
.idea
coverage
.ollama
.ollama

# Playwright (Tier-2 e2e) artifacts
test-results
playwright-report
.playwright
69 changes: 69 additions & 0 deletions e2e/config-default.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {test, expect, type Browser} from "@playwright/test";
import {startAnvil, readStubCallCount, type AnvilHandle} from "./fixtures/chain";
import {makeScratchEnv, runCli, readDescriptor, isPidAlive, type ScratchEnv} from "./fixtures/cli";
import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage";

/**
* S5 — config default `walletMode=browser`. With the config set and a live
* session, a bare `validator-join` (no --wallet) signs via the browser session;
* an explicit `--wallet keystore` overrides it and takes the keystore path
* (which errors here because no keystore exists — proving it did NOT enqueue).
*/
const CHAIN_ID = 61343;
const HASH_RE = /0x[0-9a-fA-F]{64}/;

test.describe.serial("S5 config default walletMode=browser", () => {
let anvil: AnvilHandle;
let browser: Browser;
let driver: BridgeDriver;
let scratch: ScratchEnv;

test.beforeAll(async () => {
anvil = await startAnvil({chainId: CHAIN_ID});
browser = await launchBrowser();
scratch = makeScratchEnv({
chainId: CHAIN_ID,
rpcUrl: anvil.rpcUrl,
stubAddress: anvil.stubAddress,
walletMode: "browser",
timing: {longPollMs: 1000},
});
({driver} = await establishSession(browser, anvil, scratch));
});

test.afterAll(async () => {
await driver?.close().catch(() => {});
const d = readDescriptor(scratch);
if (d && isPidAlive(d.pid)) {
try {
process.kill(d.pid, "SIGKILL");
} catch {
/* gone */
}
}
await anvil?.stop();
});

test("bare validator-join signs via session (no --wallet flag)", async () => {
const before = await readStubCallCount(anvil);
const res = await runCli(["staking", "validator-join", "--amount", "1"], scratch);
expect(res.all).toContain("Validator created successfully!");
expect(res.all.match(HASH_RE)?.[0]).toBeTruthy();
expect(await readStubCallCount(anvil)).toBe(before + 1);
});

test("--wallet keystore overrides config, takes keystore path (no enqueue)", async () => {
const before = await readStubCallCount(anvil);
const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "keystore"],
scratch,
);
// Keystore path selected: it fails on the missing account rather than
// signing via the browser session.
expect(res.all).not.toContain("Validator created successfully!");
expect(res.all.toLowerCase()).toContain("not found");
expect(res.exitCode).not.toBe(0);
// The browser session was never used → no new recorded call on the stub.
expect(await readStubCallCount(anvil)).toBe(before);
});
});
128 changes: 128 additions & 0 deletions e2e/errors.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {test, expect, type Browser} from "@playwright/test";
import {startAnvil, type AnvilHandle} from "./fixtures/chain";
import {
makeScratchEnv,
runCli,
readDescriptor,
daemonGet,
isPidAlive,
waitUntil,
type ScratchEnv,
} from "./fixtures/cli";
import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage";

/**
* S6 (subset) — deterministic error lanes with short timing overrides so they
* resolve in seconds:
* - user reject (4001): CLI surfaces "Transaction rejected in wallet", exits
* non-zero, and the session stays usable (daemon still pinging).
* - tab closed: page.close() → heartbeat goes stale → the next command fails
* fast with "tab appears to be closed", never hanging on a dead tab.
*/

test.describe.serial("S6a user reject (4001)", () => {
const CHAIN_ID = 61344;
let anvil: AnvilHandle;
let browser: Browser;
let driver: BridgeDriver;
let scratch: ScratchEnv;

test.beforeAll(async () => {
anvil = await startAnvil({chainId: CHAIN_ID});
browser = await launchBrowser();
scratch = makeScratchEnv({
chainId: CHAIN_ID,
rpcUrl: anvil.rpcUrl,
stubAddress: anvil.stubAddress,
timing: {longPollMs: 1000},
});
({driver} = await establishSession(browser, anvil, scratch, {behavior: "reject"}));
});

test.afterAll(async () => {
await driver?.close().catch(() => {});
const d = readDescriptor(scratch);
if (d && isPidAlive(d.pid)) {
try {
process.kill(d.pid, "SIGKILL");
} catch {
/* gone */
}
}
await anvil?.stop();
});

test("reject surfaces the message, exits non-zero, session survives", async () => {
const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "browser"],
scratch,
);
expect(res.all).toContain("Transaction rejected in wallet");
expect(res.exitCode).not.toBe(0);

// Session stays usable: descriptor present, daemon still answers /api/ping.
const d = readDescriptor(scratch);
expect(d).not.toBeNull();
expect(isPidAlive(d!.pid)).toBe(true);
const ping = await daemonGet(d!, "/api/ping");
expect(ping.status).toBe(200);
});
});

test.describe.serial("S6b tab closed", () => {
const CHAIN_ID = 61345;
const HEARTBEAT_DEAD_MS = 2000;
let anvil: AnvilHandle;
let browser: Browser;
let driver: BridgeDriver;
let scratch: ScratchEnv;

test.beforeAll(async () => {
anvil = await startAnvil({chainId: CHAIN_ID});
browser = await launchBrowser();
scratch = makeScratchEnv({
chainId: CHAIN_ID,
rpcUrl: anvil.rpcUrl,
stubAddress: anvil.stubAddress,
timing: {longPollMs: 500, heartbeatDeadMs: HEARTBEAT_DEAD_MS},
});
({driver} = await establishSession(browser, anvil, scratch));
});

test.afterAll(async () => {
await driver?.close().catch(() => {});
const d = readDescriptor(scratch);
if (d && isPidAlive(d.pid)) {
try {
process.kill(d.pid, "SIGKILL");
} catch {
/* gone */
}
}
await anvil?.stop();
});

test("closed tab → fail-fast 'tab appears to be closed'", async () => {
const d = readDescriptor(scratch)!;
await driver.closePage();

// Wait for the page heartbeat to go stale (no more polls after close).
const stale = await waitUntil(
async () => {
const {body} = await daemonGet(d, "/api/state");
const last = (body as {lastPagePollAt?: number}).lastPagePollAt ?? 0;
return last > 0 && Date.now() - last > HEARTBEAT_DEAD_MS;
},
{timeoutMs: 10_000, intervalMs: 200},
);
expect(stale, "heartbeat should go stale after tab close").toBe(true);

const res = await runCli(
["staking", "validator-join", "--amount", "1", "--wallet", "browser"],
scratch,
{timeoutMs: 20_000},
);
expect(res.all.toLowerCase()).toContain("tab appears to be closed");
expect(res.exitCode).not.toBe(0);
});
});
114 changes: 114 additions & 0 deletions e2e/fixtures/StakingStub.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"abi": [
{
"type": "function",
"name": "callCount",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "lastAmount",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "lastOperator",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "lastValidator",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "validatorJoin",
"inputs": [
{
"name": "_operator",
"type": "address",
"internalType": "address"
}
],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "payable"
},
{
"type": "function",
"name": "validatorJoin",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "payable"
},
{
"type": "event",
"name": "ValidatorJoin",
"inputs": [
{
"name": "operator",
"type": "address",
"indexed": false,
"internalType": "address"
},
{
"name": "validator",
"type": "address",
"indexed": false,
"internalType": "address"
},
{
"name": "amount",
"type": "uint256",
"indexed": false,
"internalType": "uint256"
}
],
"anonymous": false
}
],
"bytecode": "0x6080604052348015600e575f5ffd5b5061050e8061001c5f395ff3fe608060405260043610610054575f3560e01c806301fe0a781461005857806330e7349f1461008857806330ebe468146100b25780634b28f9a2146100d05780636eb3cd49146100fa578063829a86d914610124575b5f5ffd5b610072600480360381019061006d919061032f565b61014e565b60405161007f9190610369565b60405180910390f35b348015610093575f5ffd5b5061009c61015f565b6040516100a99190610369565b60405180910390f35b6100ba610184565b6040516100c79190610369565b60405180910390f35b3480156100db575f5ffd5b506100e4610193565b6040516100f1919061039a565b60405180910390f35b348015610105575f5ffd5b5061010e610198565b60405161011b9190610369565b60405180910390f35b34801561012f575f5ffd5b506101386101bd565b604051610145919061039a565b60405180910390f35b5f610158826101c3565b9050919050565b60025f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f61018e336101c3565b905090565b5f5481565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60035481565b5f60015f5f8282546101d591906103e0565b92505081905550335f546040516020016101f0929190610478565b604051602081830303815290604052805190602001205f1c90508160015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060025f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550346003819055507f2b7297b31452d0a13e57c605870ec3c9b4ac6ef33e5cbae7a0f00bc2a3e967828282346040516102c4939291906104a3565b60405180910390a1919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6102fe826102d5565b9050919050565b61030e816102f4565b8114610318575f5ffd5b50565b5f8135905061032981610305565b92915050565b5f60208284031215610344576103436102d1565b5b5f6103518482850161031b565b91505092915050565b610363816102f4565b82525050565b5f60208201905061037c5f83018461035a565b92915050565b5f819050919050565b61039481610382565b82525050565b5f6020820190506103ad5f83018461038b565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6103ea82610382565b91506103f583610382565b925082820190508082111561040d5761040c6103b3565b5b92915050565b5f8160601b9050919050565b5f61042982610413565b9050919050565b5f61043a8261041f565b9050919050565b61045261044d826102f4565b610430565b82525050565b5f819050919050565b61047261046d82610382565b610458565b82525050565b5f6104838285610441565b6014820191506104938284610461565b6020820191508190509392505050565b5f6060820190506104b65f83018661035a565b6104c3602083018561035a565b6104d0604083018461038b565b94935050505056fea26469706673582212202ea3a8dac16f254ee61dc37f81eb395845a459b3af57d33fab7a95c141e032b664736f6c63430008210033"
}
Loading
Loading