test(amm-ui): add swap UI test

This commit is contained in:
Andrea Franz
2026-08-05 17:19:38 +02:00
parent 8cc4055f64
commit fc7b07174c
10 changed files with 363 additions and 4 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
target/
*.bin
**/*/result
# Nix build symlinks (result, result-mcp, result-core, result-lgx, …) at any depth
result
result-*
**/*/.DS_Store
+1 -1
View File
@@ -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
```
+7
View File
@@ -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
+66 -2
View File
@@ -145,7 +145,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
```
@@ -198,6 +198,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
@@ -207,7 +215,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
```
@@ -216,6 +224,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`:
+16
View File
@@ -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
}
]
@@ -326,6 +326,8 @@ Rectangle {
Layout.fillWidth: true
theme: root.theme
label: "Sell"
inputObjectName: "swapSellInput"
buttonObjectName: "swapSellTokenButton"
amount: root.sellDisplay
token: root.sellToken
active: root.editingSide === "sell"
@@ -384,6 +386,8 @@ Rectangle {
Layout.fillWidth: true
theme: root.theme
label: "Buy"
inputObjectName: "swapBuyInput"
buttonObjectName: "swapBuyTokenButton"
amount: root.buyDisplay
token: root.buyToken
active: root.editingSide === "buy"
@@ -447,6 +451,7 @@ Rectangle {
Rectangle {
id: ctaBox
objectName: "swapSubmitButton"
Layout.fillWidth: true
Layout.topMargin: 8
Layout.bottomMargin: 8
@@ -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
@@ -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
+2
View File
@@ -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
+250
View File
@@ -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.