mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
297 lines
14 KiB
JavaScript
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.
|