mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(token-ui): integrate Basecamp token module
Add portable Basecamp packaging and an inspector-driven end-to-end flow.
This commit is contained in:
committed by
r4bbit
parent
dc386dee17
commit
470e06c6c3
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module Logos.Wallet
|
||||
optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet
|
||||
classname Logos_WalletPlugin
|
||||
prefer :/qt/qml/Logos/Wallet/
|
||||
depends QtQuick
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
Executable
+98
@@ -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"
|
||||
@@ -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();
|
||||
@@ -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" ])
|
||||
|
||||
Reference in New Issue
Block a user