diff --git a/apps/token/flake.nix b/apps/token/flake.nix index 870cc69..0cd5203 100644 --- a/apps/token/flake.nix +++ b/apps/token/flake.nix @@ -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" ''; }; } diff --git a/apps/token/icons/token.png b/apps/token/icons/token.png index 05731f5..7a50f95 100644 Binary files a/apps/token/icons/token.png and b/apps/token/icons/token.png differ diff --git a/apps/token/metadata.json b/apps/token/metadata.json index ef618c6..6a3c200 100644 --- a/apps/token/metadata.json +++ b/apps/token/metadata.json @@ -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", diff --git a/apps/token/qml/Logos/Wallet/qmldir b/apps/token/qml/Logos/Wallet/qmldir deleted file mode 100644 index 866351b..0000000 --- a/apps/token/qml/Logos/Wallet/qmldir +++ /dev/null @@ -1,5 +0,0 @@ -module Logos.Wallet -optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet -classname Logos_WalletPlugin -prefer :/qt/qml/Logos/Wallet/ -depends QtQuick diff --git a/apps/token/qml/Main.qml b/apps/token/qml/Main.qml index e3011a8..9127fe1 100644 --- a/apps/token/qml/Main.qml +++ b/apps/token/qml/Main.qml @@ -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") diff --git a/apps/token/qml/NavBar.qml b/apps/token/qml/chrome/NavBar.qml similarity index 98% rename from apps/token/qml/NavBar.qml rename to apps/token/qml/chrome/NavBar.qml index 7cd4427..a7711ec 100644 --- a/apps/token/qml/NavBar.qml +++ b/apps/token/qml/chrome/NavBar.qml @@ -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 diff --git a/apps/token/qml/pages/CreatePage.qml b/apps/token/qml/pages/CreatePage.qml index f244485..2a368fa 100644 --- a/apps/token/qml/pages/CreatePage.qml +++ b/apps/token/qml/pages/CreatePage.qml @@ -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 diff --git a/apps/token/qml/pages/ManagePage.qml b/apps/token/qml/pages/ManagePage.qml index 7677529..1333509 100644 --- a/apps/token/qml/pages/ManagePage.qml +++ b/apps/token/qml/pages/ManagePage.qml @@ -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 { diff --git a/apps/token/tests/README.md b/apps/token/tests/README.md new file mode 100644 index 0000000..a51135b --- /dev/null +++ b/apps/token/tests/README.md @@ -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 -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. diff --git a/apps/token/tests/run-basecamp-e2e.sh b/apps/token/tests/run-basecamp-e2e.sh new file mode 100755 index 0000000..8015d41 --- /dev/null +++ b/apps/token/tests/run-basecamp-e2e.sh @@ -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" diff --git a/apps/token/tests/token-definition.mjs b/apps/token/tests/token-definition.mjs new file mode 100644 index 0000000..c88b148 --- /dev/null +++ b/apps/token/tests/token-definition.mjs @@ -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(); diff --git a/flake.nix b/flake.nix index e1ab704..5212fa5 100644 --- a/flake.nix +++ b/flake.nix @@ -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..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" ])