feat(token-ui): integrate Basecamp token module

Add portable Basecamp packaging and an inspector-driven end-to-end flow.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-20 13:42:46 +02:00
committed by r4bbit
parent dc386dee17
commit 470e06c6c3
12 changed files with 563 additions and 17 deletions
+11
View File
@@ -32,7 +32,18 @@
walletQmlInstallDir="$out/lib/Logos/Wallet"
mkdir -p "$walletQmlInstallDir"
cp -r "$walletQmlDir/." "$walletQmlInstallDir/"
# Basecamp ui_qml views run in a sandboxed QML host and cannot load
# native plugins from an imported QML module. Token already exposes
# wallet operations through TokenUiBackend, so the shared wallet
# controls only need their pure-QML files here.
sed -i -E '/^(linktarget|optional plugin|classname|typeinfo|prefer)/d' \
"$walletQmlInstallDir/qmldir"
find "$walletQmlInstallDir" -maxdepth 1 -type f \
\( -name 'liblogos_wallet_qml*' -o -name 'plugins.qmltypes' \
-o -name '*module_dir_map.qrc' \) -delete
test -f "$walletQmlInstallDir/qmldir"
grep -q '^module Logos.Wallet$' "$walletQmlInstallDir/qmldir"
grep -q '^WalletControl 1.0 WalletControl.qml$' "$walletQmlInstallDir/qmldir"
'';
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 723 B

