Files
lez-programs/apps/amm/tests/custom-token.mjs
r4bbit 99ea6805df feat(apps/amm): introduce positions view
This view allows the user to manage their positions.
2026-08-20 19:52:35 +02:00

189 lines
8.9 KiB
JavaScript

// ---------------------------------------------------------------------------
// AMM UI test — add a CUSTOM (unlisted) token by id through the Liquidity view.
//
// Token D is created on-chain by the setup script but deliberately LEFT OUT of
// the token config, so it never appears in the selector on its own. This test
// pastes D's definition id into a liquidity token slot and verifies the app
// RESOLVES it (reads its on-chain definition), SELECTS it, and PERSISTS it to the
// custom-token store — the addCustomToken path. No pool / submit is involved, so
// it needs neither an open wallet nor a seeded pool.
//
// NOTE: the add path is holding-agnostic — a token resolves from its public
// definition whether or not the wallet holds it (balance shows "0" when not held).
// D happens to be held by the test wallet only because minting requires the holding
// account to sign; a genuinely un-owned token adds via exactly the same path.
//
// Prereqs in the running app (see apps/amm/tests/README.md):
// * launched against the isolated test wallet + TOKENS_CONFIG the setup writes
// (TKA, TKB, TKC — NOT token D) and CUSTOM_TOKEN_CONFIG pointing at a
// writable path (defaults below, matching the README launch line)
// * a reachable local sequencer (to read D's definition)
// ---------------------------------------------------------------------------
import { resolve } from "node:path";
import { rm } from "node:fs/promises";
import { execFileSync } from "node:child_process";
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 isolated test wallet home (set by the setup script) — used to resolve token
// D's deterministic definition id via the wallet CLI, the same way the setup does.
const WALLET_HOME =
process.env.LEE_WALLET_HOME_DIR ||
new URL("./testnet/.wallet", import.meta.url).pathname;
// Where the app persists custom tokens. Defaults to the isolated test store; set
// CUSTOM_TOKEN_CONFIG to override. IMPORTANT: launch the app with the SAME path
// (CUSTOM_TOKEN_CONFIG) so the test's clean-slate clears the store the app actually
// uses — otherwise the app writes to its default per-user store and the test starts
// from a stale slate. Only used to pre-clear; persistence is verified through the app.
const CUSTOM_TOKEN_CONFIG =
process.env.CUSTOM_TOKEN_CONFIG ||
new URL("./testnet/custom-tokens.json", import.meta.url).pathname;
// --- small helpers (mirror create-pool.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 evaluate(app, id, expression) {
await app.inspector.send("evaluate", { expression, objectId: id });
}
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 import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, Buffer.from(shot.image, "base64")));
console.log(` screenshot -> ${path}`);
}
}
// Resolve token D's definition id from the test wallet (deterministic under the
// test mnemonic), the same account label the setup script mints it to.
function resolveTokenD() {
const out = execFileSync("wallet", ["account", "id", "--account-id", "token-d-def"], {
encoding: "utf8",
env: { ...process.env, LEE_WALLET_HOME_DIR: WALLET_HOME, NSSA_WALLET_HOME_DIR: WALLET_HOME },
});
const id = (out.match(/[1-9A-HJ-NP-Za-km-z]{32,44}/) || [])[0];
if (!id)
throw new Error(`could not resolve token-d-def id from wallet (home=${WALLET_HOME}) — run the setup script`);
return id;
}
// Trigger a token-list reload and wait for it to complete. resolveTokens() re-reads the
// persisted custom-token store from disk (in C++), so a token that survives a reload was
// genuinely persisted — no need to know the app's store path.
async function reloadTokens(app, pageId) {
await evaluate(app, pageId, "refreshTokens()");
await app.waitFor(
async () => { if ((await prop(app, pageId, "tokensLoading")) === true) throw new Error("reloading"); },
{ timeout: 10000, interval: 200, description: "token reload to finish" },
);
}
async function selectableIds(app, formId) {
// Read the CSV string form (var arrays don't reliably serialize over the inspector).
const csv = await prop(app, formId, "selectableTokenIdsCsv");
return typeof csv === "string" && csv.length > 0 ? csv.split(",") : [];
}
// --- the test ---------------------------------------------------------------
test("amm liquidity: add a custom (unlisted) token by id", async (app) => {
const tokenD = resolveTokenD();
console.log(` custom token D = ${tokenD}`);
// 1. Switch to the Liquidity tab and wait for the form + page 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)");
await app.waitFor(
async () => { await idByObjectName(app, "newPositionForm"); },
{ timeout: 10000, interval: 300, description: "liquidity form to render" },
);
const formId = await idByObjectName(app, "newPositionForm");
const pageId = await idByObjectName(app, "liquidityPage");
// 2. Clean slate: clear the isolated custom-token store the app uses and reload, so D is
// genuinely absent and pasting it must go through addCustomToken (not a direct select).
// If D is still listed after this, the app isn't using this store — launch it with
// CUSTOM_TOKEN_CONFIG pointing here (see README).
await rm(CUSTOM_TOKEN_CONFIG, { force: true });
await reloadTokens(app, pageId);
if ((await selectableIds(app, formId)).includes(tokenD))
throw new Error(
`token D is still listed after clearing ${CUSTOM_TOKEN_CONFIG} — launch the app with ` +
"CUSTOM_TOKEN_CONFIG set to this same path so the test controls the store (see README).",
);
// 3. Paste D's id into token slot A — the same entry point the token input's
// onTokenEntered uses. It's unlisted, so resolveToken routes through the app's
// addCustomToken: resolve the on-chain definition, persist, select.
await evaluate(app, formId, `resolveToken("A", "${tokenD}")`);
// 4. Wait for the resolution to complete: D selected on side A, no resolution error.
try {
await app.waitFor(
async () => {
const err = await prop(app, formId, "tokenResolutionError");
if (err) throw new Error(`resolution failed: ${err}`);
const selected = await prop(app, formId, "selectedTokenAId");
if (selected !== tokenD) throw new Error(`D not selected yet (selectedTokenAId=${selected})`);
},
{ timeout: 20000, interval: 500, description: "token D resolved + selected" },
);
} catch (e) {
await saveShot(app, "custom-token-not-resolved");
const err = await prop(app, formId, "tokenResolutionError");
throw new Error(`${e.message}. tokenResolutionError=${err}`);
}
// 5. The persistence proof: reload the list (re-reads the store from disk) and confirm D
// SURVIVES. D isn't in the token config, so if it's still selectable after a reload it
// can only have come from the persisted custom-token store — i.e. addCustomToken wrote
// it. If persistence silently failed, D would vanish here.
await reloadTokens(app, pageId);
if (!(await selectableIds(app, formId)).includes(tokenD)) {
await saveShot(app, "custom-token-not-persisted");
throw new Error(
"token D disappeared after a reload — it resolved + selected but was NOT persisted to the " +
"custom-token store. Launch the app with CUSTOM_TOKEN_CONFIG set to a writable path (see README).",
);
}
console.log(" token D added as a custom token ✓ (survives a token-list reload)");
await saveShot(app, "custom-token-added");
// Leave no side effects: clear the persisted custom token. The running app keeps it in
// memory until restart, but the next run's clean-slate step re-reads this cleared store.
await rm(CUSTOM_TOKEN_CONFIG, { force: true });
});
run();
// How to run: see apps/amm/tests/README.md — same flow as create-pool.mjs, plus
// launch the UI with CUSTOM_TOKEN_CONFIG set (the setup creates token D on-chain
// but leaves it out of the token config).