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.
333 lines
16 KiB
JavaScript
333 lines
16 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// AMM UI test — create a NEW pool (A/C) through the Liquidity view.
|
|
//
|
|
// Drives the running AMM UI through the QML inspector (logos-qt-mcp), the same
|
|
// way swap.mjs does. It selects the A/C pair (which the setup script leaves
|
|
// UNSEEDED — only A/B is created), lets the form auto-fill the minimum opening
|
|
// deposit, submits the create, and verifies the pool now exists ON-CHAIN.
|
|
//
|
|
// Prereqs in the running app (see apps/amm/tests/README.md):
|
|
// * launched against the isolated test wallet + TOKENS_CONFIG that
|
|
// testnet/setup-amm-testnet.sh writes (TKA, TKB, TKC)
|
|
// * an open wallet + reachable local sequencer
|
|
// * the A/C pool must NOT exist yet (setup only seeds A/B)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { resolve } from "node:path";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
|
|
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"));
|
|
|
|
// The token config the app was launched with (same file the setup script writes).
|
|
const TOKENS_CONFIG =
|
|
process.env.TOKENS_CONFIG ||
|
|
new URL("../testnet/amm-tokens.json", import.meta.url).pathname;
|
|
|
|
// --- small helpers (mirrors swap.mjs) --------------------------------------
|
|
|
|
const ignore = async (fn) => { try { return await fn(); } catch { /* best effort */ } };
|
|
|
|
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 Liquidity tab?)`);
|
|
return res.matches[0].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;
|
|
}
|
|
|
|
async function setProp(app, id, property, value) {
|
|
await app.inspector.send("setProperty", { objectId: id, property, value });
|
|
}
|
|
|
|
async function evaluate(app, id, expression) {
|
|
await app.inspector.send("evaluate", { expression, objectId: id });
|
|
}
|
|
|
|
// The NewPositionForm's create/pool state — explains WHY the CTA isn't ready.
|
|
async function formState(app, formId) {
|
|
const props = (await app.getProperties(formId)).properties || [];
|
|
const get = (n) => { const p = props.find((x) => x.name === n); return p ? p.value : undefined; };
|
|
return {
|
|
activePool: get("activePool"),
|
|
missingPool: get("missingPool"),
|
|
canConfirm: get("canConfirm"),
|
|
amountA: get("amountA"),
|
|
amountB: get("amountB"),
|
|
submitError: get("submitError"),
|
|
transactionId: get("transactionId"),
|
|
selectedHoldingAId: get("selectedHoldingAId"),
|
|
selectedHoldingBId: get("selectedHoldingBId"),
|
|
};
|
|
}
|
|
|
|
// Pick the funding account for a create-pool side. The selector auto-selects a
|
|
// single holding, but choose it explicitly (robust to multi-account wallets):
|
|
// wait for the holdings to populate, then select the first match. canConfirm now
|
|
// requires both A/B holdings before a pool can be created.
|
|
async function selectAccount(app, selectorObjectName) {
|
|
// The selector lives in a Loader that instantiates only once the pool is known
|
|
// to be missing, so it may render a frame after missingPool flips — wait for it.
|
|
let id;
|
|
await app.waitFor(
|
|
async () => { id = await idByObjectName(app, selectorObjectName); },
|
|
{ timeout: 10000, interval: 300, description: `${selectorObjectName} to render` },
|
|
);
|
|
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` },
|
|
);
|
|
await evaluate(app, id, "setSelection(accountIdFor(matchingAccounts[0]), false)");
|
|
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` },
|
|
);
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
// --- Swap-card token picker helpers (shared shape with swap.mjs), used only to
|
|
// read the A/C pool's on-chain state for the final assertion. ------------
|
|
|
|
async function pickerOpen(app) {
|
|
const id = await idByObjectName(app, "tokenSelectorModal");
|
|
return (await prop(app, id, "visible")) === true;
|
|
}
|
|
|
|
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}` },
|
|
);
|
|
}
|
|
|
|
async function pickToken(app, index) {
|
|
const res = await app.findByProperty("objectName", "tokenListItem");
|
|
const items = (res && res.matches) || [];
|
|
if (items.length <= index)
|
|
throw new Error(`token #${index + 1} not found — only ${items.length} in the list`);
|
|
await app.inspector.send("click", { objectId: items[index].id });
|
|
await app.waitFor(
|
|
async () => { if (await pickerOpen(app)) throw new Error("picker still open"); },
|
|
{ timeout: 5000, interval: 200, description: `select token #${index + 1}` },
|
|
);
|
|
}
|
|
|
|
// --- the test ---------------------------------------------------------------
|
|
|
|
test("amm liquidity: create the A/C pool", async (app) => {
|
|
// Resolve the A and C token-definition ids from the launched token config.
|
|
const tokens = JSON.parse(await readFile(TOKENS_CONFIG, "utf8"));
|
|
const bySymbol = (s) => {
|
|
const t = tokens.find((x) => (x.symbol || "").toUpperCase() === s);
|
|
if (!t) throw new Error(`token ${s} not in ${TOKENS_CONFIG} — run the setup script`);
|
|
return t.definitionId;
|
|
};
|
|
const tokenA = bySymbol("TKA");
|
|
const tokenC = bySymbol("TKC");
|
|
console.log(` create pool A(${tokenA.slice(0, 6)}…) / C(${tokenC.slice(0, 6)}…)`);
|
|
|
|
// 1. Switch to the Liquidity tab and wait for the form to render.
|
|
await app.waitFor(
|
|
async () => { await app.expectTexts(["Trade", "Pool"]); },
|
|
{ timeout: 20000, interval: 500, description: "nav bar to load" },
|
|
);
|
|
// "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly.
|
|
const navBarId = await idByObjectName(app, "navBar");
|
|
await evaluate(app, navBarId, "select(2, 1)");
|
|
// waitFor resolves when the condition stops throwing — it does NOT return the
|
|
// callback's value, so fetch the id with a direct call afterwards.
|
|
await app.waitFor(
|
|
async () => { await idByObjectName(app, "newPositionForm"); },
|
|
{ timeout: 10000, interval: 300, description: "liquidity form to render" },
|
|
);
|
|
const formId = await idByObjectName(app, "newPositionForm");
|
|
|
|
// 2. Select the A/C pair through selectToken() — the same entry point the token picker
|
|
// uses — so the form runs its normal selection logic and resetPairDraft(), clearing any
|
|
// persisted amounts/price/minimums from a reused app session and firing a fresh quote.
|
|
// Setting selectedToken*Id directly would bypass that reset and could assert against
|
|
// stale draft state. Both tokens are already in the config, so no async token resolution
|
|
// is needed. The first missing-pool quote returns the minimum opening deposit, which
|
|
// applyQuoteSideEffects auto-fills — so canConfirm becomes ready.
|
|
await evaluate(app, formId, `selectToken("A", "${tokenA}")`);
|
|
await evaluate(app, formId, `selectToken("B", "${tokenC}")`);
|
|
|
|
// Sanity: the selection must stick — the ids have to be present in the token
|
|
// config the app was launched with, or tokenById can't resolve them.
|
|
await app.waitFor(
|
|
async () => {
|
|
const a = await prop(app, formId, "selectedTokenAId");
|
|
const b = await prop(app, formId, "selectedTokenBId");
|
|
if (!a || !b) throw new Error(`pair not selected (A=${a} B=${b})`);
|
|
},
|
|
{ timeout: 5000, interval: 300, description: "A/C pair selected" },
|
|
);
|
|
await evaluate(app, formId, "requestQuote(true)");
|
|
|
|
// 3. Wait for the missing-pool quote (which makes the per-side account selectors
|
|
// render), pick the funding account for each side, then wait for a submittable
|
|
// create quote — canConfirm needs the funded minimum deposit AND both holdings.
|
|
try {
|
|
await app.waitFor(
|
|
async () => {
|
|
const s = await formState(app, formId);
|
|
if (s.activePool === true)
|
|
throw new Error("A/C pool already exists — reset the testnet (only A/B should be seeded)");
|
|
if (!s.missingPool) throw new Error("pool status not resolved yet");
|
|
},
|
|
{ timeout: 20000, interval: 500, description: "missing-pool quote" },
|
|
);
|
|
await selectAccount(app, "newPositionAccountSelectorA");
|
|
await selectAccount(app, "newPositionAccountSelectorB");
|
|
// The live app window persists across runs, so the form may carry leftover deposit
|
|
// amounts and a stale "Position submitted" transactionId from a prior create — both
|
|
// block a clean run (mismatched amounts keep canConfirm false; a stale txId would make
|
|
// step 5's "submitted" wait pass instantly). resetPairDraft() clears the amounts/price
|
|
// and re-quotes (price-only), so applyQuoteSideEffects re-fills the fresh minimum
|
|
// deposit, and its draftChanged() clears the stale transactionId. Holdings are kept.
|
|
await evaluate(app, formId, "resetPairDraft()");
|
|
await app.waitFor(
|
|
async () => {
|
|
const s = await formState(app, formId);
|
|
if (!s.canConfirm) throw new Error("create CTA not ready yet");
|
|
},
|
|
{ timeout: 20000, interval: 500, description: "create CTA ready" },
|
|
);
|
|
} catch (e) {
|
|
await saveShot(app, "create-pool-cta-not-ready");
|
|
throw new Error(`${e.message}. Form state: ${JSON.stringify(await formState(app, formId))}`);
|
|
}
|
|
await saveShot(app, "create-pool-filled");
|
|
console.log(` minimum deposit: A=${(await formState(app, formId)).amountA} C=${(await formState(app, formId)).amountB}`);
|
|
|
|
// 3b. A brand-new pool has no LP holding yet, so the LP-destination selector shows a fresh
|
|
// account — no matching holdings, nothing preselected — and the create mints a new LP
|
|
// account. (create-pool has no lpDefinitionId, so the selector never matches a holding.)
|
|
const lpSelectorId = await idByObjectName(app, "lpDestinationSelector");
|
|
await app.waitFor(
|
|
async () => {
|
|
if (await prop(app, lpSelectorId, "hasFunds"))
|
|
throw new Error("unexpected existing LP holding for a brand-new pool");
|
|
if (await prop(app, lpSelectorId, "selectedAccountId"))
|
|
throw new Error("LP destination should be unselected (fresh) for create-pool");
|
|
},
|
|
{ timeout: 5000, interval: 300, description: "LP destination is a fresh account" },
|
|
);
|
|
|
|
// 4. Submit -> confirmation dialog -> confirm.
|
|
const dialogId = await idByObjectName(app, "liquidityConfirmDialog");
|
|
const submitId = await idByObjectName(app, "newPositionSubmitButton");
|
|
await app.inspector.send("click", { objectId: submitId });
|
|
|
|
// QtQuick Controls Buttons don't reliably take the inspector's synthetic click,
|
|
// so if the dialog didn't open, emit the form's confirmationRequested signal
|
|
// directly (exactly what the button's onClicked does).
|
|
try {
|
|
await app.waitFor(
|
|
async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("not open"); },
|
|
{ timeout: 4000, interval: 300, description: "confirm dialog open" },
|
|
);
|
|
} catch {
|
|
console.log(" submit click didn't take — emitting confirmationRequested via evaluate");
|
|
await ignore(() => evaluate(app, formId, "confirmationRequested(submissionSnapshot())"));
|
|
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 (after evaluate)" },
|
|
);
|
|
}
|
|
|
|
const confirmId = await idByObjectName(app, "transactionConfirmButton");
|
|
await app.inspector.send("click", { objectId: confirmId });
|
|
// QtQuick Buttons don't always take the synthetic click; fall back to invoking
|
|
// the dialog's confirm() slot directly (fires onConfirmed -> flow.confirm()).
|
|
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(() => evaluate(app, dialogId, "confirm()"));
|
|
}
|
|
|
|
// 5. Wait for the create to submit. It's fully async — createAccountPublic (mint
|
|
// the LP holding) then createPool then the tx submit — so transactionId lands
|
|
// a few seconds after confirm(), not immediately.
|
|
try {
|
|
await app.waitFor(
|
|
async () => {
|
|
const s = await formState(app, formId);
|
|
if (!s.transactionId) throw new Error("create not submitted yet");
|
|
},
|
|
{ timeout: 30000, interval: 1000, description: "create to submit (transactionId set)" },
|
|
);
|
|
} catch {
|
|
await saveShot(app, "create-pool-result");
|
|
throw new Error(`create did not submit. Form state: ${JSON.stringify(await formState(app, formId))}`);
|
|
}
|
|
const final = await formState(app, formId);
|
|
console.log(` create submitted: tx ${final.transactionId}`);
|
|
|
|
// 6. Verify ON-CHAIN via the Swap card's resolvePool — a plain hex pool read
|
|
// against the sequencer (createPool has no tx poll / poolStatus, so we don't
|
|
// lean on the liquidity form's legacy quote). Selecting A/C on the Trade tab
|
|
// must now report the pool as existing with reserves.
|
|
await ignore(() => app.click("Trade"));
|
|
await app.waitFor(
|
|
async () => { await app.expectTexts(["Sell", "Buy"]); },
|
|
{ timeout: 10000, interval: 500, description: "swap card to load" },
|
|
);
|
|
await openPicker(app, "swapSellTokenButton");
|
|
await pickToken(app, 0); // TKA
|
|
await openPicker(app, "swapBuyTokenButton");
|
|
await pickToken(app, 2); // TKC
|
|
const swapId = await idByObjectName(app, "swapCard");
|
|
|
|
try {
|
|
await app.waitFor(
|
|
async () => {
|
|
// Force a fresh read each poll — the create block may not be applied yet.
|
|
await ignore(() => evaluate(app, swapId, "doResolvePool()"));
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
if ((await prop(app, swapId, "poolExists")) !== true)
|
|
throw new Error("A/C pool not found yet");
|
|
},
|
|
{ timeout: 40000, interval: 1500, description: "A/C pool to exist on-chain" },
|
|
);
|
|
} catch {
|
|
await saveShot(app, "create-pool-result");
|
|
throw new Error(
|
|
`A/C pool not found on-chain after the create (tx ${final.transactionId}).\n` +
|
|
` swap card: poolExists=${await prop(app, swapId, "poolExists")} ` +
|
|
`reserveA=${await prop(app, swapId, "poolReserveA")} ` +
|
|
`reserveB=${await prop(app, swapId, "poolReserveB")}`,
|
|
);
|
|
}
|
|
const rA = await prop(app, swapId, "poolReserveA");
|
|
const rB = await prop(app, swapId, "poolReserveB");
|
|
console.log(` A/C pool exists on-chain ✓ reserves A=${rA} B=${rB} (tx ${final.transactionId})`);
|
|
await saveShot(app, "create-pool-result");
|
|
});
|
|
|
|
run();
|
|
|
|
// How to run (from scratch, interactive + CI): see the "Running the UI tests"
|
|
// section in apps/amm/tests/README.md — same flow as swap.mjs.
|