+1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"type": "ui_qml",
"category": "token",
"display_name": "Token",
"description": "Logos UI module for creating and inspecting Token Program assets",
"main": "token_ui_plugin",
"view": "qml/Main.qml",
-5
View File
@@ -1,5 +0,0 @@
module Logos.Wallet
optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet
classname Logos_WalletPlugin
prefer :/qt/qml/Logos/Wallet/
depends QtQuick
+3
View File
@@ -2,12 +2,15 @@ import QtQuick 2.15
import Logos.Theme
import "chrome"
import "pages"
import "state"
Item {
id: root
objectName: "tokenApp"
// Backend replica + account model, bridged from the C++ backend.
readonly property var backend: logos.module("token_ui")
readonly property var accountModel: logos.model("token_ui", "accountModel")
@@ -69,6 +69,7 @@ Item {
required property int index
required property var modelData
objectName: tabIndex === 0 ? "tokenCreateTab" : "tokenInspectTab"
readonly property int tabIndex: index
readonly property bool active: root.currentIndex === tabIndex
+51 -8
View File
@@ -7,6 +7,8 @@ import QtQuick.Layouts
Item {
id: root
objectName: "tokenCreatePage"
property var store: null
property var backend: null
property var runtime: null
@@ -301,17 +303,22 @@ Item {
Menu {
id: examplesMenu
objectName: "tokenExamplesMenu"
MenuItem {
objectName: "tokenFixedTemplate"
text: qsTr("Fixed supply")
onTriggered: root.setPattern("fixed")
}
MenuItem {
objectName: "tokenMetadataTemplate"
text: qsTr("Metadata-backed fungible")
onTriggered: root.setPattern("metadata")
}
MenuItem {
objectName: "tokenNftTemplate"
text: qsTr("NFT collection")
onTriggered: root.setPattern("nft")
}
@@ -535,6 +542,8 @@ Item {
Button {
id: fungibleTab
objectName: "tokenFungibleTab"
property bool selected: root.tokenKind === 0
Layout.fillWidth: true
@@ -564,6 +573,8 @@ Item {
Button {
id: nonFungibleTab
objectName: "tokenNonFungibleTab"
property bool selected: root.tokenKind === 1
Layout.fillWidth: true
@@ -622,6 +633,8 @@ Item {
TextField {
id: nameField
objectName: "tokenNameField"
Layout.fillWidth: true
Layout.preferredHeight: 44
Accessible.name: qsTr("Token definition name")
@@ -651,6 +664,8 @@ Item {
TextField {
id: supplyField
objectName: "tokenSupplyField"
Layout.fillWidth: true
Layout.preferredHeight: 44
Accessible.name: root.supplyLabel
@@ -705,6 +720,8 @@ Item {
ComboBox {
id: authoritySelector
objectName: "tokenAuthoritySelector"
Layout.fillWidth: true
Layout.preferredHeight: 44
Accessible.name: qsTr("Mint authority policy")
@@ -791,8 +808,10 @@ Item {
}
}
CheckBox {
id: metadataCheckBox
CheckBox {
id: metadataCheckBox
objectName: "tokenMetadataCheckBox"
Layout.alignment: Qt.AlignVCenter
Accessible.name: qsTr("Include metadata account")
@@ -948,8 +967,10 @@ Item {
Layout.fillWidth: true
spacing: 8
Button {
id: examplesButton
Button {
id: examplesButton
objectName: "tokenExamplesButton"
Layout.preferredHeight: 36
text: qsTr("Use template")
@@ -981,6 +1002,8 @@ Item {
Button {
id: continueButton
objectName: "tokenContinueButton"
Layout.fillWidth: true
Layout.preferredHeight: visible ? 46 : 0
activeFocusOnTab: true
@@ -1051,6 +1074,8 @@ Item {
Button {
id: createAccountsButton
objectName: "tokenCreateAccountsButton"
Layout.fillWidth: true
Layout.preferredHeight: 38
enabled: root.backend !== null && root.backend.isWalletOpen && !root.accountBusy
@@ -1084,6 +1109,8 @@ Item {
TextField {
id: definitionTargetField
objectName: "tokenDefinitionTargetField"
Layout.fillWidth: true
Layout.preferredHeight: 44
Accessible.name: qsTr("Definition target account ID")
@@ -1121,6 +1148,8 @@ Item {
TextField {
id: holdingTargetField
objectName: "tokenHoldingTargetField"
Layout.fillWidth: true
Layout.preferredHeight: 44
Accessible.name: root.isFungible ? qsTr("Initial fungible holding target account ID") : qsTr("NFT master holding target account ID")
@@ -1164,6 +1193,8 @@ Item {
TextField {
id: metadataTargetField
objectName: "tokenMetadataTargetField"
Layout.fillWidth: true
Layout.preferredHeight: 44
Accessible.name: qsTr("Metadata target account ID")
@@ -1200,6 +1231,8 @@ Item {
Button {
id: backToConfigureButton
objectName: "tokenBackToConfigureButton"
Layout.preferredWidth: 112
Layout.preferredHeight: 46
activeFocusOnTab: true
@@ -1223,8 +1256,10 @@ Item {
}
}
Button {
id: reviewButton
Button {
id: reviewButton
objectName: "tokenReviewButton"
Layout.fillWidth: true
Layout.preferredHeight: 46
@@ -1466,6 +1501,8 @@ Item {
Button {
id: backToAccountsButton
objectName: "tokenBackToAccountsButton"
Layout.preferredWidth: 112
Layout.preferredHeight: 46
activeFocusOnTab: true
@@ -1490,8 +1527,10 @@ Item {
}
}
Button {
id: prepareButton
Button {
id: prepareButton
objectName: "tokenPrepareButton"
Layout.fillWidth: true
Layout.preferredHeight: 46
@@ -1522,6 +1561,8 @@ Item {
Button {
id: inspectButton
objectName: "tokenInspectButton"
Layout.fillWidth: true
Layout.preferredHeight: 42
visible: root.prepared
@@ -1738,6 +1779,8 @@ Item {
Button {
id: summaryContinueButton
objectName: "tokenSummaryContinueButton"
Layout.fillWidth: true
Layout.preferredHeight: visible ? 46 : 0
visible: root.step === 0 && stage.columnCount === 2
+18 -3
View File
@@ -7,6 +7,8 @@ import QtQuick.Layouts 1.15
Item {
id: root
objectName: "tokenManagePage"
property var store: null
property var backend: null
property var runtime: null
@@ -247,6 +249,7 @@ Item {
spacing: 8
Text {
objectName: "tokenManageStatus"
Layout.fillWidth: true
color: root.loadError.length > 0 ? "#F08A76" : "#8E8780"
font.pixelSize: 12
@@ -257,6 +260,8 @@ Item {
Button {
id: refreshDefinitionsButton
objectName: "tokenRefreshButton"
Layout.preferredWidth: 86
Layout.preferredHeight: 32
enabled: root.backend !== null && root.backend.isWalletOpen && !root.loading
@@ -339,6 +344,8 @@ Item {
TextField {
id: searchField
objectName: "tokenSearchField"
Layout.fillWidth: true
Layout.preferredHeight: 42
Accessible.name: qsTr("Search token definitions")
@@ -364,6 +371,8 @@ Item {
Button {
id: allFilterButton
objectName: "tokenAllFilterButton"
Layout.preferredHeight: 30
activeFocusOnTab: true
Accessible.name: qsTr("Show all token definitions")
@@ -389,6 +398,8 @@ Item {
Button {
id: fungibleFilterButton
objectName: "tokenFungibleFilterButton"
Layout.preferredHeight: 30
activeFocusOnTab: true
Accessible.name: qsTr("Show fungible definitions")
@@ -414,6 +425,8 @@ Item {
Button {
id: nftFilterButton
objectName: "tokenNftFilterButton"
Layout.preferredHeight: 30
activeFocusOnTab: true
Accessible.name: qsTr("Show non-fungible definitions")
@@ -459,6 +472,8 @@ Item {
delegate: Rectangle {
id: definitionRow
objectName: "tokenDefinitionRow"
required property var modelData
width: definitionList.width
@@ -802,7 +817,7 @@ Item {
Text {
Layout.fillWidth: true
visible: root.hasSelection && root.selectedDefinition.definitionHex
visible: root.hasSelection && !!root.selectedDefinition.definitionHex
Layout.preferredHeight: visible ? implicitHeight : 0
color: "#8E8780"
elide: Text.ElideMiddle
@@ -1118,7 +1133,7 @@ Item {
visible: holdingRow.modelData.displayBalance !== undefined || holdingRow.modelData.printBalance !== undefined
color: "#8E8780"
font.pixelSize: 11
text: holdingRow.modelData.printBalance !== undefined ? qsTr("print balance") : holdingRow.modelData.displayBalance
text: holdingRow.modelData.printBalance !== undefined ? qsTr("print balance") : root.valueOrDash(holdingRow.modelData.displayBalance)
}
}
}
@@ -1211,7 +1226,7 @@ Item {
color: "#A9A098"
font.pixelSize: 13
wrapMode: Text.Wrap
text: root.hasSelection ? root.selectedDefinition.description : ""
text: root.hasSelection ? (root.selectedDefinition.description || "") : ""
}
Rectangle {
+62
View File
@@ -0,0 +1,62 @@
# Token UI Basecamp E2E
`token-definition.mjs` drives the running Token UI through the Logos QML
inspector, the same path used by Basecamp UI tests.
The hermetic Basecamp runner follows the AMM and Logos Palace harnesses: it
uses the inspector-enabled portable Basecamp bundle, stages portable installs
under a fresh `--user-dir`, launches with `-platform offscreen`, waits for the
inspector, isolates the wallet home under the run directory, then stores
screenshots from the same inspector connection.
Run the full Basecamp flow:
```bash
apps/token/tests/run-basecamp-e2e.sh
```
The runner builds these inputs when not supplied through environment
overrides:
- `logos-qt-mcp` test framework
- inspector-enabled Basecamp bundle
- `logos_execution_zone` portable core module
- `token_module` portable core module
- `token_ui` portable UI plugin
Override already-built inputs with `LOGOS_QT_MCP`, `TOKEN_BASECAMP_BUNDLE`,
`TOKEN_WALLET_INSTALL`, `TOKEN_MODULE_INSTALL`, or `TOKEN_UI_INSTALL`.
Build only the inspector framework:
```bash
nix build .#test-framework -o apps/token/result-mcp
```
For manual runs, stage the portable install outputs into a Basecamp user
directory: `.#install-portable` for `token_module`,
`.#token-ui-install-portable` for `token_ui`, and the matching portable
`logos_execution_zone` install. Launch the inspector-enabled bundle with
`--user-dir <path> -platform offscreen`.
Run the non-mutating visual flow:
```bash
node apps/token/tests/token-definition.mjs
```
Run the live round trip against an open wallet and reachable sequencer:
```bash
TOKEN_E2E_LIVE=1 \
TOKEN_E2E_NAME="Basecamp Token E2E" \
node apps/token/tests/token-definition.mjs
```
The live path creates fresh public wallet accounts, submits a fixed-supply
definition through `token_module`, switches to Inspect, and waits for the
definition to be read back from the connected wallet.
Screenshots are written to `.3esmit/projects/lez-programs/docs/token-basecamp-e2e/`
after each major step. The inspector listens on `localhost:3768`; override it with
`QML_INSPECTOR_PORT` when another test is using that port.
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P)"
output_dir="${TOKEN_E2E_OUTPUT:-${repo_root}/.3esmit/projects/lez-programs/docs/token-basecamp-e2e}"
run_root="${TOKEN_E2E_RUN_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/token-basecamp-e2e.XXXXXX")}"
inspector_port="${QML_INSPECTOR_PORT:-3768}"
basecamp_pid=""
cleanup() {
local status=$?
set +e
if [[ -n "${basecamp_pid}" ]] && kill -0 "${basecamp_pid}" 2>/dev/null; then
kill "${basecamp_pid}" 2>/dev/null
wait "${basecamp_pid}" 2>/dev/null
fi
if (( status != 0 )); then
printf 'Basecamp E2E run retained for diagnosis: %s\n' "${run_root}" >&2
if [[ -f "${run_root}/basecamp.log" ]]; then
tail -120 "${run_root}/basecamp.log" >&2
fi
else
printf 'Basecamp E2E run: %s\n' "${run_root}"
fi
exit "${status}"
}
trap cleanup EXIT
mkdir -p "${output_dir}" "${run_root}/user/modules" "${run_root}/user/plugins"
wallet_home="${run_root}/wallet"
mkdir -p "${wallet_home}"
build_if_missing() {
local override="$1"
local output="$2"
shift 2
if [[ -n "${override}" ]]; then
printf '%s\n' "${override}"
return
fi
nix build "$@" -o "${output}"
printf '%s\n' "${output}"
}
mcp_root="$(build_if_missing "${LOGOS_QT_MCP:-}" "${run_root}/result-mcp" .#test-framework)"
bundle_root="$(build_if_missing "${TOKEN_BASECAMP_BUNDLE:-}" "${run_root}/result-bundle" github:logos-co/logos-basecamp#bin-bundle-dir-inspector)"
wallet_install="$(build_if_missing "${TOKEN_WALLET_INSTALL:-}" "${run_root}/wallet-install" \
'github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr#install-portable' \
--override-input logos-execution-zone \
'github:logos-blockchain/logos-execution-zone?rev=415964d7f9043a1bfe28da8d0e8b3a6f64abb258')"
token_install="$(build_if_missing "${TOKEN_MODULE_INSTALL:-}" "${run_root}/token-install" .#install-portable)"
ui_install="$(build_if_missing "${TOKEN_UI_INSTALL:-}" "${run_root}/token-ui-install" .#token-ui-install-portable)"
cp -RL "${wallet_install}/modules/." "${run_root}/user/modules/"
cp -RL "${token_install}/modules/." "${run_root}/user/modules/"
cp -RL "${ui_install}/plugins/." "${run_root}/user/plugins/"
basecamp_bin="${bundle_root}/bin/LogosBasecamp"
if [[ ! -x "${basecamp_bin}" ]]; then
printf 'Basecamp binary unavailable: %s\n' "${basecamp_bin}" >&2
exit 2
fi
QT_QPA_PLATFORM=offscreen \
QT_FORCE_STDERR_LOGGING=1 \
QML_DISABLE_DISK_CACHE=1 \
QML_INSPECTOR_PORT="${inspector_port}" \
LEE_WALLET_HOME_DIR="${wallet_home}" \
NSSA_WALLET_HOME_DIR="${wallet_home}" \
LD_LIBRARY_PATH="${bundle_root}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \
"${basecamp_bin}" --user-dir "${run_root}/user" -platform offscreen \
>"${run_root}/basecamp.log" 2>&1 &
basecamp_pid=$!
inspector_ready=0
for _ in $(seq 1 120); do
if (exec 3<>"/dev/tcp/127.0.0.1/${inspector_port}") 2>/dev/null; then
exec 3>&- 3<&-
inspector_ready=1
break
fi
if ! kill -0 "${basecamp_pid}" 2>/dev/null; then
printf 'Basecamp exited before inspector startup\n' >&2
exit 1
fi
sleep 0.5
done
if (( inspector_ready != 1 )); then
printf 'Inspector did not become ready on port %s\n' "${inspector_port}" >&2
exit 1
fi
LOGOS_QT_MCP="${mcp_root}" \
QML_INSPECTOR_PORT="${inspector_port}" \
TOKEN_E2E_OUTPUT="${output_dir}" \
node "${repo_root}/apps/token/tests/token-definition.mjs"
+249
View File
@@ -0,0 +1,249 @@
// Token UI end-to-end test for Logos Basecamp.
//
// Normal mode drives the complete visible create -> inspect shell without
// mutating chain state. Set TOKEN_E2E_LIVE=1 to create fresh wallet accounts,
// submit a fixed-supply definition through token_module, and verify it appears
// in the live Inspect view.
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdir, 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"));
const live = process.env.TOKEN_E2E_LIVE === "1";
const evidenceDir = process.env.TOKEN_E2E_OUTPUT
? resolve(process.env.TOKEN_E2E_OUTPUT)
: fileURLToPath(
new URL(
"../../../.3esmit/projects/lez-programs/docs/token-basecamp-e2e/",
import.meta.url,
),
);
const tokenName = process.env.TOKEN_E2E_NAME || `Basecamp E2E ${Date.now()}`;
async function idByObjectName(app, name) {
const result = await app.findByProperty("objectName", name);
if (result.error || !result.matches || result.matches.length === 0)
throw new Error(`no object with objectName="${name}"`);
return result.matches[0].id;
}
async function prop(app, objectId, property) {
const result = await app.getProperties(objectId);
const properties = result.properties || [];
const found = properties.find((item) => item.name === property);
return found ? found.value : undefined;
}
async function setProp(app, objectId, property, value) {
const result = await app.inspector.send("setProperty", {
objectId,
property,
value,
});
if (result.error)
throw new Error(`setProperty ${property}: ${result.error}`);
}
async function clickObject(app, name) {
const objectId = await idByObjectName(app, name);
const result = await app.inspector.send("click", { objectId });
if (result.error)
throw new Error(`click ${name}: ${result.error}`);
return objectId;
}
async function clickFirstVisible(app, names) {
for (const name of names) {
const result = await app.findByProperty("objectName", name);
for (const match of result.matches || []) {
if ((await prop(app, match.id, "visible")) !== true)
continue;
if ((await prop(app, match.id, "enabled")) === false)
continue;
const clickResult = await app.inspector.send("click", { objectId: match.id });
if (clickResult.error)
throw new Error(`click ${name}: ${clickResult.error}`);
return match.id;
}
}
throw new Error(`no visible enabled object: ${names.join(", ")}`);
}
async function waitForProperty(app, objectId, property, predicate, description, timeout = 10000) {
await app.waitFor(
async () => {
const value = await prop(app, objectId, property);
if (!predicate(value))
throw new Error(`${property}=${JSON.stringify(value)}`);
},
{ timeout, interval: 300, description },
);
}
async function saveShot(app, name) {
const shot = await app.screenshot();
if (!shot || !shot.image)
throw new Error(`screenshot unavailable for ${name}`);
await mkdir(evidenceDir, { recursive: true });
const path = resolve(evidenceDir, `${name}.png`);
await writeFile(path, Buffer.from(shot.image, "base64"));
console.log(` screenshot -> ${path}`);
}
async function chooseFixedTemplate(app) {
await clickObject(app, "tokenExamplesButton");
await app.waitFor(
async () => {
const menuId = await idByObjectName(app, "tokenExamplesMenu");
if ((await prop(app, menuId, "visible")) !== true)
throw new Error("template menu is closed");
},
{ timeout: 3000, interval: 150, description: "template menu to open" },
);
await clickObject(app, "tokenFixedTemplate");
const nameFieldId = await idByObjectName(app, "tokenNameField");
await waitForProperty(
app,
nameFieldId,
"text",
(value) => value === "Fixed supply token",
"fixed template values",
);
}
async function openTokenApp(app) {
await app.waitFor(
async () => { await app.expectTexts(["Applications", "Settings"]); },
{ timeout: 60000, interval: 500, description: "Basecamp shell to load" },
);
await saveShot(app, "basecamp-launch");
const labels = ["Token", "token_ui", "Logos Token"];
await app.waitFor(
async () => {
for (const label of labels) {
const result = await app.findByProperty("text", label);
if (result.matches && result.matches.length > 0) {
await app.click(label);
return;
}
}
throw new Error(`Token app not visible in Basecamp sidebar (${labels.join(", ")})`);
},
{ timeout: 30000, interval: 500, description: "Token app in Basecamp sidebar" },
);
}
test(live
? "token definition: create through token_module and inspect live state"
: "token UI: create shell flows into Inspect", async (app) => {
await openTokenApp(app);
await app.waitFor(
async () => { await app.expectTexts(["Create definition", "Fungible", "Use template"]); },
{ timeout: 20000, interval: 400, description: "token create view to load" },
);
const createPageId = await idByObjectName(app, "tokenCreatePage");
await saveShot(app, "token-create-initial");
await chooseFixedTemplate(app);
const nameFieldId = await idByObjectName(app, "tokenNameField");
await setProp(app, nameFieldId, "text", tokenName);
await saveShot(app, "token-create-template");
await clickFirstVisible(app, ["tokenContinueButton", "tokenSummaryContinueButton"]);
await waitForProperty(
app,
createPageId,
"step",
(value) => value === 1,
"account-target step",
);
if (!live) {
const createAccountsButtonId = await idByObjectName(app, "tokenCreateAccountsButton");
if (await prop(app, createAccountsButtonId, "enabled") === true)
throw new Error("visual mode unexpectedly has a live wallet");
await saveShot(app, "token-account-targets-disconnected");
await app.click("Inspect");
await app.waitFor(
async () => { await app.expectTexts(["Inspect token definitions", "Definition index"]); },
{ timeout: 10000, interval: 300, description: "Inspect view to load" },
);
await app.waitFor(
async () => {
const result = await app.findByProperty("text", "Pebble");
if (!result.matches || result.matches.length === 0)
throw new Error("example definitions not rendered");
},
{ timeout: 5000, interval: 250, description: "example definitions" },
);
await saveShot(app, "token-inspect-examples");
console.log(" mode: visual shell; chain state unchanged");
return;
}
const createAccountsButtonId = await idByObjectName(app, "tokenCreateAccountsButton");
await waitForProperty(
app,
createAccountsButtonId,
"enabled",
(value) => value === true,
"connected wallet for fresh accounts",
5000,
);
await clickObject(app, "tokenCreateAccountsButton");
const definitionTargetId = await idByObjectName(app, "tokenDefinitionTargetField");
const holdingTargetId = await idByObjectName(app, "tokenHoldingTargetField");
const validAccount = (value) => typeof value === "string" && value.length > 0;
await waitForProperty(app, definitionTargetId, "text", validAccount, "definition target account", 20000);
await waitForProperty(app, holdingTargetId, "text", validAccount, "holding target account", 20000);
await saveShot(app, "token-account-targets-ready");
await clickObject(app, "tokenReviewButton");
await waitForProperty(app, createPageId, "step", (value) => value === 2, "definition review");
await saveShot(app, "token-definition-review");
const prepareButtonId = await idByObjectName(app, "tokenPrepareButton");
await waitForProperty(app, prepareButtonId, "enabled", (value) => value === true, "create definition action");
await clickObject(app, "tokenPrepareButton");
await app.waitFor(
async () => {
const prepared = await prop(app, createPageId, "prepared");
const error = await prop(app, createPageId, "errorMessage");
if (prepared !== true && !error)
throw new Error("transaction is still pending");
if (error)
throw new Error(`Token Program rejected request: ${error}`);
},
{ timeout: 60000, interval: 500, description: "Token Program submission" },
);
await saveShot(app, "token-definition-submitted");
await app.click("Inspect");
await app.waitFor(
async () => { await app.expectTexts(["Inspect token definitions", "Definition index"]); },
{ timeout: 10000, interval: 300, description: "live Inspect view" },
);
await app.waitFor(
async () => {
try { await clickObject(app, "tokenRefreshButton"); } catch { /* refresh may be busy */ }
const result = await app.findByProperty("text", tokenName);
if (!result.matches || result.matches.length === 0)
throw new Error(`live definition ${tokenName} not indexed yet`);
},
{ timeout: 60000, interval: 1200, description: "created definition in live Inspect view" },
);
await app.expectTexts([tokenName, "Network"]);
await saveShot(app, "token-inspect-live-definition");
console.log(` mode: live; verified ${tokenName} in wallet-backed Inspect view`);
});
run();
+69 -1
View File
@@ -179,9 +179,29 @@
exit 1
fi
walletQmlDir="$(dirname "$walletQmlDescriptor")"
# Stage the shared Logos.Wallet module at the plugin ROOT (NOT under
# qml/), and strip every plugin/resource directive from its qmldir so
# it loads as PURE QML from the co-located .qml files. Two reasons:
# 1. Basecamp's QML sandbox rejects `prefer :/qt/qml/...` (and the
# `classname`/`typeinfo`/`linktarget` it pairs with) with
# "Invalid null URL" that compiled resource isn't registered
# there. The .qml files are physically present, so the module
# resolves without any native plugin; Token drives wallet ops
# through TokenUiBackend regardless.
# 2. Basecamp adds the *containing dir* of every discovered Logos.*
# module to a shared QML import path. Keeping Logos.Wallet at the
# root means only the root is shared putting it under qml/ would
# also leak qml/NavBar.qml et al. into that shared namespace and
# collide with other plugins' identically-named types (e.g. amm's
# NavBar). Standalone reaches this root module via QML_IMPORT_PATH
# (see the token-ui app wrapper), not a qml/ stub. Keep in sync
# with apps/amm.
walletQmlInstallDir="$out/lib/Logos/Wallet"
mkdir -p "$walletQmlInstallDir"
cp -r "$walletQmlDir/." "$walletQmlInstallDir/"
grep -vE '^(linktarget|optional plugin|plugin|classname|typeinfo|prefer)([[:space:]]|$)' \
"$walletQmlInstallDir/qmldir" > "$walletQmlInstallDir/qmldir.pureqml"
mv "$walletQmlInstallDir/qmldir.pureqml" "$walletQmlInstallDir/qmldir"
test -f "$walletQmlInstallDir/qmldir"
'';
};
@@ -197,6 +217,18 @@
tokenAppApps = tokenAppOutputs.apps or { };
tokenAppPkgs = tokenAppOutputs.packages or { };
# Keep the token UI's Basecamp install artifacts addressable after the
# core token module is merged into the same package set. Both builders
# expose an `lgx` attribute, so a named alias prevents the core module
# package from shadowing the UI package.
tokenUiPackages = builtins.mapAttrs (
system: attrs:
(if attrs ? lgx then { token-ui-lgx = attrs.lgx; } else { })
// (if attrs ? lgx-portable then { token-ui-lgx-portable = attrs.lgx-portable; } else { })
// (if attrs ? install then { token-ui-install = attrs.install; } else { })
// (if attrs ? install-portable then { token-ui-install-portable = attrs.install-portable; } else { })
) tokenAppPkgs;
# AMM core module (modules/amm): the AMM business logic as a headless
# `core` Logos module. It links the amm_ffi crate (the transport-
# independent AMM brain, resolved via `self`) and depends on the
@@ -230,6 +262,16 @@
};
tokenModulePkgs = tokenModuleOutputs.packages or { };
# Alias the token core module's Basecamp install artifacts. The bare
# `lgx` / `install` attrs collide across builders in the merged package
# set (last write wins), so expose an explicit `token-module-lgx` /
# `token-module-install` that resolves unambiguously.
tokenModuleAliases = builtins.mapAttrs (
system: attrs:
(if attrs ? lgx then { token-module-lgx = attrs.lgx; } else { })
// (if attrs ? install then { token-module-install = attrs.install; } else { })
) tokenModulePkgs;
# Wrap the app launcher to export DYLD_FALLBACK_LIBRARY_PATH pointing at the
# amm_ffi lib. The logos module builder links the plugin against
# @rpath/libamm_ffi.dylib but does NOT stage that dylib into the
@@ -253,9 +295,31 @@
(builtins.removeAttrs attrs [ "default" ]) // (if attrs ? default then { amm-ui = wrapWithDyld system attrs.default; } else { })
) appApps;
# Prepend the token UI module's `lib` dir to QML_IMPORT_PATH before the
# standalone shell runs. The shared Logos.Wallet module is staged at the
# plugin ROOT (lib/Logos/Wallet), NOT under the view dir (lib/qml) — see
# the tokenAppOutputs postInstall for why (Basecamp shared-import-path
# collisions). But the standalone shell only searches the view dir for
# unqualified imports, so `import Logos.Wallet` from lib/qml/Main.qml would
# not resolve without help. logos-standalone-app appends any pre-existing
# QML_IMPORT_PATH to the paths it sets, so exporting lib here makes the
# root module resolvable with no qml/ stub. tokenAppPkgs.<sys>.default is
# the module derivation whose /lib is the plugin dir the app loads.
wrapTokenQmlImportPath = system: app:
let
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
moduleDir = tokenAppPkgs.${system}.default;
in
app // {
program = "${pkgs.writeShellScript "run-token-ui" ''
export QML_IMPORT_PATH="${moduleDir}/lib''${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}"
exec ${app.program} "$@"
''}";
};
renamedTokenApps = builtins.mapAttrs (
system: attrs:
(builtins.removeAttrs attrs [ "default" ]) // (if attrs ? default then { token-ui = attrs.default; } else { })
(builtins.removeAttrs attrs [ "default" ]) // (if attrs ? default then { token-ui = wrapTokenQmlImportPath system attrs.default; } else { })
) tokenAppApps;
mergedApps = builtins.mapAttrs (
@@ -268,18 +332,22 @@
let
appSysPkgs = appPkgs.${system} or { };
tokenAppSysPkgs = tokenAppPkgs.${system} or { };
tokenUiSysPkgs = tokenUiPackages.${system} or { };
ammModSysPkgs = ammModulePkgs.${system} or { };
tokenModSysPkgs = tokenModulePkgs.${system} or { };
tokenModAliasPkgs = tokenModuleAliases.${system} or { };
in
(builtins.removeAttrs cratePkgs [ "default" ])
// (builtins.removeAttrs appSysPkgs [ "default" ])
// (if appSysPkgs ? default then { amm-ui = appSysPkgs.default; } else { })
// (builtins.removeAttrs tokenAppSysPkgs [ "default" ])
// (if tokenAppSysPkgs ? default then { token-ui = tokenAppSysPkgs.default; } else { })
// tokenUiSysPkgs
// (builtins.removeAttrs ammModSysPkgs [ "default" ])
// (if ammModSysPkgs ? default then { amm-module = ammModSysPkgs.default; } else { })
// (builtins.removeAttrs tokenModSysPkgs [ "default" ])
// (if tokenModSysPkgs ? default then { token-module = tokenModSysPkgs.default; } else { })
// tokenModAliasPkgs
) crateOutputs.packages;
in
(builtins.removeAttrs appOutputs [ "apps" "packages" ])