Files
lnbits/tests/wallets/index.html
T
2026-03-26 12:48:28 +02:00

2408 lines
75 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<title>Wallet Fixture Studio</title>
<link rel="stylesheet" href="../../lnbits/static/vendor/quasar.css" />
<style>
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('../../lnbits/static/fonts/material-icons-v50.woff2')
format('woff2');
}
:root {
--q-primary: #19363d;
--q-secondary: #b86a28;
--q-accent: #287675;
--q-positive: #2c7a54;
--q-negative: #b6402c;
--q-warning: #c48a12;
--q-info: #4c6fa5;
--studio-bg: #f5efe7;
--studio-surface: rgba(255, 252, 247, 0.88);
--studio-surface-strong: rgba(255, 255, 255, 0.96);
--studio-border: rgba(25, 54, 61, 0.12);
--studio-border-strong: rgba(25, 54, 61, 0.18);
--studio-ink: #162028;
--studio-muted: #5d6a71;
--studio-shadow: 0 18px 54px rgba(26, 40, 46, 0.08);
}
* {
box-sizing: border-box;
}
[v-cloak] {
display: none;
}
html,
body,
#q-app {
min-height: 100%;
}
body {
margin: 0;
color: var(--studio-ink);
font-family: 'Avenir Next', 'Segoe UI', 'Helvetica Neue', sans-serif;
background:
radial-gradient(
circle at top left,
rgba(184, 106, 40, 0.16),
transparent 28%
),
radial-gradient(
circle at top right,
rgba(40, 118, 117, 0.13),
transparent 24%
),
linear-gradient(180deg, #fbf7f1 0%, var(--studio-bg) 100%);
}
a {
color: inherit;
}
.app-shell {
max-width: 1680px;
margin: 0 auto;
padding: 24px;
}
.topbar {
margin-bottom: 18px;
padding: 22px;
border: 1px solid var(--studio-border);
border-radius: 28px;
background:
linear-gradient(
135deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 242, 233, 0.92) 48%,
rgba(239, 247, 247, 0.9) 100%
);
box-shadow: var(--studio-shadow);
}
.hero-row {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
}
.hero-copy {
min-width: 0;
flex: 1 1 auto;
}
.hero-eyebrow {
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.74rem;
font-weight: 700;
color: var(--studio-muted);
margin-bottom: 8px;
}
.hero-title {
margin: 0;
font-size: clamp(1.7rem, 2.2vw, 2.75rem);
line-height: 1.05;
font-weight: 800;
letter-spacing: -0.035em;
}
.hero-copy p {
margin: 10px 0 0;
max-width: 900px;
color: var(--studio-muted);
font-size: 0.98rem;
line-height: 1.55;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.dataset-tabs {
margin-top: 20px;
border-radius: 18px;
background: rgba(22, 32, 40, 0.04);
padding: 6px;
}
.stats-row {
margin-top: 18px;
display: grid;
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat-card {
padding: 16px 18px;
border-radius: 22px;
border: 1px solid var(--studio-border);
background: var(--studio-surface);
backdrop-filter: blur(10px);
}
.stat-label {
margin-bottom: 4px;
color: var(--studio-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
}
.stat-value {
font-size: 1.8rem;
line-height: 1;
font-weight: 800;
}
.workspace-grid {
display: grid;
gap: 18px;
grid-template-columns: 320px minmax(0, 1fr) 430px;
align-items: start;
}
.stack {
display: grid;
gap: 18px;
}
.studio-card {
border-radius: 26px;
border: 1px solid var(--studio-border);
background: var(--studio-surface);
box-shadow: var(--studio-shadow);
backdrop-filter: blur(8px);
}
.studio-card .q-card__section--vert {
padding: 18px 20px;
}
.section-title {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.section-title h2,
.section-title h3 {
margin: 0;
font-size: 1rem;
line-height: 1.2;
font-weight: 800;
}
.section-caption {
margin-top: 4px;
color: var(--studio-muted);
font-size: 0.88rem;
line-height: 1.45;
}
.summary-grid {
display: grid;
gap: 12px;
}
.summary-item {
padding: 12px 14px;
border-radius: 18px;
background: rgba(25, 54, 61, 0.04);
border: 1px solid rgba(25, 54, 61, 0.08);
}
.summary-item-label {
color: var(--studio-muted);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
}
.summary-item-value {
margin-top: 4px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 0.86rem;
line-height: 1.5;
word-break: break-word;
}
.pill-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.function-list,
.test-list,
.wallet-list {
display: grid;
gap: 10px;
}
.function-item,
.test-item,
.wallet-item {
border-radius: 18px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.62);
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.function-item:hover,
.test-item:hover,
.wallet-item:hover {
transform: translateY(-1px);
border-color: var(--studio-border-strong);
box-shadow: 0 8px 26px rgba(26, 40, 46, 0.06);
}
.function-item.active,
.test-item.active {
border-color: rgba(25, 54, 61, 0.25);
background: rgba(25, 54, 61, 0.08);
}
.list-button {
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 14px 16px;
color: inherit;
cursor: pointer;
font: inherit;
}
.list-button-title {
font-weight: 800;
font-size: 0.96rem;
}
.list-button-subtitle {
margin-top: 6px;
color: var(--studio-muted);
font-size: 0.84rem;
line-height: 1.4;
}
.function-matrix {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.matrix-card {
border-radius: 20px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(255, 255, 255, 0.7);
padding: 14px;
}
.matrix-card-header {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: flex-start;
}
.matrix-card-title {
margin: 0;
font-size: 0.96rem;
font-weight: 800;
}
.matrix-card-meta {
color: var(--studio-muted);
font-size: 0.82rem;
margin-top: 4px;
}
.matrix-list {
margin-top: 12px;
display: grid;
gap: 10px;
}
.matrix-list-row {
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(25, 54, 61, 0.04);
}
.matrix-list-title {
font-weight: 700;
font-size: 0.86rem;
}
.matrix-list-caption {
margin-top: 4px;
color: var(--studio-muted);
font-size: 0.8rem;
}
.test-detail-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detail-panel {
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(255, 255, 255, 0.72);
}
.detail-label {
color: var(--studio-muted);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.11em;
font-weight: 700;
margin-bottom: 8px;
}
.wallet-expanded-list {
display: grid;
gap: 14px;
}
.wallet-expanded-card {
border-radius: 22px;
border: 1px solid rgba(25, 54, 61, 0.1);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.88) 0%,
rgba(252, 248, 242, 0.78) 100%
);
overflow: hidden;
}
.wallet-expanded-header {
padding: 16px 18px;
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
background: rgba(25, 54, 61, 0.05);
}
.wallet-expanded-title {
margin: 0;
font-size: 1rem;
font-weight: 800;
}
.wallet-expanded-meta {
margin-top: 4px;
color: var(--studio-muted);
font-size: 0.86rem;
}
.wallet-expanded-body {
padding: 16px 18px 18px;
display: grid;
gap: 14px;
}
.variant-card {
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(255, 255, 255, 0.76);
}
.variant-header {
display: flex;
gap: 10px;
align-items: flex-start;
justify-content: space-between;
}
.variant-title {
margin: 0;
font-size: 0.94rem;
font-weight: 800;
}
.variant-subtitle {
margin-top: 5px;
color: var(--studio-muted);
font-size: 0.82rem;
line-height: 1.45;
word-break: break-word;
}
.mock-list {
margin-top: 12px;
display: grid;
gap: 12px;
}
.mock-card {
border-radius: 16px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(25, 54, 61, 0.03);
padding: 12px;
}
.mock-card-header {
display: flex;
gap: 10px;
justify-content: space-between;
align-items: flex-start;
}
.mock-card-title {
font-size: 0.88rem;
font-weight: 800;
}
.mock-card-subtitle {
margin-top: 4px;
color: var(--studio-muted);
font-size: 0.8rem;
line-height: 1.45;
word-break: break-word;
}
.mock-source-grid {
margin-top: 10px;
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.source-chip {
padding: 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(25, 54, 61, 0.08);
}
.source-chip-label {
color: var(--studio-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.source-chip-value {
margin-top: 5px;
font-size: 0.79rem;
line-height: 1.45;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
word-break: break-word;
}
.json-panel {
display: grid;
gap: 6px;
}
.json-label {
color: var(--studio-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.json-pre {
margin: 0;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: #fffdf8;
color: #1c2931;
font-size: 0.8rem;
line-height: 1.55;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
}
.editor-card {
position: sticky;
top: 20px;
}
.editor-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.editor-status {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.fixture-editor {
width: 100%;
min-height: 72vh;
resize: vertical;
padding: 16px;
border-radius: 20px;
border: 1px solid rgba(25, 54, 61, 0.12);
background: #fffdf9;
color: #172127;
font-size: 0.84rem;
line-height: 1.55;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
box-shadow: inset 0 1px 2px rgba(18, 24, 28, 0.04);
}
.fixture-editor:focus {
outline: 2px solid rgba(25, 54, 61, 0.22);
border-color: rgba(25, 54, 61, 0.2);
}
.empty-state {
padding: 18px;
border-radius: 18px;
border: 1px dashed rgba(25, 54, 61, 0.18);
background: rgba(255, 255, 255, 0.5);
color: var(--studio-muted);
font-size: 0.92rem;
line-height: 1.5;
}
.hidden-input {
display: none;
}
@media (max-width: 1320px) {
.workspace-grid {
grid-template-columns: 300px minmax(0, 1fr);
}
.editor-card {
position: static;
top: auto;
grid-column: 1 / -1;
}
}
@media (max-width: 1080px) {
.hero-row {
flex-direction: column;
}
.hero-actions {
justify-content: flex-start;
}
.stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-grid {
grid-template-columns: 1fr;
}
.test-detail-grid,
.mock-source-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 14px;
}
.topbar,
.studio-card .q-card__section--vert {
padding-left: 16px;
padding-right: 16px;
}
.stats-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="q-app" v-cloak>
<div class="app-shell">
<header class="topbar">
<div class="hero-row">
<div class="hero-copy">
<div class="hero-eyebrow">Wallet Fixture Studio</div>
<h1 class="hero-title">Fixture editor and test matrix for wallet funding sources</h1>
<p>
Browse and edit
<code>fixtures_rest.json</code>
and
<code>fixtures_rpc.json</code>
with the same expansion logic the wallet tests use. The explorer shows which
funding source, test case, and merged mocks produce each generated wallet test.
</p>
</div>
<div class="hero-actions">
<q-btn
color="primary"
unelevated
label="Try Relative Load"
@click="loadAllRelative"
></q-btn>
<q-btn
color="secondary"
outline
label="Pick Fixtures Folder"
@click="pickFixturesDirectory"
></q-btn>
<q-btn
color="accent"
outline
label="Load Both Files"
@click="openBulkInput"
></q-btn>
</div>
</div>
<q-tabs
v-model="selectedDatasetKey"
dense
active-color="white"
indicator-color="transparent"
align="left"
class="dataset-tabs text-primary"
>
<q-tab
v-for="datasetDef in datasetDefs"
:key="datasetDef.key"
:name="datasetDef.key"
>
<div class="text-left">
<div class="text-weight-bold">{{ datasetDef.label }}</div>
<div class="text-caption">
{{ datasets[datasetDef.key].analysis ? datasets[datasetDef.key].analysis.summary.expandedTests : 0 }}
generated wallet tests
</div>
</div>
</q-tab>
</q-tabs>
<div class="stats-row" v-if="currentDataset.analysis">
<div class="stat-card">
<div class="stat-label">Funding Sources</div>
<div class="stat-value">{{ currentDataset.analysis.summary.fundingSources }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Functions</div>
<div class="stat-value">{{ currentDataset.analysis.summary.functions }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Raw Test Cases</div>
<div class="stat-value">{{ currentDataset.analysis.summary.rawTests }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Expanded Wallet Tests</div>
<div class="stat-value">{{ currentDataset.analysis.summary.expandedTests }}</div>
</div>
</div>
</header>
<main class="workspace-grid">
<section class="stack">
<q-card flat class="studio-card">
<q-card-section>
<div class="section-title">
<div>
<h2>Current Dataset</h2>
<div class="section-caption">
{{ currentDatasetKindHelp }}
</div>
</div>
<q-chip
dense
square
:color="currentDataset.analysis ? 'positive' : 'grey-6'"
text-color="white"
>
{{ currentDataset.analysis ? 'Loaded' : 'Waiting' }}
</q-chip>
</div>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-item-label">Fixture File</div>
<div class="summary-item-value">{{ currentDataset.fileName }}</div>
</div>
<div class="summary-item">
<div class="summary-item-label">Fixture Path</div>
<div class="summary-item-value">{{ currentDataset.relativePath }}</div>
</div>
<div class="summary-item">
<div class="summary-item-label">Pytest File</div>
<div class="summary-item-value">{{ currentDataset.testFile }}</div>
</div>
<div class="summary-item">
<div class="summary-item-label">Loaded From</div>
<div class="summary-item-value">
{{ currentDataset.sourceLabel || 'Not loaded yet' }}
</div>
</div>
</div>
<q-banner
v-if="currentDataset.loadError"
dense
rounded
inline-actions
class="bg-orange-1 text-orange-10 q-mt-md"
>
{{ currentDataset.loadError }}
</q-banner>
<div class="pill-row q-mt-md">
<q-btn
size="sm"
color="primary"
outline
label="Open Current File"
@click="openCurrentFile"
></q-btn>
<q-btn
size="sm"
color="secondary"
outline
label="Reload Relative"
@click="loadRelative(currentDataset.key)"
></q-btn>
<q-btn
size="sm"
color="accent"
outline
label="Reset Editor"
@click="resetEditor(currentDataset.key)"
></q-btn>
</div>
</q-card-section>
</q-card>
<q-card flat class="studio-card">
<q-card-section>
<div class="section-title">
<div>
<h2>Function Explorer</h2>
<div class="section-caption">
Select a wallet interface method, then inspect its raw test cases.
</div>
</div>
</div>
<q-input
v-model.trim="search"
dense
outlined
clearable
placeholder="Search functions, tests, or funding sources"
></q-input>
<div class="function-list q-mt-md">
<div
v-for="fn in filteredFunctions"
:key="fn.name"
class="function-item"
:class="{ active: fn.name === currentFunctionName }"
>
<button class="list-button" @click="selectFunction(fn.name)">
<div class="list-button-title">{{ fn.name }}</div>
<div class="list-button-subtitle">
{{ fn.tests.length }} raw tests · {{ fn.expandedCount }} generated wallet tests
</div>
</button>
</div>
</div>
<div v-if="!filteredFunctions.length" class="empty-state q-mt-md">
No functions match the current search.
</div>
</q-card-section>
</q-card>
<q-card flat class="studio-card">
<q-card-section>
<div class="section-title">
<div>
<h2>Raw Test Cases</h2>
<div class="section-caption">
These are the entries under
<code>functions.{{ currentFunctionName || '...' }}.tests</code>.
</div>
</div>
</div>
<div class="test-list">
<div
v-for="test in filteredTests"
:key="`${currentFunctionName}-${test.index}`"
class="test-item"
:class="{ active: test.index === currentTestIndex }"
>
<button class="list-button" @click="selectTest(test.index)">
<div class="list-button-title">
{{ test.description || `Test ${test.index + 1}` }}
</div>
<div class="list-button-subtitle">
{{ test.readyWalletCount }} wallets with generated variants ·
{{ test.generatedVariants }} generated tests
</div>
</button>
</div>
</div>
<div v-if="!filteredTests.length" class="empty-state q-mt-md">
No raw tests match the current search for this function.
</div>
</q-card-section>
</q-card>
<q-card flat class="studio-card">
<q-card-section>
<div class="section-title">
<div>
<h2>Funding Sources</h2>
<div class="section-caption">
Wallet classes and settings configured in the current fixture file.
</div>
</div>
</div>
<div class="wallet-list">
<div
v-for="wallet in currentFundingSources"
:key="wallet.name"
class="wallet-item"
>
<div class="list-button">
<div class="list-button-title">{{ wallet.name }}</div>
<div class="list-button-subtitle">
{{ wallet.wallet_class }}
</div>
<div class="pill-row q-mt-sm">
<q-chip dense size="sm" square color="primary" text-color="white">
{{ Object.keys(wallet.settings || {}).length }} settings
</q-chip>
<q-chip
v-if="wallet.skip"
dense
size="sm"
square
color="warning"
text-color="white"
>
Skipped by wallet config
</q-chip>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</section>
<section class="stack">
<q-card flat class="studio-card" v-if="currentFunction">
<q-card-section>
<div class="section-title">
<div>
<h2>Function-Level Mock Templates</h2>
<div class="section-caption">
These are the reusable mock definitions under
<code>functions.{{ currentFunction.name }}.mocks</code>.
</div>
</div>
<q-chip dense square color="primary" text-color="white">
{{ currentFunction.name }}
</q-chip>
</div>
<div class="function-matrix">
<div
v-for="entry in currentFunction.baseMockWallets"
:key="`${currentFunction.name}-${entry.fundingSource.name}`"
class="matrix-card"
>
<div class="matrix-card-header">
<div>
<h3 class="matrix-card-title">{{ entry.fundingSource.name }}</h3>
<div class="matrix-card-meta">
{{ entry.fundingSource.wallet_class }}
</div>
</div>
<q-chip
dense
square
:color="entry.mockNames.length ? 'primary' : 'grey-6'"
text-color="white"
>
{{ entry.mockNames.length }} mocks
</q-chip>
</div>
<div class="matrix-list" v-if="entry.mockEntries.length">
<div
v-for="mock in entry.mockEntries"
:key="`${entry.fundingSource.name}-${mock.name}`"
class="matrix-list-row"
>
<div class="matrix-list-title">{{ mock.name }}</div>
<div class="matrix-list-caption">{{ mockSummary(mock) }}</div>
</div>
</div>
<div v-else class="empty-state q-mt-md">
No base mock templates for this wallet in this function.
</div>
</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="studio-card" v-if="selectedRawTest">
<q-card-section>
<div class="section-title">
<div>
<h2>Selected Raw Test</h2>
<div class="section-caption">
Source path:
<code>{{ selectedRawTestPath }}</code>
</div>
</div>
<q-chip dense square color="secondary" text-color="white">
{{ selectedRawTest.description || `Test ${selectedRawTest.index + 1}` }}
</q-chip>
</div>
<div class="pill-row q-mb-md">
<q-chip
v-for="coverage in selectedRawTest.walletCoverage"
:key="`${selectedRawTest.index}-${coverage.fundingSource.name}`"
dense
square
:color="coverageChipColor(coverage)"
text-color="white"
>
{{ coverage.fundingSource.name }} · {{ coverage.variantCount }}
</q-chip>
</div>
<div class="test-detail-grid">
<div class="detail-panel">
<json-panel
label="Call Params"
:value="selectedRawTest.call_params || {}"
max-height="220px"
></json-panel>
</div>
<div class="detail-panel">
<json-panel
label="Expect"
:value="selectedRawTest.expect"
max-height="220px"
></json-panel>
</div>
<div class="detail-panel">
<json-panel
label="Expect Error"
:value="selectedRawTest.expect_error"
empty-label="No expect_error block"
max-height="220px"
></json-panel>
</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="studio-card" v-if="selectedRawTest">
<q-card-section>
<div class="section-title">
<div>
<h2>Generated Wallet Tests</h2>
<div class="section-caption">
Expanded using the same wallet-test combination rules as
<code>{{ currentDataset.testFile }}</code>.
</div>
</div>
</div>
<div class="wallet-expanded-list">
<div
v-for="walletEntry in expandedWalletEntries"
:key="`${currentFunctionName}-${selectedRawTest.index}-${walletEntry.fundingSource.name}`"
class="wallet-expanded-card"
>
<div class="wallet-expanded-header">
<div>
<h3 class="wallet-expanded-title">{{ walletEntry.fundingSource.name }}</h3>
<div class="wallet-expanded-meta">
{{ walletEntry.fundingSource.wallet_class }}
</div>
</div>
<div class="pill-row">
<q-chip
dense
square
:color="walletEntry.status.color"
text-color="white"
>
{{ walletEntry.status.label }}
</q-chip>
<q-chip dense square color="primary" text-color="white">
{{ walletEntry.variants.length }} variants
</q-chip>
</div>
</div>
<div class="wallet-expanded-body">
<div class="pill-row">
<q-chip dense square color="grey-8" text-color="white">
Base path: {{ walletEntry.basePath }}
</q-chip>
<q-chip
v-if="walletEntry.testPath"
dense
square
color="grey-8"
text-color="white"
>
Test path: {{ walletEntry.testPath }}
</q-chip>
</div>
<div
v-if="walletEntry.note"
class="empty-state"
>
{{ walletEntry.note }}
</div>
<div v-if="walletEntry.variants.length">
<div
v-for="variant in walletEntry.variants"
:key="variant._meta.variantKey"
class="variant-card"
>
<div class="variant-header">
<div>
<h4 class="variant-title">{{ variantDisplayLabel(variant) }}</h4>
<div class="variant-subtitle">
{{ buildTestId(variant) }}
</div>
</div>
<div class="pill-row">
<q-chip
v-if="variant.skip"
dense
square
color="warning"
text-color="white"
>
Skipped
</q-chip>
<q-chip
dense
square
:color="variant.expect_error ? 'negative' : 'positive'"
text-color="white"
>
{{ variant.expect_error ? 'expect_error' : 'expect' }}
</q-chip>
</div>
</div>
<div class="pill-row q-mt-sm">
<q-chip
v-for="mock in variant.mocks"
:key="`${variant._meta.variantKey}-${mock.name}`"
dense
square
color="secondary"
text-color="white"
>
{{ mock.name }}
</q-chip>
</div>
<div class="mock-list" v-if="variant.mocks.length">
<div
v-for="mock in variant.mocks"
:key="`${variant._meta.variantKey}-mock-${mock.name}`"
class="mock-card"
>
<div class="mock-card-header">
<div>
<div class="mock-card-title">{{ mock.name }}</div>
<div class="mock-card-subtitle">{{ mockSummary(mock) }}</div>
</div>
<q-chip
dense
square
:color="mock.skip ? 'warning' : 'primary'"
text-color="white"
>
{{ mock.response_type || 'n/a' }}
</q-chip>
</div>
<div class="mock-source-grid">
<div class="source-chip">
<div class="source-chip-label">Base Mock Path</div>
<div class="source-chip-value">
{{ baseMockPath(currentFunctionName, walletEntry.fundingSource.name, mock.name) }}
</div>
</div>
<div class="source-chip" v-if="mock._meta">
<div class="source-chip-label">Test Mock Path</div>
<div class="source-chip-value">
{{ testMockPath(currentFunctionName, selectedRawTest.index, walletEntry.fundingSource.name, mock.name, mock._meta.testMockIndex) }}
</div>
</div>
</div>
<json-panel
class="q-mt-md"
label="Merged Mock"
:value="cleanMockForDisplay(mock)"
max-height="360px"
></json-panel>
</div>
</div>
<div v-else class="empty-state q-mt-md">
This variant does not generate any combined mocks.
</div>
</div>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</section>
<aside class="stack">
<q-card flat class="studio-card editor-card">
<q-card-section>
<div class="section-title">
<div>
<h2>Raw JSON Editor</h2>
<div class="section-caption">
Edit the current fixture file directly, then apply or save the changes.
</div>
</div>
</div>
<div class="editor-toolbar">
<div class="editor-status">
<q-chip
dense
square
:color="editorDirty ? 'warning' : 'positive'"
text-color="white"
>
{{ editorDirty ? 'Unsaved editor changes' : 'Editor synced' }}
</q-chip>
<q-chip
dense
square
:color="currentDataset.parseError ? 'negative' : 'primary'"
text-color="white"
>
{{ currentDataset.parseError ? 'JSON error' : 'Valid JSON' }}
</q-chip>
</div>
<div class="pill-row">
<q-btn
size="sm"
color="primary"
unelevated
label="Apply"
@click="applyEditor(currentDataset.key)"
></q-btn>
<q-btn
size="sm"
color="secondary"
outline
label="Format"
@click="formatEditor(currentDataset.key)"
></q-btn>
<q-btn
size="sm"
color="accent"
outline
label="Save"
@click="saveDataset(currentDataset.key)"
></q-btn>
<q-btn
size="sm"
color="grey-8"
outline
label="Download"
@click="downloadDataset(currentDataset.key)"
></q-btn>
</div>
</div>
<q-banner
v-if="currentDataset.parseError"
dense
rounded
inline-actions
class="bg-red-1 text-red-10 q-mb-md"
>
{{ currentDataset.parseError }}
</q-banner>
<textarea
v-model="datasets[selectedDatasetKey].rawText"
class="fixture-editor"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
></textarea>
</q-card-section>
</q-card>
</aside>
</main>
</div>
<input
ref="bulkInput"
class="hidden-input"
type="file"
accept=".json,application/json"
multiple
@change="handleBulkFileSelection"
/>
<input
ref="restInput"
class="hidden-input"
type="file"
accept=".json,application/json"
@change="handleSingleFileSelection('rest', $event)"
/>
<input
ref="rpcInput"
class="hidden-input"
type="file"
accept=".json,application/json"
@change="handleSingleFileSelection('rpc', $event)"
/>
</div>
<script src="../../lnbits/static/vendor/vue.global.prod.js"></script>
<script src="../../lnbits/static/vendor/quasar.umd.prod.js"></script>
<script>
const DATASET_DEFS = [
{
key: 'rest',
label: 'REST Fixtures',
relativePath: './fixtures/json/fixtures_rest.json',
fileName: 'fixtures_rest.json',
testFile: 'tests/wallets/test_rest_wallets.py'
},
{
key: 'rpc',
label: 'RPC Fixtures',
relativePath: './fixtures/json/fixtures_rpc.json',
fileName: 'fixtures_rpc.json',
testFile: 'tests/wallets/test_rpc_wallets.py'
}
]
const JsonPanel = {
name: 'JsonPanel',
props: {
label: {
type: String,
default: ''
},
value: {
type: null,
default: undefined
},
emptyLabel: {
type: String,
default: 'No data'
},
maxHeight: {
type: String,
default: '260px'
}
},
computed: {
formatted() {
if (this.value === undefined) {
return this.emptyLabel
}
return safeStringify(this.value)
}
},
template: `
<div class="json-panel">
<div v-if="label" class="json-label">{{ label }}</div>
<pre class="json-pre" :style="{ maxHeight }">{{ formatted }}</pre>
</div>
`
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
function deepClone(value) {
if (value === undefined) {
return undefined
}
return JSON.parse(JSON.stringify(value))
}
function safeStringify(value) {
if (value === undefined) {
return 'undefined'
}
return JSON.stringify(value, null, 2)
}
function parseJsonText(text) {
try {
return {
ok: true,
data: JSON.parse(text)
}
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
function buildTestId(test) {
return `${test.funding_source.name}.${test.function}(${test.description})`
}
function combineMocks(mockName, fsMock, testMock, testMockIndex) {
const merged = {
...deepClone(fsMock),
...deepClone(testMock),
name: mockName
}
if (isPlainObject(merged.response) && isPlainObject(fsMock?.response)) {
merged.response = {
...deepClone(merged.response),
...deepClone(fsMock.response)
}
}
merged._meta = {
testMockIndex
}
return merged
}
function buildMockObjects(mockNames, fsMocks, testMocks) {
const mocks = []
for (const mockName of mockNames) {
if (!Object.prototype.hasOwnProperty.call(testMocks || {}, mockName)) {
continue
}
const values = Array.isArray(testMocks[mockName])
? testMocks[mockName]
: [testMocks[mockName]]
values.forEach((testMock, testMockIndex) => {
const mock = {
fsMock: fsMocks[mockName],
testMock,
testMockIndex
}
if (mockNames.length === 1) {
mocks.push({ [mockName]: mock })
} else {
const subMocks = buildMockObjects(
mockNames.slice(1),
fsMocks,
testMocks
)
subMocks.forEach(subMock => {
mocks.push({
[mockName]: mock,
...subMock
})
})
}
})
return mocks
}
return mocks
}
function testsFromMock(baseTest, mockObject) {
const mocks = Object.keys(mockObject).map(mockName => {
const value = mockObject[mockName]
return combineMocks(
mockName,
value.fsMock,
value.testMock,
value.testMockIndex
)
})
const extraDescription = mocks
.filter(mock => mock.description)
.map(mock => mock.description)
.join(';')
const description = `${baseTest.description}:${extraDescription}`
const skip = Boolean(baseTest.skip || mocks.some(mock => mock.skip))
return {
...deepClone(baseTest),
description,
skip,
mocks,
_meta: {
...deepClone(baseTest._meta),
extraDescription,
variantKey: `${baseTest.funding_source.name}-${baseTest.function}-${baseTest._meta.testIndex}-${description}`
}
}
}
function testsFromFsMocks(baseTest, fn, test, fsName) {
const fsMocks = fn?.mocks?.[fsName] || {}
const testMocks = test?.mocks?.[fsName]
const allTestMocks = Array.isArray(testMocks) ? testMocks : [testMocks]
let mockSets = []
allTestMocks.forEach(testMockSet => {
if (!testMockSet) {
return
}
mockSets = mockSets.concat(
buildMockObjects(Object.keys(fsMocks), fsMocks, testMockSet)
)
})
return mockSets.map(mockSet => testsFromMock(baseTest, mockSet))
}
function testsForFundingSource(fs, fnName, fn, test, testIndex) {
const baseTest = {
skip: Boolean(fs.skip),
function: fnName,
description: test.description || '',
funding_source: deepClone(fs),
call_params: deepClone(test.call_params || {}),
expect: deepClone(test.expect),
expect_error: deepClone(test.expect_error),
mocks: [],
_meta: {
testIndex,
rawDescription: test.description || ''
}
}
if (test && Object.prototype.hasOwnProperty.call(test, 'mocks')) {
if (!Object.prototype.hasOwnProperty.call(test.mocks || {}, fs.name)) {
return [
{
...baseTest,
skip: true,
_meta: {
...baseTest._meta,
variantKey: `${fs.name}-${fnName}-${testIndex}-missing`,
skipReason: 'This raw test has no mock entry for this funding source.'
}
}
]
}
return testsFromFsMocks(baseTest, fn, test, fs.name)
}
return [
{
...baseTest,
_meta: {
...baseTest._meta,
variantKey: `${fs.name}-${fnName}-${testIndex}-plain`
}
}
]
}
function walletFixturesFromJson(data) {
const fundingSources = Object.entries(data?.funding_sources || {}).map(
([name, config]) => ({
name,
...deepClone(config)
})
)
const tests = []
Object.entries(data?.functions || {}).forEach(([fnName, fn]) => {
;(fn.tests || []).forEach((test, testIndex) => {
fundingSources.forEach(fs => {
tests.push(...testsForFundingSource(fs, fnName, fn, test, testIndex))
})
})
})
return tests
}
function createCoverageEntry(fs, fnName, fn, test, testIndex) {
const hasMocksEntry = Boolean(
test?.mocks && Object.prototype.hasOwnProperty.call(test.mocks, fs.name)
)
const variants = testsForFundingSource(fs, fnName, fn, test, testIndex)
const testMockNames = hasMocksEntry
? Object.keys(test.mocks?.[fs.name] || {})
: []
return {
fundingSource: fs,
hasMocksEntry,
variantCount: variants.length,
readyCount: variants.filter(variant => !variant.skip).length,
skippedCount: variants.filter(variant => variant.skip).length,
testMockNames
}
}
function analyzeFixtureData(data) {
const fundingSources = Object.entries(data?.funding_sources || {}).map(
([name, config]) => ({
name,
...deepClone(config)
})
)
const functions = Object.entries(data?.functions || {}).map(
([fnName, fn]) => {
const tests = (fn.tests || []).map((test, testIndex) => {
const walletCoverage = fundingSources.map(fs =>
createCoverageEntry(fs, fnName, fn, test, testIndex)
)
return {
index: testIndex,
description: test.description || '',
call_params: deepClone(test.call_params || {}),
expect: deepClone(test.expect),
expect_error: deepClone(test.expect_error),
raw: deepClone(test),
walletCoverage,
generatedVariants: walletCoverage.reduce(
(sum, item) => sum + item.variantCount,
0
),
readyWalletCount: walletCoverage.filter(
item => item.readyCount > 0
).length
}
})
const baseMockWallets = fundingSources.map(fs => {
const mocks = deepClone(fn?.mocks?.[fs.name] || {})
const mockEntries = Object.entries(mocks).map(([name, mock]) => ({
name,
...mock
}))
return {
fundingSource: fs,
mocks,
mockEntries,
mockNames: mockEntries.map(item => item.name)
}
})
return {
name: fnName,
raw: deepClone(fn),
tests,
baseMockWallets,
expandedCount: tests.reduce(
(sum, item) => sum + item.generatedVariants,
0
)
}
}
)
const expandedTests = walletFixturesFromJson(data)
return {
fundingSources,
functions,
expandedTests,
summary: {
fundingSources: fundingSources.length,
functions: functions.length,
rawTests: functions.reduce((sum, fn) => sum + fn.tests.length, 0),
expandedTests: expandedTests.length,
skippedExpandedTests: expandedTests.filter(test => test.skip).length
}
}
}
function createDatasetState(def) {
return {
...def,
rawText: '',
snapshotText: '',
sourceLabel: '',
parseError: '',
loadError: '',
fileHandle: null,
data: null,
analysis: null
}
}
const { createApp } = Vue
createApp({
components: {
JsonPanel
},
data() {
const datasets = {}
const selectedFunctionByDataset = {}
const selectedTestIndexByDataset = {}
DATASET_DEFS.forEach(def => {
datasets[def.key] = createDatasetState(def)
selectedFunctionByDataset[def.key] = ''
selectedTestIndexByDataset[def.key] = 0
})
return {
datasetDefs: DATASET_DEFS,
datasets,
selectedDatasetKey: 'rest',
selectedFunctionByDataset,
selectedTestIndexByDataset,
search: ''
}
},
computed: {
currentDataset() {
return this.datasets[this.selectedDatasetKey]
},
currentFundingSources() {
return this.currentDataset.analysis?.fundingSources || []
},
currentFunctionName() {
return this.selectedFunctionByDataset[this.selectedDatasetKey] || ''
},
currentFunction() {
return (
this.currentDataset.analysis?.functions.find(
fn => fn.name === this.currentFunctionName
) || null
)
},
currentTestIndex() {
return this.selectedTestIndexByDataset[this.selectedDatasetKey] || 0
},
selectedRawTest() {
if (!this.currentFunction) {
return null
}
return (
this.currentFunction.tests.find(
test => test.index === this.currentTestIndex
) || this.currentFunction.tests[0] || null
)
},
filteredFunctions() {
const functions = this.currentDataset.analysis?.functions || []
const query = this.search.toLowerCase().trim()
if (!query) {
return functions
}
return functions.filter(fn => {
const walletNames = fn.baseMockWallets
.map(entry => entry.fundingSource.name)
.join(' ')
const testDescriptions = fn.tests
.map(test => test.description)
.join(' ')
return `${fn.name} ${walletNames} ${testDescriptions}`
.toLowerCase()
.includes(query)
})
},
filteredTests() {
if (!this.currentFunction) {
return []
}
const query = this.search.toLowerCase().trim()
if (!query) {
return this.currentFunction.tests
}
return this.currentFunction.tests.filter(test => {
const walletNames = test.walletCoverage
.map(entry => entry.fundingSource.name)
.join(' ')
return `${test.description} ${walletNames}`
.toLowerCase()
.includes(query)
})
},
selectedRawTestPath() {
if (!this.currentFunction || !this.selectedRawTest) {
return ''
}
return `functions.${this.currentFunction.name}.tests[${this.selectedRawTest.index}]`
},
currentDatasetKindHelp() {
if (this.selectedDatasetKey === 'rest') {
return 'HTTP funding sources with request and response mocks consumed by pytest_httpserver.'
}
return 'RPC funding sources with patched Python methods, nested return data, and spy assertions.'
},
editorDirty() {
return this.currentDataset.rawText !== this.currentDataset.snapshotText
},
expandedWalletEntries() {
if (!this.currentFunction || !this.selectedRawTest) {
return []
}
return this.currentFundingSources.map(fs => {
const variants = testsForFundingSource(
fs,
this.currentFunction.name,
this.currentFunction.raw,
this.selectedRawTest.raw,
this.selectedRawTest.index
)
const hasMocksEntry = Boolean(
this.selectedRawTest.raw?.mocks &&
Object.prototype.hasOwnProperty.call(
this.selectedRawTest.raw.mocks,
fs.name
)
)
const basePath = this.baseWalletPath(this.currentFunction.name, fs.name)
const testPath = hasMocksEntry
? this.testWalletPath(
this.currentFunction.name,
this.selectedRawTest.index,
fs.name
)
: ''
let note = ''
let status = {
label: 'Ready',
color: 'positive'
}
if (fs.skip) {
status = {
label: 'Skipped wallet',
color: 'warning'
}
note = 'This funding source is marked with `skip: true` in the fixture file.'
} else if (!hasMocksEntry && this.selectedRawTest.raw?.mocks) {
status = {
label: 'Missing mock entry',
color: 'negative'
}
note = 'This raw test omits this funding source under its `mocks` section, so the generated wallet test is skipped.'
} else if (!variants.length) {
status = {
label: 'No variants',
color: 'grey-7'
}
note = 'This wallet has a mock entry, but it expands to zero generated variants.'
} else if (variants.every(variant => variant.skip)) {
status = {
label: 'All skipped',
color: 'warning'
}
}
return {
fundingSource: fs,
variants,
hasMocksEntry,
basePath,
testPath,
note,
status
}
})
}
},
watch: {
selectedDatasetKey() {
this.ensureSelections(this.selectedDatasetKey)
}
},
mounted() {
this.loadAllRelative()
window.addEventListener('keydown', this.handleGlobalKeydown)
},
beforeUnmount() {
window.removeEventListener('keydown', this.handleGlobalKeydown)
},
methods: {
notify(message, type = 'info', caption = '') {
Quasar.Notify.create({
message,
caption: caption || void 0,
type,
timeout: 2500,
progress: true
})
},
handleGlobalKeydown(event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
this.saveDataset(this.currentDataset.key)
}
},
mockSummary(mock) {
const pieces = []
if (mock.method && mock.uri) {
pieces.push(`${mock.method} ${mock.uri}`)
} else if (mock.method) {
pieces.push(mock.method)
} else if (mock.uri) {
pieces.push(mock.uri)
}
if (mock.request_type) {
pieces.push(`request=${mock.request_type}`)
}
if (mock.response_type) {
pieces.push(`response=${mock.response_type}`)
}
return pieces.join(' · ') || 'No mock metadata'
},
cleanMockForDisplay(mock) {
const clone = deepClone(mock)
delete clone._meta
return clone
},
coverageChipColor(coverage) {
if (coverage.fundingSource.skip) {
return 'warning'
}
if (!coverage.hasMocksEntry) {
return 'negative'
}
if (!coverage.variantCount) {
return 'grey-7'
}
if (!coverage.readyCount) {
return 'warning'
}
return 'positive'
},
variantDisplayLabel(variant) {
const raw = variant._meta?.rawDescription || variant.description || ''
const extra = variant._meta?.extraDescription
if (extra) {
return `${raw} / ${extra}`
}
return raw || variant.description || 'Generated variant'
},
buildTestId,
baseWalletPath(functionName, fundingSourceName) {
return `functions.${functionName}.mocks.${fundingSourceName}`
},
testWalletPath(functionName, testIndex, fundingSourceName) {
return `functions.${functionName}.tests[${testIndex}].mocks.${fundingSourceName}`
},
baseMockPath(functionName, fundingSourceName, mockName) {
return `functions.${functionName}.mocks.${fundingSourceName}.${mockName}`
},
testMockPath(
functionName,
testIndex,
fundingSourceName,
mockName,
mockIndex
) {
return `functions.${functionName}.tests[${testIndex}].mocks.${fundingSourceName}.${mockName}[${mockIndex}]`
},
ensureSelections(datasetKey) {
const dataset = this.datasets[datasetKey]
const functions = dataset.analysis?.functions || []
if (!functions.length) {
this.selectedFunctionByDataset[datasetKey] = ''
this.selectedTestIndexByDataset[datasetKey] = 0
return
}
const currentFunctionName = this.selectedFunctionByDataset[datasetKey]
const selectedFunction = functions.find(
fn => fn.name === currentFunctionName
)
if (!selectedFunction) {
this.selectedFunctionByDataset[datasetKey] = functions[0].name
}
const nextFunction = functions.find(
fn => fn.name === this.selectedFunctionByDataset[datasetKey]
)
const tests = nextFunction?.tests || []
const currentIndex = this.selectedTestIndexByDataset[datasetKey]
if (!tests.length) {
this.selectedTestIndexByDataset[datasetKey] = 0
return
}
const matchingTest = tests.find(test => test.index === currentIndex)
if (!matchingTest) {
this.selectedTestIndexByDataset[datasetKey] = tests[0].index
}
},
selectFunction(functionName) {
this.selectedFunctionByDataset[this.selectedDatasetKey] = functionName
const fn = this.currentDataset.analysis?.functions.find(
item => item.name === functionName
)
this.selectedTestIndexByDataset[this.selectedDatasetKey] =
fn?.tests[0]?.index || 0
},
selectTest(testIndex) {
this.selectedTestIndexByDataset[this.selectedDatasetKey] = testIndex
},
commitDataset(datasetKey, parsedData, options = {}) {
const dataset = this.datasets[datasetKey]
const rawText =
options.rawText !== undefined
? options.rawText
: JSON.stringify(parsedData, null, 2)
const snapshotText =
options.snapshotText !== undefined ? options.snapshotText : rawText
dataset.data = parsedData
dataset.analysis = analyzeFixtureData(parsedData)
dataset.rawText = rawText
dataset.snapshotText = snapshotText
dataset.parseError = ''
if (options.fileHandle !== undefined) {
dataset.fileHandle = options.fileHandle
}
if (options.sourceLabel !== undefined) {
dataset.sourceLabel = options.sourceLabel
}
if (options.loadError !== undefined) {
dataset.loadError = options.loadError
}
this.ensureSelections(datasetKey)
},
validateEditor(datasetKey) {
const dataset = this.datasets[datasetKey]
const parsed = parseJsonText(dataset.rawText)
if (!parsed.ok) {
dataset.parseError = `Invalid JSON in ${dataset.fileName}: ${parsed.error}`
return null
}
dataset.parseError = ''
return parsed.data
},
applyEditor(datasetKey) {
const parsedData = this.validateEditor(datasetKey)
if (!parsedData) {
this.notify('Could not apply the editor contents.', 'negative')
return
}
const dataset = this.datasets[datasetKey]
this.commitDataset(datasetKey, parsedData, {
rawText: dataset.rawText,
snapshotText: dataset.rawText,
sourceLabel: dataset.sourceLabel
})
this.notify(`Applied ${dataset.fileName}.`, 'positive')
},
formatEditor(datasetKey) {
const parsedData = this.validateEditor(datasetKey)
if (!parsedData) {
this.notify('Could not format invalid JSON.', 'negative')
return
}
const formatted = JSON.stringify(parsedData, null, 2)
const dataset = this.datasets[datasetKey]
this.commitDataset(datasetKey, parsedData, {
rawText: formatted,
snapshotText: formatted,
sourceLabel: dataset.sourceLabel
})
this.notify(`Formatted ${dataset.fileName}.`, 'positive')
},
resetEditor(datasetKey) {
const dataset = this.datasets[datasetKey]
dataset.rawText = dataset.snapshotText
dataset.parseError = ''
this.notify(`Reset editor for ${dataset.fileName}.`, 'info')
},
async loadRelative(datasetKey) {
const dataset = this.datasets[datasetKey]
try {
const response = await fetch(dataset.relativePath, {
cache: 'no-store'
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const text = await response.text()
const parsed = parseJsonText(text)
if (!parsed.ok) {
throw new Error(parsed.error)
}
const formatted = JSON.stringify(parsed.data, null, 2)
this.commitDataset(datasetKey, parsed.data, {
rawText: formatted,
snapshotText: formatted,
sourceLabel: `Relative load: ${dataset.relativePath}`,
fileHandle: null,
loadError: ''
})
} catch (error) {
dataset.loadError =
`Automatic relative loading failed for ${dataset.fileName}. ` +
`If you opened this page with file://, use “Load Both Files” or “Pick Fixtures Folder”.`
if (!dataset.analysis) {
dataset.parseError = ''
}
console.error(error)
}
},
async loadAllRelative() {
await Promise.all(this.datasetDefs.map(def => this.loadRelative(def.key)))
},
openBulkInput() {
this.$refs.bulkInput.value = ''
this.$refs.bulkInput.click()
},
async openCurrentFile() {
const datasetKey = this.currentDataset.key
if (window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker({
multiple: false,
types: [
{
description: 'JSON fixture file',
accept: {
'application/json': ['.json']
}
}
]
})
if (!handle) {
return
}
const file = await handle.getFile()
await this.loadDatasetFromFile(datasetKey, file, {
fileHandle: handle,
sourceLabel: `File picker: ${file.name}`
})
return
} catch (error) {
if (error && error.name !== 'AbortError') {
console.error(error)
}
}
}
const refName = `${datasetKey}Input`
this.$refs[refName].value = ''
this.$refs[refName].click()
},
async handleBulkFileSelection(event) {
const files = Array.from(event.target.files || [])
const byName = Object.fromEntries(files.map(file => [file.name, file]))
for (const def of this.datasetDefs) {
if (byName[def.fileName]) {
await this.loadDatasetFromFile(def.key, byName[def.fileName], {
sourceLabel: `Manual file load: ${byName[def.fileName].name}`
})
}
}
const missing = this.datasetDefs
.filter(def => !byName[def.fileName])
.map(def => def.fileName)
if (missing.length) {
this.notify(
`Missing file selection for ${missing.join(', ')}.`,
'warning'
)
}
},
async handleSingleFileSelection(datasetKey, event) {
const file = event.target.files?.[0]
if (!file) {
return
}
await this.loadDatasetFromFile(datasetKey, file, {
sourceLabel: `Manual file load: ${file.name}`
})
},
async loadDatasetFromFile(datasetKey, file, options = {}) {
const text = await file.text()
const parsed = parseJsonText(text)
if (!parsed.ok) {
this.datasets[datasetKey].parseError = `Invalid JSON in ${file.name}: ${parsed.error}`
this.notify(`Could not read ${file.name}.`, 'negative')
return
}
const formatted = JSON.stringify(parsed.data, null, 2)
this.commitDataset(datasetKey, parsed.data, {
rawText: formatted,
snapshotText: formatted,
fileHandle: options.fileHandle,
sourceLabel: options.sourceLabel || `Loaded from ${file.name}`,
loadError: ''
})
this.notify(`Loaded ${file.name}.`, 'positive')
},
async pickFixturesDirectory() {
if (!window.showDirectoryPicker) {
this.notify(
'Directory picker is not available in this browser. Use “Load Both Files” instead.',
'warning'
)
return
}
try {
const directoryHandle = await window.showDirectoryPicker({
mode: 'readwrite'
})
for (const def of this.datasetDefs) {
try {
const fileHandle = await directoryHandle.getFileHandle(def.fileName)
const file = await fileHandle.getFile()
await this.loadDatasetFromFile(def.key, file, {
fileHandle,
sourceLabel: `Directory handle: ${def.fileName}`
})
} catch (error) {
console.error(error)
this.datasets[def.key].loadError =
`Could not find ${def.fileName} in the selected folder. Choose tests/wallets/fixtures/json.`
}
}
} catch (error) {
if (error && error.name !== 'AbortError') {
console.error(error)
this.notify('Could not open the selected folder.', 'negative')
}
}
},
async saveDataset(datasetKey) {
const parsedData = this.validateEditor(datasetKey)
if (!parsedData) {
this.notify('Fix the JSON error before saving.', 'negative')
return
}
const dataset = this.datasets[datasetKey]
const contents = dataset.rawText
this.commitDataset(datasetKey, parsedData, {
rawText: contents,
snapshotText: contents,
fileHandle: dataset.fileHandle,
sourceLabel: dataset.sourceLabel
})
if (dataset.fileHandle) {
try {
const writable = await dataset.fileHandle.createWritable()
await writable.write(contents)
await writable.close()
this.notify(`Saved ${dataset.fileName} in place.`, 'positive')
return
} catch (error) {
console.error(error)
}
}
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: dataset.fileName,
types: [
{
description: 'JSON fixture file',
accept: {
'application/json': ['.json']
}
}
]
})
const writable = await handle.createWritable()
await writable.write(contents)
await writable.close()
dataset.fileHandle = handle
dataset.sourceLabel = `Save picker: ${dataset.fileName}`
this.notify(`Saved ${dataset.fileName}.`, 'positive')
return
} catch (error) {
if (error && error.name === 'AbortError') {
return
}
console.error(error)
}
}
this.downloadDataset(datasetKey)
},
downloadDataset(datasetKey) {
const parsedData = this.validateEditor(datasetKey)
if (!parsedData) {
this.notify('Fix the JSON error before downloading.', 'negative')
return
}
const dataset = this.datasets[datasetKey]
const contents = dataset.rawText
this.commitDataset(datasetKey, parsedData, {
rawText: contents,
snapshotText: contents,
fileHandle: dataset.fileHandle,
sourceLabel: dataset.sourceLabel
})
const status = Quasar.exportFile(
dataset.fileName,
contents,
'application/json'
)
if (status === true) {
this.notify(`Downloaded ${dataset.fileName}.`, 'positive')
} else {
this.notify(
`Browser blocked the download for ${dataset.fileName}.`,
'negative'
)
}
}
}
})
.use(Quasar, {
config: {
notify: {
position: 'top-right'
}
}
})
.mount('#q-app')
</script>
</body>
</html>