mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-24 21:59:36 +00:00
test(amm-ui): add swap UI test
This commit is contained in:
+3
-1
@@ -1,4 +1,6 @@
|
||||
target/
|
||||
*.bin
|
||||
**/*/result
|
||||
# Nix build symlinks (result, result-mcp, result-core, result-lgx, …) at any depth
|
||||
result
|
||||
result-*
|
||||
**/*/.DS_Store
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user