Files
lez-programs/apps/amm/tests/swap.mjs
r4bbit 86575a65aa test(apps/amm): select the funding account before submitting a swap
canSubmit now requires a chosen holding on both swap sides, so the swap e2e
test must select them first. The selector auto-selects a single holding, but
pick it explicitly (robust to multi-account wallets) via a new selectAccount
step that waits for the holdings to load and selects the first match.

Expose each side's selector via selectorObjectName (swapSell/BuyAccountSelector)
and surface sellHolding/buyHolding in the failure diagnostics.
2026-08-11 12:04:48 +02:00

297 lines
14 KiB
JavaScript

// ---------------------------------------------------------------------------
// AMM UI test — swap the first token in the list for the second (SELL_AMOUNT).
//
// 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 = "100";
// --- 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;
}
// Pick the funding account for a swap side. The selector auto-selects when the
// wallet holds the token in exactly one account, but make the choice explicit (and robust
// to multi-account wallets with zero-balance holdings): wait for the holdings to populate,
// then select the highest-balance match. canSubmit now requires BOTH sides' holdings to be
// set, so a swap can't be submitted until this runs.
async function selectAccount(app, selectorObjectName) {
const id = await idByObjectName(app, selectorObjectName);
await app.waitFor(
async () => { if ((await prop(app, id, "hasFunds")) !== true) throw new Error("no matching holdings yet"); },
{ timeout: 10000, interval: 300, description: `${selectorObjectName} holdings to load` },
);
// setSelection/accountIdFor/matchingAccounts/valueFor are members of the selector, so
// evaluate in its own QML context (same pragmatic style as setSellAmount). tokenHoldings can
// include zero-balance holdings for the token, and hasFunds only checks the match COUNT — so
// pick the matching account with the largest balanceRaw rather than matchingAccounts[0],
// which could be an empty holding the swap transfer can't debit. balanceRaw values are
// non-negative base-unit integer strings, so "longer wins, else lexicographic" is an exact
// max without needing BigInt.
await app.inspector.send("evaluate", {
expression:
"(function(){var r=matchingAccounts,b=r[0],bb=String(valueFor(b,'balanceRaw')||'0');"
+ "for(var i=1;i<r.length;++i){var v=String(valueFor(r[i],'balanceRaw')||'0');"
+ "if(v.length>bb.length||(v.length===bb.length&&v>bb)){b=r[i];bb=v;}}"
+ "setSelection(accountIdFor(b),false);})()",
objectId: id,
});
await app.waitFor(
async () => { if (!(await prop(app, id, "selectedAccountId"))) throw new Error("holding not selected yet"); },
{ timeout: 5000, interval: 200, description: `${selectorObjectName} holding selected` },
);
}
// 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 updates the property (and
// the bound TextInput display) so the reactive flow can run.
//
// Setting a property programmatically re-evaluates dependent BINDINGS but does
// not fire the onSellInputChanged HANDLER the way real typing does — and the
// Sell preview is now driven by that handler (onSellInputChanged ->
// requestQuoteIn -> async backend.swapExactInQuote), not a synchronous binding.
// So kick the quote explicitly, mirroring the doResolvePool() nudge used in the
// reserve-change check below. Without this the CTA never leaves "Amount too
// small" because the server quote never fires.
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) });
await app.inspector.send("evaluate", { expression: "requestQuoteIn()", objectId: cardId });
}
// 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"),
sellHolding: get("sellHolding"),
buyHolding: get("buyHolding"),
};
}
// 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 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}`);
// 4. Pick the funding account for each side (canSubmit needs both selected).
await selectAccount(app, "swapSellAccountSelector");
await selectAccount(app, "swapBuyAccountSelector");
// 5. Enter 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.