taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 21fcb03521e5288403c4822dd1f297efb864ad02
parent e797e4e30f4a47175f17472b8f7ef3a29c8c97a4
Author: Florian Dold <dold@taler.net>
Date:   Tue,  1 Sep 2026 19:47:22 +0200

wallet web UI: save JSON or YAML diagnostics

Diffstat:
Mpackages/wallet-webui/build.mjs | 24++++++++++++++----------
Mpackages/wallet-webui/src/custom.d.ts | 1+
Mpackages/wallet-webui/src/routes/App.tsx | 30+++++++++++++++++++++++-------
Mpackages/wallet-webui/src/screens/SettingsScreen.tsx | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/wallet-webui/src/testing/demo-wallet.ts | 39+++++++++++++++++++++++++++++++--------
Mpackages/wallet-webui/test/screens.test.tsx | 43++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 175 insertions(+), 29 deletions(-)

diff --git a/packages/wallet-webui/build.mjs b/packages/wallet-webui/build.mjs @@ -24,6 +24,10 @@ const packageDirectory = fileURLToPath(new URL("./", import.meta.url)); const packageMetadata = JSON.parse( await readFile(new URL("./package.json", import.meta.url), "utf8"), ); +const defineBuildConstants = (extra = {}) => ({ + __WALLET_WEBUI_VERSION__: JSON.stringify(packageMetadata.version), + ...extra, +}); const dist = new URL("./dist/", import.meta.url); const fontNames = [ "roboto-normal-400.ttf", @@ -119,13 +123,13 @@ if (sqliteEnabled) { } await build({ ...shared, - define: { + define: defineBuildConstants({ __SQLITE_ENABLED__: JSON.stringify(sqliteEnabled), __SQLITE_WASM_URL__: JSON.stringify( sqliteEnabled ? `./sqlite3.wasm?v=${sqliteWasmVersion}` : "", ), __DEMO_BUILD__: "false", - }, + }), entryPoints: { "wallet-worker": "src/workers/wallet-worker.ts" }, outdir: pwa.pathname, }); @@ -149,13 +153,13 @@ if (pwaSqliteTest) { const workerVersion = await contentVersion(pwa, ["wallet-worker.js"]); await build({ ...shared, - define: { + define: defineBuildConstants({ __SQLITE_ENABLED__: JSON.stringify(sqliteEnabled), __DEMO_BUILD__: "false", __WALLET_WORKER_URL__: JSON.stringify( `./wallet-worker.js?v=${workerVersion}`, ), - }, + }), entryPoints: { app: "src/pwa-entry.tsx" }, outdir: pwa.pathname, }); @@ -235,10 +239,10 @@ await build({ "wallet-webui-sqlite-runtime": "./src/platform/sqlite-runtime-disabled.ts", "wallet-webui-storage-faults": "./src/testing/storage-faults-disabled.ts", }, - define: { + define: defineBuildConstants({ __SQLITE_ENABLED__: "false", __DEMO_BUILD__: "true", - }, + }), entryPoints: { app: "src/demo-entry.tsx" }, outdir: demo.pathname, }); @@ -270,10 +274,10 @@ await html(demo, "index.html", "GNU Taler Wallet Demo", { await build({ ...shared, - define: { + define: defineBuildConstants({ __STATIC_CACHE_VERSION__: JSON.stringify(pwaVersion), __STATIC_ASSETS__: JSON.stringify(versionedPwaAssets), - }, + }), entryPoints: { "service-worker": "src/platform/service-worker.ts" }, outdir: pwa.pathname, }); @@ -283,11 +287,11 @@ for (const target of ["chrome", "firefox"]) { await mkdir(directory, { recursive: true }); await build({ ...shared, - define: { + define: defineBuildConstants({ __SQLITE_ENABLED__: "false", __DEMO_BUILD__: "false", __WALLET_TARGET__: JSON.stringify(target), - }, + }), entryPoints: { app: "src/extension-entry.tsx", popup: "src/popup-entry.tsx", diff --git a/packages/wallet-webui/src/custom.d.ts b/packages/wallet-webui/src/custom.d.ts @@ -3,6 +3,7 @@ declare const __WALLET_WORKER_URL__: string; declare const __SQLITE_ENABLED__: boolean; declare const __SQLITE_WASM_URL__: string; declare const __DEMO_BUILD__: boolean; +declare const __WALLET_WEBUI_VERSION__: string; declare const __STATIC_CACHE_VERSION__: string; declare const __STATIC_ASSETS__: string[]; declare module "*.css"; diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -68,7 +68,10 @@ import { import { ShoppingScreen } from "../screens/ShoppingScreen.js"; import { RecoveryScreen } from "../screens/RecoveryScreen.js"; import { QrScannerScreen } from "../screens/QrScannerScreen.js"; -import { SettingsScreen } from "../screens/SettingsScreen.js"; +import { + SettingsScreen, + type DiagnosticsExportOptions, +} from "../screens/SettingsScreen.js"; import { ImportRecoveryScreen, type ImportStage, @@ -220,6 +223,10 @@ const decodeQr = jsQR as unknown as ( ) => jsQrTypes.QRCode | null; const demoCompiled = typeof __DEMO_BUILD__ !== "undefined" && __DEMO_BUILD__; +const walletWebUiVersion = + typeof __WALLET_WEBUI_VERSION__ !== "undefined" + ? __WALLET_WEBUI_VERSION__ + : "unknown"; export function walletRoutePath(location: string): string { return location.split("?", 1)[0] || "/"; @@ -4875,22 +4882,31 @@ function SettingsRoute() { setBusy(false); } }; - const exportDiagnostics = async () => { + const exportDiagnostics = async (options: DiagnosticsExportOptions) => { setBusy(true); setMessage(undefined); setSettingsError(undefined); try { const diagnostics = await connection.client.call( WalletApiOperation.GetDiagnostics, - {}, + { + format: options.format, + transactionLimit: options.transactionLimit, + frontendInfo: { + name: "wallet-webui", + version: walletWebUiVersion, + platform: platform.target, + }, + }, ); - const blob = new Blob([JSON.stringify(diagnostics, undefined, 2)], { - type: "application/json", + const blob = new Blob([diagnostics], { + type: + options.format === "yaml" ? "application/yaml" : "application/json", }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `taler-wallet-diagnostics-${new Date().toISOString().slice(0, 10)}.json`; + anchor.download = `taler-wallet-diagnostics-${new Date().toISOString().slice(0, 10)}.${options.format}`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 0); setMessage(i18n.str`Diagnostics export created.`); @@ -5200,7 +5216,7 @@ function SettingsRoute() { browserIntegrationMessage={browserIntegrationMessage} balanceHistoryEnabled={uiPreferences.balanceHistoryEnabled} onExport={() => void exportDb()} - onDiagnostics={() => void exportDiagnostics()} + onDiagnostics={(options) => void exportDiagnostics(options)} onImport={(file) => void reviewImport(file)} onOpenExchanges={() => navigate("/exchanges")} onOpenAccounts={() => navigate("/accounts")} diff --git a/packages/wallet-webui/src/screens/SettingsScreen.tsx b/packages/wallet-webui/src/screens/SettingsScreen.tsx @@ -38,6 +38,11 @@ export interface StorageGenerationView { generation: number; } +export interface DiagnosticsExportOptions { + format: "json" | "yaml"; + transactionLimit: number; +} + function storageBackendLabel(backend: string): string { if (backend === "sqlite") return "SQLite"; if (backend === "indexeddb") return "IndexedDB"; @@ -186,7 +191,7 @@ export function SettingsScreen(props: { browserIntegrationMessage?: string; balanceHistoryEnabled: boolean; onExport: () => void; - onDiagnostics: () => void; + onDiagnostics: (options: DiagnosticsExportOptions) => void; onImport: (file: File) => void; onOpenExchanges: () => void; onOpenAccounts: () => void; @@ -209,6 +214,17 @@ export function SettingsScreen(props: { const [confirmingClear, setConfirmingClear] = useState(false); const [confirmingDeveloperMode, setConfirmingDeveloperMode] = useState(false); const [confirmingStaleDeletion, setConfirmingStaleDeletion] = useState(false); + const [diagnosticsFormat, setDiagnosticsFormat] = useState<"json" | "yaml">( + "json", + ); + const [diagnosticsTransactionLimit, setDiagnosticsTransactionLimit] = + useState("100"); + const parsedDiagnosticsTransactionLimit = Number(diagnosticsTransactionLimit); + const diagnosticsTransactionLimitValid = + diagnosticsTransactionLimit.trim() !== "" && + Number.isSafeInteger(parsedDiagnosticsTransactionLimit) && + parsedDiagnosticsTransactionLimit >= 0 && + parsedDiagnosticsTransactionLimit < Number.MAX_SAFE_INTEGER; return ( <div class="space-y-5"> <div> @@ -410,10 +426,55 @@ export function SettingsScreen(props: { <Card> <h2 class="font-semibold">{i18n.str`Diagnostics`}</h2> <p class="my-3 text-sm text-secondary">{i18n.str`Save privacy-scrubbed wallet statistics for troubleshooting. This is not a wallet database backup.`}</p> + <div class="mb-4 grid gap-3 sm:grid-cols-2"> + <label class="block"> + <span class="mb-1 block text-sm font-medium"> + {i18n.str`File format`} + </span> + <select + value={diagnosticsFormat} + onChange={(event) => + setDiagnosticsFormat( + event.currentTarget.value === "yaml" ? "yaml" : "json", + ) + } + class="w-full rounded-xl border border-outline bg-surface px-3 py-3" + > + <option value="json">JSON</option> + <option value="yaml">YAML</option> + </select> + </label> + <label class="block"> + <span class="mb-1 block text-sm font-medium"> + {i18n.str`Maximum transactions`} + </span> + <input + type="number" + min="0" + step="1" + value={diagnosticsTransactionLimit} + aria-invalid={!diagnosticsTransactionLimitValid} + onInput={(event) => + setDiagnosticsTransactionLimit(event.currentTarget.value) + } + class="w-full rounded-xl border border-outline bg-surface px-3 py-3" + /> + {!diagnosticsTransactionLimitValid && ( + <span class="mt-1 block text-xs text-error"> + {i18n.str`Enter a non-negative whole number.`} + </span> + )} + </label> + </div> <Button tone="secondary" - onClick={props.onDiagnostics} - disabled={props.busy} + onClick={() => + props.onDiagnostics({ + format: diagnosticsFormat, + transactionLimit: parsedDiagnosticsTransactionLimit, + }) + } + disabled={props.busy || !diagnosticsTransactionLimitValid} >{i18n.str`Save diagnostics`}</Button> </Card> {!props.demo && ( diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts @@ -13,6 +13,7 @@ import { TransactionMinorState, TransactionType, WithdrawalType, + stringifyYaml, type AmountString, type ScopeInfo, type Transaction, @@ -1195,14 +1196,36 @@ export class DemoWalletConnection implements WalletConnection { return { transactionId: this.state.transactions[0].transactionId, } as never; - case WalletApiOperation.GetDiagnostics: - return { - walletManifestVersion: "demo", - walletManifestDisplayVersion: "Demo mode", - errors: [], - firefoxIdbProblem: false, - dbOutdated: false, - } as never; + case WalletApiOperation.GetDiagnostics: { + const report = { + formatVersion: 1, + generatedAt: new Date(this.now()).toISOString(), + walletCoreVersion: { + implementationSemver: "demo", + implementationGitHash: "demo", + version: "10:0:0", + }, + frontendInfo: request.frontendInfo, + database: { backend: "indexeddb", recordCounts: {} }, + exchanges: [], + coins: { total: 0, groups: [] }, + bankAccounts: [], + transactions: { + limit: + typeof request.transactionLimit === "number" + ? request.transactionLimit + : 100, + returned: 0, + truncated: false, + entries: [], + }, + }; + return ( + request.format === "yaml" + ? stringifyYaml(report) + : `${JSON.stringify(report, undefined, 2)}\n` + ) as never; + } default: throw Error( `The ${String(operation)} operation is unavailable in demo mode.`, diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx @@ -3314,8 +3314,10 @@ test("developer settings require explicit confirmation before clearing the walle .default as unknown as { setup(options: { document: Document }): { click(element: Element): Promise<void>; + clear(element: Element): Promise<void>; keyboard(value: string): Promise<void>; selectOptions(element: Element, values: string): Promise<void>; + type(element: Element, value: string): Promise<void>; }; }; let clearCount = 0; @@ -3325,6 +3327,9 @@ test("developer settings require explicit confirmation before clearing the walle let selectedLanguage: string | undefined; let integrationChange: [string, boolean] | undefined; let balanceHistoryEnabled: boolean | undefined; + let diagnosticsOptions: + | { format: "json" | "yaml"; transactionLimit: number } + | undefined; const settings = (testMoneyBusy = false, metadataLoaded = true) => ( <main> <SettingsScreen @@ -3362,7 +3367,9 @@ test("developer settings require explicit confirmation before clearing the walle }} balanceHistoryEnabled={false} onExport={() => {}} - onDiagnostics={() => {}} + onDiagnostics={(options) => { + diagnosticsOptions = options; + }} onImport={() => {}} onOpenExchanges={() => {}} onOpenAccounts={() => {}} @@ -3409,6 +3416,40 @@ test("developer settings require explicit confirmation before clearing the walle assert(view.getByRole("heading", { name: "Wallet data protection" })); assert(view.getByRole("heading", { name: "Browser integration" })); assert(view.getByRole("heading", { name: "Balance display" })); + const diagnosticsFormat = view.getByRole("combobox", { + name: "File format", + }); + const diagnosticsLimit = view.getByRole("spinbutton", { + name: "Maximum transactions", + }); + assert.equal((diagnosticsFormat as HTMLSelectElement).value, "json"); + assert.equal((diagnosticsLimit as HTMLInputElement).value, "100"); + await user.click(view.getByRole("button", { name: "Save diagnostics" })); + assert.deepEqual(diagnosticsOptions, { + format: "json", + transactionLimit: 100, + }); + await user.selectOptions(diagnosticsFormat, "yaml"); + await user.clear(diagnosticsLimit); + await user.type(diagnosticsLimit, "25"); + await user.click(view.getByRole("button", { name: "Save diagnostics" })); + assert.deepEqual(diagnosticsOptions, { + format: "yaml", + transactionLimit: 25, + }); + await user.clear(diagnosticsLimit); + await user.type(diagnosticsLimit, "-1"); + assert(view.getByText("Enter a non-negative whole number.")); + assert.equal( + ( + view.getByRole("button", { + name: "Save diagnostics", + }) as HTMLButtonElement + ).disabled, + true, + ); + await user.clear(diagnosticsLimit); + await user.type(diagnosticsLimit, "100"); await user.click( view.getByRole("checkbox", { name: "Show balance history graph" }), );