From 2ccc7322d04d7ee325590a50ad8ef0c1f8c55a07 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Wed, 29 Jul 2026 12:29:56 +0000 Subject: [PATCH] test(amm-ui): add swap UI test --- .gitignore | 4 +- README.md | 2 +- apps/amm/.gitignore | 7 + apps/amm/README.md | 68 ++++- apps/amm/amm-tokens.json.example | 16 ++ apps/amm/qml/components/swap/SwapCard.qml | 5 + apps/amm/qml/components/swap/TokenInput.qml | 7 + .../components/swap/TokenSelectorModal.qml | 6 + apps/amm/qml/pages/SwapPage.qml | 2 + apps/amm/tests/swap.mjs | 250 ++++++++++++++++++ 10 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 apps/amm/amm-tokens.json.example create mode 100644 apps/amm/tests/swap.mjs diff --git a/.gitignore b/.gitignore index 3b98e775..162e2db9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ target/ *.bin -**/*/result +# Nix build symlinks (result, result-mcp, result-core, result-lgx, …) at any depth +result +result-* **/*/.DS_Store diff --git a/README.md b/README.md index 5b2dc090..d71c6f58 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ To use the Swap view, also set `AMM_PROGRAM_BIN` (your deployed `amm.bin`) and ```bash AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ -TOKENS_CONFIG=$(pwd)/amm-tokens.json \ +TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \ nix run .#amm-ui ``` diff --git a/apps/amm/.gitignore b/apps/amm/.gitignore index 7274a4b3..6acdd425 100644 --- a/apps/amm/.gitignore +++ b/apps/amm/.gitignore @@ -1,3 +1,10 @@ result result-* modules/ + +# Personal token/account config — copy amm-tokens.json.example and fill in +# your own definitionId/holding accounts. Never commit your real accounts. +amm-tokens.json + +# Screenshot artifacts written by tests/swap.mjs +tests/*.png diff --git a/apps/amm/README.md b/apps/amm/README.md index b33841a0..e9a893c4 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -144,7 +144,7 @@ not preserve the working directory, so relative paths won't resolve: ```bash AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ -TOKENS_CONFIG=$(pwd)/amm-tokens.json \ +TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \ nix run .#amm-ui ``` @@ -197,6 +197,14 @@ account the wallet will sign transfers from/to for that token): ] ``` +The quickest start is to copy the checked-in template and edit it: + +```bash +cp apps/amm/amm-tokens.json.example apps/amm/amm-tokens.json # then replace the REPLACE_… placeholders +``` + +`amm-tokens.json` is git-ignored so your own accounts never get committed. + If `TOKENS_CONFIG` is unset, unreadable, or not a valid JSON array, the token picker stays empty (a `qWarning` naming the exact cause is logged to stderr; no swap can be started). `definitionId`/`holding` may be given as base58 (as the @@ -206,7 +214,7 @@ Full command with both variables set (absolute paths, from the repo root): ```bash AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ -TOKENS_CONFIG=$(pwd)/amm-tokens.json \ +TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \ nix run .#amm-ui ``` @@ -215,6 +223,62 @@ nix run .#amm-ui New Position validation commands and acceptance criteria live in [VALIDATION.md](VALIDATION.md). +## Running the UI tests + +The UI tests live in `apps/amm/tests/` (e.g. `swap.mjs`). They drive the running +app through a QML inspector: each test connects to the inspector's TCP server, +finds elements, clicks them, and asserts on the resulting state. `swap.mjs` +selects two tokens, enters a sell amount, submits a swap end-to-end, and then +verifies the pool reserves actually changed on-chain (read back from the +sequencer via the app's `resolvePool`). + +The test framework itself — the `test()` / `run()` / `app.*` API that the tests +import from `test-framework/framework.mjs` — comes from the +[**`logos-co/logos-qt-mcp`**](https://github.com/logos-co/logos-qt-mcp) repo. +It isn't vendored here; the `nix build .#test-framework` step below materializes +it (Nix resolves it via this app's flake inputs, pinned in `flake.lock`). + +Run everything **from the repository root** (the `apps/amm` flake can't resolve +`amm_client_ffi` on its own). + +**Prerequisites** for the swap test to complete: + +- a token list with ≥2 tokens — copy `apps/amm/amm-tokens.json.example` to + `apps/amm/amm-tokens.json` and fill it in (see [Token list config](#token-list-config-required-for-the-swap-token-picker)), +- the AMM program binary (see [AMM program binary](#amm-program-binary-required-for-swaps)), +- a running sequencer with a pool + liquidity for that token pair, and an open + wallet — otherwise the swap resolves to "No pool / no liquidity" and can't submit. + +**From scratch:** + +```bash +# 1. Build the JS test framework once. The -o path is where the tests expect it +# (apps/amm/tests/swap.mjs imports ../result-mcp); or set LOGOS_QT_MCP instead. +nix build .#test-framework -o apps/amm/result-mcp + +# 2. Terminal 1 — launch the AMM UI with a real, visible window. The inspector +# listens on localhost:3768. Absolute paths ($(pwd)/…) because nix run may +# not preserve the working directory. +AMM_DEBUG=1 \ + AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ + TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \ + nix run .#amm-ui + +# 3. Terminal 2 — run a test against the running app; watch it drive the UI. +node apps/amm/tests/swap.mjs +``` + +On failure the test prints the relevant `SwapCard` state and saves screenshot +PNGs next to the test (`apps/amm/tests/swap-*.png`, git-ignored) for inspection. + +**Headless CI variant** (no window, launches the app itself, pass/fail only): + +```bash +nix build .#integration-test -L +``` + +It runs every `*.mjs` under `apps/amm/tests/` with `QT_QPA_PLATFORM=offscreen`. + ## Updating Dependencies To update the pinned versions of dependencies in `flake.lock`: diff --git a/apps/amm/amm-tokens.json.example b/apps/amm/amm-tokens.json.example new file mode 100644 index 00000000..16ca0002 --- /dev/null +++ b/apps/amm/amm-tokens.json.example @@ -0,0 +1,16 @@ +[ + { + "symbol": "TKA", + "name": "Token A", + "definitionId": "REPLACE_WITH_TOKEN_A_DEFINITION_ID", + "holding": "REPLACE_WITH_YOUR_TOKEN_A_HOLDING_ACCOUNT", + "decimals": 18 + }, + { + "symbol": "TKB", + "name": "Token B", + "definitionId": "REPLACE_WITH_TOKEN_B_DEFINITION_ID", + "holding": "REPLACE_WITH_YOUR_TOKEN_B_HOLDING_ACCOUNT", + "decimals": 18 + } +] diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 9dbe0235..102c97c6 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -319,6 +319,8 @@ Rectangle { Layout.fillWidth: true theme: root.theme label: "Sell" + inputObjectName: "swapSellInput" + buttonObjectName: "swapSellTokenButton" amount: root.sellDisplay token: root.sellToken active: root.editingSide === "sell" @@ -377,6 +379,8 @@ Rectangle { Layout.fillWidth: true theme: root.theme label: "Buy" + inputObjectName: "swapBuyInput" + buttonObjectName: "swapBuyTokenButton" amount: root.buyDisplay token: root.buyToken active: root.editingSide === "buy" @@ -440,6 +444,7 @@ Rectangle { Rectangle { id: ctaBox + objectName: "swapSubmitButton" Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 8 diff --git a/apps/amm/qml/components/swap/TokenInput.qml b/apps/amm/qml/components/swap/TokenInput.qml index 4b582f7f..8707da07 100644 --- a/apps/amm/qml/components/swap/TokenInput.qml +++ b/apps/amm/qml/components/swap/TokenInput.qml @@ -16,6 +16,12 @@ Rectangle { // string (see decimalToU128Le in AmmUiBackend.cpp); fractional/decimal // input there fails opaquely rather than being scaled. property bool digitsOnly: false + // objectName forwarded to the inner TextInput so UI tests can target the + // sell/buy amount fields deterministically (see apps/amm/tests/). + property alias inputObjectName: tiInput.objectName + // objectName forwarded to the token-select button, so tests can open the + // right picker by objectId rather than fuzzy text. + property alias buttonObjectName: tokenButton.objectName signal tokenClicked() signal inputEdited(string newValue) @@ -99,6 +105,7 @@ Rectangle { } Rectangle { + id: tokenButton height: 40 radius: 20 color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg diff --git a/apps/amm/qml/components/swap/TokenSelectorModal.qml b/apps/amm/qml/components/swap/TokenSelectorModal.qml index bb487d8a..de7a00ef 100644 --- a/apps/amm/qml/components/swap/TokenSelectorModal.qml +++ b/apps/amm/qml/components/swap/TokenSelectorModal.qml @@ -6,6 +6,9 @@ import "TokenVisuals.js" as TokenVisuals Item { id: root + // Stable hook so UI tests can read `visible` to know the picker is open. + objectName: "tokenSelectorModal" + property var theme property var tokens: [] property string searchText: "" @@ -160,6 +163,9 @@ Item { }) delegate: TokenListItem { width: tokenList.width + // Stable hook for UI tests to enumerate the list (QML + // file-defined types aren't matchable via findByType). + objectName: "tokenListItem" theme: root.theme tokenName: modelData.name tokenSymbol: modelData.symbol diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index d524e527..8433d234 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -100,6 +100,7 @@ Item { SwapCard { id: swapCard + objectName: "swapCard" Layout.alignment: Qt.AlignHCenter theme: pageTheme tokens: root.tokens @@ -172,6 +173,7 @@ Item { TransactionConfirmationDialog { id: swapConfirmationDialog + objectName: "swapConfirmDialog" title: qsTr("Confirm swap") confirmText: qsTr("Confirm swap") summary: swapConfirmationSummary diff --git a/apps/amm/tests/swap.mjs b/apps/amm/tests/swap.mjs new file mode 100644 index 00000000..30c1bd2e --- /dev/null +++ b/apps/amm/tests/swap.mjs @@ -0,0 +1,250 @@ +// --------------------------------------------------------------------------- +// AMM UI test — swap 10000 of the first token in the list for the second. +// +// Drives the running AMM UI through the QML inspector (logos-qt-mcp). Run it +// against a LIVE app window so you can watch it happen — see "Running the UI +// tests" in apps/amm/README.md for how to run it from scratch. +// +// Requires, in the running app: +// * the wallet backend ready with a token list (TOKENS_CONFIG) of >= 2 tokens +// * an existing pool WITH liquidity for token[0]/token[1] — otherwise the CTA +// stays "No pool / no liquidity" and the swap can't be submitted +// * (to actually land on-chain) an open wallet + reachable sequencer +// --------------------------------------------------------------------------- + +import { resolve } from "node:path"; +import { writeFile } from "node:fs/promises"; + +// Locate the test framework. This defaults to ./result-mcp next to this test +// (apps/amm/result-mcp), which is where the README's build step puts it: +// from the repo root run `nix build .#test-framework -o apps/amm/result-mcp`. +// Override with LOGOS_QT_MCP=/abs/path/to/result-mcp if it lives elsewhere. +const fwRoot = + process.env.LOGOS_QT_MCP || + new URL("../result-mcp", import.meta.url).pathname; +const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); + +const SELL_AMOUNT = "10000"; + +// --- small helpers over the raw inspector commands ------------------------- + +const ignore = async (fn) => { try { return await fn(); } catch { /* best effort */ } }; + +// Resolve a single object's inspector id by its QML objectName. +async function idByObjectName(app, name) { + const res = await app.findByProperty("objectName", name); + if (res.error || !res.matches || res.matches.length === 0) + throw new Error(`no object with objectName="${name}" (is the app on the Trade tab?)`); + return res.matches[0].id; +} + +// Read a single property off an object id. +async function prop(app, id, name) { + const props = (await app.getProperties(id)).properties || []; + const p = props.find((x) => x.name === name); + return p ? p.value : undefined; +} + +// Is the token picker actually visible right now? +async function pickerOpen(app) { + const id = await idByObjectName(app, "tokenSelectorModal"); + return (await prop(app, id, "visible")) === true; +} + +// Open the sell/buy picker by clicking its token button (by objectId — center +// click, no fuzzy text), then wait until the modal is genuinely visible. +async function openPicker(app, buttonObjectName) { + const btnId = await idByObjectName(app, buttonObjectName); + await app.inspector.send("click", { objectId: btnId }); + await app.waitFor( + async () => { if (!(await pickerOpen(app))) throw new Error("picker not open"); }, + { timeout: 5000, interval: 200, description: `open ${buttonObjectName}` }, + ); +} + +// The token list delegates, in list order (objectName "tokenListItem"). +async function tokenItems(app) { + const res = await app.findByProperty("objectName", "tokenListItem"); + return (res && res.matches) || []; +} + +// Click the Nth token in the (open) picker by objectId; returns its symbol. +async function pickToken(app, index) { + const items = await tokenItems(app); + if (items.length <= index) + throw new Error(`token #${index + 1} not found — only ${items.length} token(s) in the list`); + const id = items[index].id; + const symbol = await prop(app, id, "tokenSymbol"); + await app.inspector.send("click", { objectId: id }); + // Selecting a token closes the picker. + await app.waitFor( + async () => { if (await pickerOpen(app)) throw new Error("picker still open"); }, + { timeout: 5000, interval: 200, description: `select token #${index + 1}` }, + ); + return symbol; +} + +// Enter the sell amount by setting the SwapCard's state directly. Synthesizing +// keystrokes needs the TextInput to hold active focus, which the inspector +// can't reliably grant headlessly; setting sellInput drives the exact same +// reactive flow (estimate -> CTA -> confirm -> submit) and the bound TextInput +// still displays the value. +async function setSellAmount(app, amount) { + const cardId = await idByObjectName(app, "swapCard"); + await app.inspector.send("setProperty", { objectId: cardId, property: "editingSide", value: "sell" }); + await app.inspector.send("setProperty", { objectId: cardId, property: "sellInput", value: String(amount) }); +} + +// Read the SwapCard's swap/pool state — explains WHY the CTA isn't "Swap" yet. +async function cardState(app) { + const id = await idByObjectName(app, "swapCard"); + const props = (await app.getProperties(id)).properties || []; + const get = (n) => { const p = props.find((x) => x.name === n); return p ? p.value : undefined; }; + return { + editingSide: get("editingSide"), + sellInput: get("sellInput"), + poolLoading: get("poolLoading"), + poolResolved: get("poolResolved"), + poolExists: get("poolExists"), + poolError: get("poolError"), + swapError: get("swapError"), + canSubmit: get("canSubmit"), + submitButtonText: get("submitButtonText"), + }; +} + +// Read the pool's on-chain reserves as the app sees them. These come from +// AmmUiBackend.resolvePool() — a live query against the sequencer — so +// comparing them before/after a swap is a genuine chain-state assertion. +async function poolReserves(app) { + const id = await idByObjectName(app, "swapCard"); + return { + a: await prop(app, id, "poolReserveA"), + b: await prop(app, id, "poolReserveB"), + }; +} + +// Save a screenshot PNG next to this test file so failures are inspectable. +async function saveShot(app, name) { + const shot = await ignore(() => app.screenshot()); + if (shot && shot.image) { + const path = new URL(`./${name}.png`, import.meta.url).pathname; + await writeFile(path, Buffer.from(shot.image, "base64")); + console.log(` screenshot -> ${path}`); + } +} + +// --- the test --------------------------------------------------------------- + +test("amm swap: sell 10000 of token #1 for token #2", async (app) => { + // 1. Wait for the swap card to render (Trade tab is the default, index 0). + await app.waitFor( + async () => { await app.expectTexts(["Sell", "Buy"]); }, + { timeout: 20000, interval: 500, description: "swap card to load" }, + ); + await ignore(() => app.click("Trade")); // make the active tab explicit + + // 2. Pick the FIRST token for the SELL side. + await openPicker(app, "swapSellTokenButton"); + const first = await pickToken(app, 0); + + // 3. Pick the SECOND token for the BUY side. + await openPicker(app, "swapBuyTokenButton"); + const second = await pickToken(app, 1); + console.log(` sell ${first} -> buy ${second}`); + + // 5. Enter 10000 as the sell amount. + await setSellAmount(app, SELL_AMOUNT); + await app.expectTexts([SELL_AMOUNT]); // the amount should now be visible + + // 6. Wait for pool resolution — the CTA turns into a live "Swap" button + // (canSubmit) once a pool with enough liquidity is found for this pair. + try { + await app.waitFor( + async () => { + const s = await cardState(app); + if (!s.canSubmit) throw new Error("not submittable yet"); + }, + { timeout: 15000, interval: 500, description: "pool resolve / CTA ready" }, + ); + } catch { + await saveShot(app, "swap-cta-not-ready"); + throw new Error(`CTA never became submittable. Card state: ${JSON.stringify(await cardState(app))}`); + } + await saveShot(app, "swap-filled"); // filled-in swap form + + // Capture the pool reserves BEFORE the swap (baseline for the on-chain check). + const before = await poolReserves(app); + console.log(` pool reserves before: A=${before.a} B=${before.b}`); + + // 7. Submit -> confirmation dialog -> confirm (all by objectId). + const submitId = await idByObjectName(app, "swapSubmitButton"); + await app.inspector.send("click", { objectId: submitId }); + + // The confirm dialog is a shared TransactionConfirmationDialog (a Popup), so + // its open state is the Popup's `visible`, and the confirm button carries the + // shared objectName "transactionConfirmButton". + const dialogId = await idByObjectName(app, "swapConfirmDialog"); + await app.waitFor( + async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("dialog not open"); }, + { timeout: 8000, interval: 300, description: "confirm dialog open" }, + ); + + // Click the confirm button. QtQuick Controls Buttons don't always react to the + // inspector's synthetic click, so if the dialog doesn't close, fall back to + // invoking the dialog's confirm() slot directly. + const confirmId = await idByObjectName(app, "transactionConfirmButton"); + await app.inspector.send("click", { objectId: confirmId }); + // QtQuick Controls Buttons don't reliably react to the inspector's synthetic + // click, so if the dialog hasn't closed, invoke confirm() in the dialog's own + // QML context (this is what fires executeSwap via onConfirmed). + try { + await app.waitFor( + async () => { if ((await prop(app, dialogId, "visible")) === true) throw new Error("still open"); }, + { timeout: 3000, interval: 300, description: "confirm click registered" }, + ); + } catch { + console.log(" confirm button click didn't take — invoking confirm() via evaluate"); + await ignore(() => app.inspector.send("evaluate", { expression: "confirm()", objectId: dialogId })); + } + + // 8. Verify the swap actually hit the chain: after a successful submit the + // card re-resolves the pool from the sequencer, so the reserves must move. + // (Needs an open wallet + reachable local sequencer with this pool.) + const cardId = await idByObjectName(app, "swapCard"); + let after = before; + try { + await app.waitFor( + async () => { + // Force a fresh pool read each poll: after a swap the app re-resolves + // only once and can race the not-yet-applied block, leaving poolReserveA/B + // stale. Re-trigger doResolvePool() until the applied block shows up. + await ignore(() => app.inspector.send("evaluate", { expression: "doResolvePool()", objectId: cardId })); + await new Promise((r) => setTimeout(r, 800)); + after = await poolReserves(app); + if (after.a === before.a && after.b === before.b) + throw new Error("reserves unchanged"); + }, + { timeout: 40000, interval: 1200, description: "pool reserves to change on-chain" }, + ); + } catch { + await saveShot(app, "swap-result"); + const s = await cardState(app); + const inProgress = await prop(app, cardId, "swapInProgress"); + const sellLeft = await prop(app, cardId, "sellInput"); + throw new Error( + `pool reserves did not change after the swap.\n` + + ` before: A=${before.a} B=${before.b}\n` + + ` after: A=${after.a} B=${after.b}\n` + + ` swapInProgress=${inProgress} sellInput="${sellLeft}" swapError=${JSON.stringify(s.swapError)}\n` + + ` (sellInput="" => executeSwap ran & reset; sellInput="${SELL_AMOUNT}" => confirm never triggered executeSwap)`, + ); + } + console.log(` pool reserves after: A=${after.a} B=${after.b} ✓ changed on-chain`); + await saveShot(app, "swap-result"); +}); + +run(); + +// How to run these tests (from scratch, interactive + CI): see the +// "Running the UI tests" section in apps/amm/README.md.