Files
lnbits/tests/wallets/index.html
T

6397 lines
219 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;
}
.material-icons,
.q-icon,
.q-notification__icon {
font-family: 'Material Icons' !important;
font-feature-settings: 'liga';
font-style: normal;
font-weight: 400;
letter-spacing: normal;
line-height: 1;
text-transform: none;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
}
[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;
}
.stats-row {
margin-top: 18px;
display: grid;
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat-card {
display: flex;
flex-direction: column;
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;
}
.stat-value-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.stat-card-action {
flex: 0 0 auto;
}
.workspace-grid {
display: grid;
gap: 18px;
}
.primary-row {
display: grid;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
}
.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;
}
.picker-card {
height: 100%;
}
.picker-card--full {
grid-column: 1 / -1;
}
.picker-card .q-card__section--vert {
height: 100%;
padding: 16px 18px;
}
.picker-card .section-title {
margin-bottom: 10px;
}
.picker-card .section-caption {
font-size: 0.84rem;
}
.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;
}
.native-select-wrap {
position: relative;
}
.native-select-wrap::after {
content: 'expand_more';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-family: 'Material Icons';
font-feature-settings: 'liga';
font-style: normal;
font-weight: 400;
color: var(--studio-muted);
pointer-events: none;
font-size: 1.2rem;
line-height: 1;
}
.native-select-label {
display: block;
margin-bottom: 6px;
color: var(--studio-muted);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
}
.native-select {
width: 100%;
appearance: none;
-webkit-appearance: none;
border: 1px solid rgba(25, 54, 61, 0.14);
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
color: var(--studio-ink);
padding: 14px 42px 14px 14px;
font: inherit;
font-size: 0.95rem;
line-height: 1.35;
box-shadow: inset 0 1px 1px rgba(20, 30, 36, 0.04);
}
.native-select:focus {
outline: 2px solid rgba(25, 54, 61, 0.18);
border-color: rgba(25, 54, 61, 0.22);
}
.fixture-select .q-field__control {
min-height: 46px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
}
.fixture-select.q-field--outlined .q-field__control::before {
border-color: rgba(25, 54, 61, 0.14);
}
.fixture-select.q-field--outlined .q-field__control:hover::before {
border-color: rgba(25, 54, 61, 0.22);
}
.fixture-select .q-field__native,
.fixture-select .q-field__input,
.fixture-select .q-field__marginal {
color: var(--studio-ink);
}
.fixture-select .q-field__append {
color: var(--studio-muted);
}
.fixture-select-icon {
font-family: 'Material Icons';
font-feature-settings: 'liga';
font-style: normal;
font-weight: 400;
font-size: 1.2rem;
line-height: 1;
color: var(--studio-muted);
user-select: none;
pointer-events: none;
}
.picker-action-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.picker-select {
flex: 1 1 auto;
min-width: 0;
}
.editor-dialog-card {
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
border-radius: 24px;
}
.dialog-header {
position: relative;
padding-right: 96px;
}
.dialog-close-btn {
position: absolute;
top: 16px;
right: 16px;
}
.native-input,
.native-textarea {
width: 100%;
border: 1px solid rgba(25, 54, 61, 0.14);
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
color: var(--studio-ink);
padding: 14px;
font: inherit;
font-size: 0.95rem;
line-height: 1.35;
box-shadow: inset 0 1px 1px rgba(20, 30, 36, 0.04);
}
.native-input {
min-height: 46px;
padding: 10px 14px;
}
.native-textarea {
min-height: 92px;
padding: 14px;
resize: vertical;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 0.84rem;
}
.native-input:focus,
.native-textarea:focus {
outline: 2px solid rgba(25, 54, 61, 0.18);
border-color: rgba(25, 54, 61, 0.22);
}
.field-label {
display: block;
margin-bottom: 8px;
color: var(--studio-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.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(2, 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);
}
.test-json-editor {
min-height: 220px;
}
.test-mock-editor {
min-height: 240px;
}
.test-mock-binding {
margin-top: 12px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(255, 255, 255, 0.7);
}
.test-mock-binding:first-of-type {
margin-top: 14px;
}
.test-mock-binding-header {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.test-mock-binding-body {
flex: 1 1 auto;
min-width: 0;
}
.test-mock-item-list {
margin-top: 12px;
display: grid;
gap: 12px;
}
.test-mock-item-card {
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(25, 54, 61, 0.03);
}
.test-mock-item-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.test-mock-item-top-row {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end;
}
.test-mock-item-action-row {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
}
.test-mock-item-pair-row {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.test-mock-item-pair-row--top {
align-items: start;
}
.test-mock-item-skip {
display: flex;
align-items: center;
min-height: 46px;
}
.test-mock-item-columns {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.test-mock-item-column {
display: grid;
gap: 12px;
align-content: start;
}
.test-mock-item-column + .test-mock-item-column {
padding-left: 16px;
border-left: 1px solid rgba(25, 54, 61, 0.12);
}
.test-mock-item-json {
min-height: 160px;
}
.request-detail-tabs {
margin-top: 12px;
border-radius: 8px;
background: rgba(22, 32, 40, 0.04);
padding: 4px;
}
.request-detail-tabs .q-tab {
flex: 1 1 0;
min-width: 0;
min-height: 42px;
border-radius: 4px !important;
color: var(--q-primary);
text-align: center;
}
.request-detail-tabs .q-tab__content {
align-items: center;
justify-content: center;
width: 100%;
}
.request-detail-tabs .q-tab--active {
background: var(--q-primary);
color: white;
}
.request-detail-tabs .q-tab--active,
.request-detail-tabs .q-tab--active .q-tab__label {
color: white !important;
}
.request-detail-panels {
margin-top: 10px;
border: 1px solid rgba(25, 54, 61, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.7);
box-shadow: none;
}
.request-detail-panel {
padding: 12px;
}
.test-mock-item-full {
grid-column: 1 / -1;
}
.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;
}
.settings-editor {
display: grid;
gap: 12px;
}
.settings-table {
border: 1px solid rgba(25, 54, 61, 0.08);
border-radius: 20px;
background: rgba(255, 255, 255, 0.78);
overflow: hidden;
}
.settings-table-grid {
display: grid;
gap: 12px;
grid-template-columns:
minmax(180px, 1fr) 140px minmax(220px, 1.3fr)
auto;
align-items: center;
}
.settings-table-header {
padding: 12px 14px;
border-bottom: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(25, 54, 61, 0.05);
}
.settings-table-heading {
color: var(--studio-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.settings-table-row {
padding: 14px;
border-bottom: 1px solid rgba(25, 54, 61, 0.08);
}
.settings-table-row:last-child {
border-bottom: 0;
}
.settings-table-actions {
display: flex;
justify-content: flex-end;
}
.settings-table-note {
margin-top: 10px;
}
.settings-row {
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(255, 255, 255, 0.78);
}
.settings-row-grid {
display: grid;
gap: 12px;
grid-template-columns:
minmax(180px, 1fr) 140px minmax(220px, 1.3fr)
auto;
align-items: end;
}
.settings-row-actions {
display: flex;
justify-content: flex-end;
}
.function-mocks-table-wrap {
overflow-x: auto;
}
.function-mocks-table {
min-width: 1320px;
border: 1px solid rgba(25, 54, 61, 0.08);
border-radius: 20px;
background: rgba(255, 255, 255, 0.78);
overflow: hidden;
}
.function-mocks-table-grid {
display: grid;
gap: 12px;
align-items: stretch;
}
.function-mocks-table-grid--rest {
grid-template-columns:
minmax(150px, 1fr)
minmax(240px, 1.3fr)
minmax(220px, 1.2fr)
minmax(180px, 1fr);
}
.function-mocks-table-grid--rpc {
grid-template-columns:
minmax(150px, 1fr)
minmax(280px, 1.35fr)
minmax(320px, 1.65fr);
}
.function-mocks-header {
padding: 12px 14px;
border-bottom: 1px solid rgba(25, 54, 61, 0.08);
background: rgba(25, 54, 61, 0.05);
}
.function-mocks-heading {
color: var(--studio-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.function-mocks-row {
padding: 14px;
border-bottom: 1px solid rgba(25, 54, 61, 0.08);
}
.function-mocks-row:last-child {
border-bottom: 0;
}
.function-mock-primary-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.function-mock-cell-stack {
display: grid;
gap: 12px;
align-content: start;
}
.function-mock-subfield {
display: grid;
gap: 6px;
align-content: start;
}
.function-mock-cell-stack--rpc {
grid-auto-rows: min-content;
align-self: start;
}
.function-mock-json-cell {
display: flex;
flex-direction: column;
height: 100%;
}
.function-mock-json-cell .native-textarea {
flex: 1 1 auto;
min-height: 248px;
}
.function-mocks-note {
margin-top: 10px;
}
.settings-hint {
color: var(--studio-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.settings-error {
color: var(--q-negative);
font-size: 0.82rem;
line-height: 1.45;
}
.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;
}
.test-expander > summary {
list-style: none;
}
.test-expander > summary::-webkit-details-marker {
display: none;
}
.wallet-expanded-header {
padding: 16px 18px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
background: rgba(25, 54, 61, 0.05);
}
.test-summary {
cursor: pointer;
user-select: none;
}
.test-summary:hover {
background: rgba(25, 54, 61, 0.08);
}
.test-summary-copy {
display: flex;
gap: 12px;
min-width: 0;
align-items: center;
}
.test-summary-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: rgba(25, 54, 61, 0.08);
color: var(--studio-muted);
font-size: 1rem;
line-height: 1;
flex: 0 0 auto;
}
.test-summary-arrow::before {
content: '▸';
}
.test-expander[open] .test-summary-arrow {
background: rgba(25, 54, 61, 0.14);
}
.test-expander[open] .test-summary-arrow::before {
content: '▾';
}
.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;
}
.function-tabs {
margin-bottom: 14px;
border-radius: 8px;
background: rgba(22, 32, 40, 0.04);
padding: 4px;
}
.function-tabs .q-tab {
flex: 1 1 0;
min-width: 0;
justify-content: center;
min-height: 48px;
border-radius: 4px !important;
color: var(--q-primary);
text-align: center;
}
.function-tabs .q-tab__content {
align-items: center;
justify-content: center;
width: 100%;
}
.function-tabs .q-tab--active {
background: var(--q-primary);
color: white;
}
.function-tabs .q-tab--active,
.function-tabs .q-tab--active .q-tab__label,
.function-tabs .q-tab--active .text-weight-bold,
.function-tabs .q-tab--active .text-caption {
color: white !important;
}
.function-content {
min-width: 0;
}
.test-type-layout {
display: grid;
gap: 18px;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
align-items: start;
}
.test-type-tabs {
border-radius: 8px;
background: rgba(22, 32, 40, 0.04);
padding: 4px;
}
.test-type-tabs .q-tab {
justify-content: flex-start;
min-height: 48px;
border-radius: 4px !important;
color: var(--q-primary);
text-align: left;
}
.test-type-tabs .q-tab__content {
align-items: flex-start;
justify-content: center;
width: 100%;
}
.test-type-tabs .q-tab--active {
background: var(--q-primary);
color: white;
}
.test-type-tabs .q-tab--active,
.test-type-tabs .q-tab--active .q-tab__label,
.test-type-tabs .q-tab--active .text-weight-bold {
color: white !important;
}
.test-type-panels {
background: transparent;
box-shadow: none;
min-width: 0;
}
.test-type-panel {
padding: 0;
}
.test-case-stack {
display: grid;
gap: 14px;
}
.test-case-section {
display: grid;
gap: 14px;
}
.test-case-section + .test-case-section {
padding-top: 18px;
border-top: 1px solid rgba(25, 54, 61, 0.08);
}
.test-case-header {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.test-case-title {
margin: 0;
font-size: 1rem;
line-height: 1.25;
font-weight: 800;
}
.test-case-subtitle {
margin-top: 4px;
color: var(--studio-muted);
font-size: 0.84rem;
line-height: 1.45;
}
.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-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: 68vh;
resize: vertical;
padding: 16px;
border-radius: 12px;
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: 1080px) {
.hero-row {
flex-direction: column;
}
.hero-actions {
justify-content: flex-start;
}
.stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.primary-row {
grid-template-columns: 1fr;
}
.test-type-layout {
grid-template-columns: 1fr;
}
.mock-source-grid,
.test-mock-item-action-row,
.test-mock-item-pair-row,
.test-detail-grid,
.test-mock-item-top-row,
.test-mock-item-grid,
.test-mock-item-columns,
.settings-row-grid,
.settings-table-grid {
grid-template-columns: 1fr;
}
.test-mock-item-column + .test-mock-item-column {
padding-left: 0;
border-left: 0;
padding-top: 12px;
border-top: 1px solid rgba(25, 54, 61, 0.12);
}
.picker-action-row,
.test-mock-binding-header {
flex-direction: column;
}
.settings-table-header {
display: none;
}
.settings-row-actions,
.settings-table-actions {
justify-content: flex-start;
}
}
@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">Test matrix for wallet funding sources</h1>
<p><code>{{ currentLoadedFileName }}</code></p>
</div>
<div class="hero-actions">
<q-btn
color="primary"
unelevated
label="Load File"
@click="openFilePicker"
></q-btn>
<q-btn
color="secondary"
outline
label="Reload Current"
@click="loadRelative(currentDataset.key)"
></q-btn>
<q-btn
color="accent"
outline
label="Save"
@click="saveDataset(currentDataset.key)"
></q-btn>
<q-btn
color="accent"
outline
label="Open JSON Editor"
@click="editorDialog = true"
></q-btn>
</div>
</div>
<div class="stats-row" v-if="currentDataset.analysis">
<div class="stat-card">
<div class="stat-label">Funding Sources</div>
<div class="stat-value-row">
<div class="stat-value">
{{ currentDataset.analysis.summary.fundingSources }}
</div>
<q-btn
color="dark"
unelevated
label="Settings"
class="stat-card-action"
@click="fundingSourceSettingsDialog = true"
></q-btn>
</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>
<q-banner
v-if="currentDataset.loadError"
dense
rounded
inline-actions
class="bg-orange-1 text-orange-10 q-mt-md"
>
{{ currentDataset.loadError }}
</q-banner>
</header>
<main class="workspace-grid">
<q-card
flat
class="studio-card"
v-if="currentTestFundingSource && currentDataset.analysis"
>
<q-card-section>
<q-tabs
v-if="functionOptions.length"
v-model="selectedFunctionByDataset[selectedDatasetKey]"
dense
active-color="white"
indicator-color="transparent"
align="justify"
class="function-tabs text-primary"
>
<q-tab
v-for="fn in currentDataset.analysis.functions"
:key="fn.name"
:name="fn.name"
>
<div class="text-left">
<div class="text-weight-bold">{{ fn.name }}</div>
<div class="text-caption">{{ fn.tests.length }} tests</div>
</div>
</q-tab>
</q-tabs>
<div v-if="functionOptions.length">
<div class="function-content">
<div
v-if="currentFunctionTestTypeGroups.length"
class="test-type-layout"
>
<q-tabs
v-model="currentSelectedTestTypeKey"
vertical
dense
active-color="white"
indicator-color="transparent"
class="test-type-tabs text-primary"
>
<q-tab
v-for="group in currentFunctionTestTypeGroups"
:key="group.key"
:name="group.key"
>
<div class="text-weight-bold">{{ group.label }}</div>
</q-tab>
</q-tabs>
<q-tab-panels
v-if="currentSelectedTestTypeGroup"
v-model="currentSelectedTestTypeKey"
class="test-type-panels"
>
<q-tab-panel
v-for="group in currentFunctionTestTypeGroups"
:key="group.key"
:name="group.key"
class="test-type-panel"
>
<div class="test-case-stack">
<div
v-for="(testEntry, entryIndex) in group.entries"
:key="`${group.key}-${testEntry.index}-${entryIndex}`"
class="test-case-section"
>
<div
class="test-case-header"
v-if="group.entries.length > 1"
>
<div>
<h3 class="test-case-title">
{{ `${group.label} ${entryIndex + 1}` }}
</h3>
<div class="test-case-subtitle">
Raw test {{ testEntry.index + 1 }}
</div>
</div>
</div>
<div v-if="testEntry.note" class="empty-state">
{{ testEntry.note }}
</div>
<div class="test-detail-grid">
<div class="detail-panel">
<label
class="field-label"
:for="`test-call-params-${group.key}-${testEntry.index}-${entryIndex}`"
>
Call Params
</label>
<textarea
:id="`test-call-params-${group.key}-${testEntry.index}-${entryIndex}`"
v-model="testEntry.callParamsText"
class="native-textarea test-json-editor"
spellcheck="false"
@input="persistTestCallParams(testEntry, { silent: true })"
></textarea>
<div
v-if="testEntry.callParamsError"
class="settings-error q-mt-sm"
>
{{ testEntry.callParamsError }}
</div>
</div>
<div class="detail-panel">
<label
class="field-label"
:for="`test-expected-${group.key}-${testEntry.index}-${entryIndex}`"
>
{{ testEntry.expectedLabel }}
</label>
<textarea
:id="`test-expected-${group.key}-${testEntry.index}-${entryIndex}`"
v-model="testEntry.expectedText"
class="native-textarea test-json-editor"
spellcheck="false"
@input="persistTestExpected(testEntry, { silent: true })"
></textarea>
<div
v-if="testEntry.expectedError"
class="settings-error q-mt-sm"
>
{{ testEntry.expectedError }}
</div>
</div>
</div>
<div class="q-mt-lg">
<label
class="field-label"
:for="`test-funding-source-${group.key}-${testEntry.index}-${entryIndex}`"
>
Funding Source
</label>
<div class="picker-action-row">
<q-select
:id="`test-funding-source-${group.key}-${testEntry.index}-${entryIndex}`"
v-model="selectedTestFundingSourceByDataset[selectedDatasetKey]"
:options="fundingSourceOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select picker-select"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
<q-btn
color="primary"
outline
:label="`Funding Source Mocks (${currentFunctionMockRows.length})`"
:disable="!currentFunction || !currentTestFundingSource"
@click="functionMocksDialog = true"
></q-btn>
</div>
</div>
<div
class="mock-list q-mt-lg"
v-if="testEntry.rawMockSets.length"
>
<div
v-for="(mockSet, mockSetIndex) in testEntry.rawMockSets"
:key="mockSet.path"
>
<div
v-if="mockSet.metaFields.description !== undefined"
class="q-mb-md"
>
<label
class="field-label"
:for="`test-mock-description-${group.key}-${testEntry.index}-${mockSetIndex}`"
>
Description
</label>
<input
:id="`test-mock-description-${group.key}-${testEntry.index}-${mockSetIndex}`"
v-model="mockSet.metaFields.description"
class="native-input"
type="text"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
/>
</div>
<div
class="test-mock-binding"
:key="`${mockSet.path}-binding-${activeTestMockBindingName(mockSet)}`"
>
<div class="test-mock-binding-header">
<div class="test-mock-binding-body">
<label
class="field-label"
:for="`test-mock-binding-${group.key}-${testEntry.index}-${mockSetIndex}`"
>
Function Mock
</label>
<q-select
:id="`test-mock-binding-${group.key}-${testEntry.index}-${mockSetIndex}`"
:model-value="activeTestMockBindingName(mockSet)"
:options="testMockBindingOptions(mockSet)"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleTestMockBindingSelection(testEntry, mockSet, $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
</div>
</div>
<div
class="test-mock-item-list"
v-if="activeTestMockBinding(mockSet) && activeTestMockBinding(mockSet).items.length"
>
<div
v-for="(item, itemIndex) in activeTestMockBinding(mockSet).items"
:key="`${mockSet.path}-binding-${activeTestMockBindingName(mockSet)}-item-${itemIndex}`"
class="test-mock-item-card"
>
<template
v-if="activeTestMockBindingMode(mockSet) === 'rest'"
>
<div class="test-mock-item-top-row">
<div>
<label
class="field-label"
:for="`test-mock-item-description-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Description
</label>
<input
:id="`test-mock-item-description-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.descriptionText"
class="native-input"
type="text"
placeholder="Optional label"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
/>
</div>
<div class="test-mock-item-skip">
<q-checkbox
:model-value="testMockItemSkip(item)"
dense
color="warning"
keep-color
label="Skip this mock definition"
@update:model-value="setTestMockItemSkip(testEntry, mockSet, item, $event)"
></q-checkbox>
</div>
<q-btn
color="negative"
outline
label="Remove"
@click="removeTestMockBindingItem(testEntry, mockSet, activeTestMockBinding(mockSet), itemIndex)"
></q-btn>
</div>
<div
class="test-mock-item-columns q-mt-md"
>
<div class="test-mock-item-column">
<div>
<label
class="field-label"
:for="`test-mock-item-request-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Request Type
</label>
<q-select
:id="`test-mock-item-request-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
:model-value="item.requestType"
:options="restTestMockRequestTypeOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleTestMockSelectChange(testEntry, mockSet, item, 'requestType', $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
</div>
<div>
<label
class="field-label"
:for="`test-mock-item-request-body-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Request Body
</label>
<textarea
:id="`test-mock-item-request-body-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.requestBodyText"
class="native-textarea test-mock-item-json"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
></textarea>
<div
v-if="item.requestBodyError"
class="settings-error q-mt-sm"
>
{{ item.requestBodyError }}
</div>
</div>
</div>
<div class="test-mock-item-column">
<div>
<label
class="field-label"
:for="`test-mock-item-response-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Response Type
</label>
<q-select
:id="`test-mock-item-response-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
:model-value="item.responseType"
:options="restTestMockResponseTypeOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleTestMockSelectChange(testEntry, mockSet, item, 'responseType', $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
</div>
<div>
<label
class="field-label"
:for="`test-mock-item-response-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Response
</label>
<textarea
:id="`test-mock-item-response-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.responseText"
class="native-textarea test-mock-item-json"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
></textarea>
<div
v-if="item.responseError"
class="settings-error q-mt-sm"
>
{{ item.responseError }}
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="test-mock-item-action-row">
<div>
<label
class="field-label"
:for="`test-mock-item-description-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Description
</label>
<input
:id="`test-mock-item-description-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.descriptionText"
class="native-input"
type="text"
placeholder="Optional label"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
/>
<div class="settings-hint q-mt-xs">
Test label for this RPC mock
variant.
</div>
</div>
<q-btn
color="negative"
outline
label="Remove"
@click="removeTestMockBindingItem(testEntry, mockSet, activeTestMockBinding(mockSet), itemIndex)"
></q-btn>
</div>
<div
class="test-mock-item-pair-row test-mock-item-pair-row--top"
>
<div>
<label
class="field-label"
:for="`test-mock-item-rpc-call-name-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
RPC Call
</label>
<input
:id="`test-mock-item-rpc-call-name-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.rpcCallName"
class="native-input"
type="text"
placeholder="some_rpc_call"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
/>
<div class="settings-hint q-mt-xs">
Mocked RPC method name used by the
wallet code.
</div>
</div>
<div>
<label
class="field-label"
:for="`test-mock-item-rpc-description-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
RPC Call Description
</label>
<input
:id="`test-mock-item-rpc-description-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.rpcDescriptionText"
class="native-input"
type="text"
placeholder="Optional label"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
/>
<div class="settings-hint q-mt-xs">
Optional label for the nested RPC
call.
</div>
</div>
</div>
<div
class="test-mock-item-pair-row test-mock-item-pair-row--top"
>
<div>
<label
class="field-label"
:for="`test-mock-item-request-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Request Type
</label>
<q-select
:id="`test-mock-item-request-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
:model-value="item.requestType"
:options="rpcTestMockRequestTypeOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleTestMockSelectChange(testEntry, mockSet, item, 'requestType', $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
<div class="settings-hint q-mt-xs">
How the mocked RPC callable is
invoked.
</div>
<q-tabs
:model-value="rpcRequestDetailTab(testEntry, mockSet, itemIndex)"
align="justify"
dense
indicator-color="transparent"
no-caps
class="request-detail-tabs text-primary"
@update:model-value="setRpcRequestDetailTab(testEntry, mockSet, itemIndex, $event)"
>
<q-tab
name="class"
label="Request Class"
></q-tab>
<q-tab
name="args"
label="Request Args"
></q-tab>
<q-tab
name="kwargs"
label="Request Kwargs"
></q-tab>
</q-tabs>
<q-tab-panels
:model-value="rpcRequestDetailTab(testEntry, mockSet, itemIndex)"
class="request-detail-panels"
>
<q-tab-panel
name="class"
class="request-detail-panel"
>
<label
class="field-label"
:for="`test-mock-item-request-class-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Request Class
</label>
<input
:id="`test-mock-item-request-class-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.requestClassName"
class="native-input"
type="text"
placeholder="optional.import.path.ClassName"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
/>
<div
class="settings-hint q-mt-xs"
>
Optional request class used by
the RPC test harness.
</div>
</q-tab-panel>
<q-tab-panel
name="args"
class="request-detail-panel"
>
<label
class="field-label"
:for="`test-mock-item-request-args-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Request Args
</label>
<textarea
:id="`test-mock-item-request-args-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.requestArgsText"
class="native-textarea test-mock-item-json"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
></textarea>
<div
class="settings-hint q-mt-xs"
>
JSON array of positional
arguments passed into the RPC
call.
</div>
<div
v-if="item.requestArgsError"
class="settings-error q-mt-sm"
>
{{ item.requestArgsError }}
</div>
</q-tab-panel>
<q-tab-panel
name="kwargs"
class="request-detail-panel"
>
<label
class="field-label"
:for="`test-mock-item-request-kwargs-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Request Kwargs
</label>
<textarea
:id="`test-mock-item-request-kwargs-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.requestKwargsText"
class="native-textarea test-mock-item-json"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
></textarea>
<div
class="settings-hint q-mt-xs"
>
JSON object of keyword
arguments passed into the RPC
call.
</div>
<div
v-if="item.requestKwargsError"
class="settings-error q-mt-sm"
>
{{ item.requestKwargsError }}
</div>
</q-tab-panel>
</q-tab-panels>
</div>
<div>
<label
class="field-label"
:for="`test-mock-item-rpc-response-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Response Type
</label>
<q-select
:id="`test-mock-item-rpc-response-type-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
:model-value="item.rpcResponseType"
:options="rpcTestMockResponseTypeOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleTestMockSelectChange(testEntry, mockSet, item, 'rpcResponseType', $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
<div class="settings-hint q-mt-xs">
Return mode expected by the test
harness.
</div>
<div class="q-mt-md">
<label
class="field-label"
:for="`test-mock-item-response-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
>
Response Payload
</label>
<textarea
:id="`test-mock-item-response-${group.key}-${testEntry.index}-${mockSetIndex}-${activeTestMockBindingName(mockSet)}-${itemIndex}`"
v-model="item.responseText"
class="native-textarea test-mock-item-json"
spellcheck="false"
@input="persistTestMockSet(testEntry, mockSet, { silent: true })"
></textarea>
<div
class="settings-hint q-mt-xs"
>
Business data returned by this
mocked RPC call.
</div>
<div
v-if="item.responseError"
class="settings-error q-mt-sm"
>
{{ item.responseError }}
</div>
</div>
</div>
</div>
</template>
<div
v-if="item.error"
class="settings-error q-mt-sm"
>
{{ item.error }}
</div>
</div>
</div>
<div v-else class="empty-state q-mt-md">
No objects are defined in this mock array
yet.
</div>
<div class="pill-row q-mt-md">
<q-btn
color="primary"
outline
label="Add Function Mock"
:disable="!activeTestMockBinding(mockSet)"
@click="addTestMockBindingItem(testEntry, mockSet, activeTestMockBinding(mockSet))"
></q-btn>
</div>
</div>
<div
v-if="mockSet.error"
class="settings-error q-mt-sm"
>
{{ mockSet.error }}
</div>
</div>
</div>
<div v-else class="empty-state q-mt-lg">
No wallet-specific mock entry is defined for
<code>{{ currentTestFundingSource.name }}</code>
in this test.
</div>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</div>
<div v-else-if="currentFunction" class="empty-state">
No tests are defined for this function.
</div>
</div>
</div>
<div v-else class="empty-state">
No functions are defined for this fixture file.
</div>
</q-card-section>
</q-card>
</main>
</div>
<q-dialog v-model="fundingSourceSettingsDialog" full-width position="top">
<q-card class="editor-dialog-card">
<q-card-section class="q-pa-lg dialog-header">
<div>
<h2 class="q-my-none">Funding Source Settings</h2>
<div class="section-caption">
Edit the settings for the selected funding source. Changes
update the active fixture JSON immediately.
</div>
</div>
<div class="pill-row q-mt-md">
<q-chip
v-if="currentFundingSource"
dense
square
color="primary"
text-color="white"
>
{{ currentFundingSourceSettingRows.length }} rows
</q-chip>
</div>
<q-btn
flat
color="primary"
label="Close"
class="dialog-close-btn"
v-close-popup
></q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pa-lg">
<div v-if="fundingSourceOptions.length">
<label
class="native-select-label"
for="dialog-funding-source-select"
>
Funding source
</label>
<q-select
id="dialog-funding-source-select"
v-model="selectedFundingSourceByDataset[selectedDatasetKey]"
:options="fundingSourceOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
>
<template v-slot:append>
<span class="fixture-select-icon" aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
<div v-if="currentFundingSource" class="q-mt-md q-mb-lg">
<q-checkbox
v-model="currentFundingSourceSkip"
dense
color="warning"
keep-color
label="Skip this funding source"
></q-checkbox>
</div>
</div>
<div v-if="currentFundingSource">
<div
class="settings-editor"
v-if="currentFundingSourceSettingRows.length"
>
<div class="settings-table">
<div class="settings-table-header settings-table-grid">
<div class="settings-table-heading">Key</div>
<div class="settings-table-heading">Type</div>
<div class="settings-table-heading">Value</div>
<div class="settings-table-heading">Actions</div>
</div>
<div
v-for="(row, rowIndex) in currentFundingSourceSettingRows"
:key="row.id"
class="settings-table-row"
>
<div class="settings-table-grid">
<div>
<input
:id="`setting-key-${row.id}`"
v-model="row.key"
class="native-input"
type="text"
placeholder="setting_key"
spellcheck="false"
@input="persistFundingSourceSettings({ silent: true })"
/>
</div>
<div class="native-select-wrap">
<select
:id="`setting-type-${row.id}`"
v-model="row.type"
class="native-select"
@change="handleFundingSourceSettingTypeChange(row)"
>
<option
v-for="option in settingTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<div>
<div
v-if="row.type === 'boolean'"
class="native-select-wrap"
>
<select
:id="`setting-value-${row.id}`"
v-model="row.valueText"
class="native-select"
@change="persistFundingSourceSettings()"
>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<textarea
v-else-if="row.type === 'json'"
:id="`setting-value-${row.id}`"
v-model="row.valueText"
class="native-textarea"
spellcheck="false"
@input="persistFundingSourceSettings({ silent: true })"
></textarea>
<input
v-else
:id="`setting-value-${row.id}`"
v-model="row.valueText"
class="native-input"
type="text"
spellcheck="false"
@input="persistFundingSourceSettings({ silent: true })"
/>
</div>
<div class="settings-table-actions">
<q-btn
color="negative"
outline
label="Remove"
@click="removeFundingSourceSettingRow(rowIndex)"
></q-btn>
</div>
</div>
<div
v-if="row.error"
class="settings-error settings-table-note"
>
{{ row.error }}
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
No settings are defined yet for this funding source.
</div>
<div class="pill-row q-mt-md">
<q-btn
color="primary"
outline
label="Add Setting"
@click="addFundingSourceSettingRow()"
></q-btn>
</div>
</div>
<div v-else class="empty-state">
No funding sources are defined for this fixture file.
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-actions align="right" class="q-pa-lg">
<q-btn
color="primary"
unelevated
label="Done"
v-close-popup
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="functionMocksDialog" full-width position="top">
<q-card class="editor-dialog-card">
<q-card-section class="q-pa-lg dialog-header">
<div>
<h2 class="q-my-none">Function Mocks</h2>
<div class="section-caption">
Reusable mocks for the selected function and funding source.
</div>
<div v-if="currentFunctionBaseMockPath" class="section-caption">
Changes update
<code>{{ currentFunctionBaseMockPath }}</code>
immediately.
</div>
</div>
<div class="pill-row q-mt-md">
<q-chip
v-if="currentFunction && currentTestFundingSource"
dense
square
color="primary"
text-color="white"
>
{{ currentFunctionMockRows.length }} mocks
</q-chip>
</div>
<q-btn
flat
color="primary"
label="Close"
class="dialog-close-btn"
v-close-popup
></q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pa-lg">
<div v-if="currentFunction && currentTestFundingSource">
<div
class="settings-editor"
v-if="currentFunctionMockRows.length"
>
<div class="function-mocks-table-wrap">
<div class="function-mocks-table">
<div
class="function-mocks-header function-mocks-table-grid"
:class="`function-mocks-table-grid--${currentFunctionMockEditorMode}`"
>
<div class="function-mocks-heading">Mock Name</div>
<div class="function-mocks-heading">Method</div>
<template v-if="currentFunctionMockEditorMode === 'rest'">
<div class="function-mocks-heading">Headers JSON</div>
<div class="function-mocks-heading">
Query Params JSON
</div>
</template>
<template v-else>
<div class="function-mocks-heading">Response</div>
</template>
</div>
<div
v-for="(mockRow, rowIndex) in currentFunctionMockRows"
:key="mockRow.id"
class="function-mocks-row"
>
<div
class="function-mocks-table-grid"
:class="`function-mocks-table-grid--${functionMockRowMode(mockRow)}`"
>
<div>
<input
:id="`function-mock-name-${mockRow.id}`"
v-model="mockRow.name"
class="native-input"
type="text"
placeholder="mock_name"
spellcheck="false"
@input="persistFunctionMocks({ silent: true })"
/>
<div class="settings-hint q-mt-sm">
Mock Name: Stable mock identifier used inside this
function's fixture map.
</div>
<div class="function-mock-primary-actions">
<q-btn
color="negative"
outline
label="Remove Mock"
@click="removeFunctionMockRow(rowIndex)"
></q-btn>
</div>
</div>
<div
class="function-mock-cell-stack"
:class="{ 'function-mock-cell-stack--rpc': mockRow.requestType === 'function' }"
>
<div class="function-mock-subfield">
<q-select
:id="`function-mock-request-type-${mockRow.id}`"
:model-value="mockRow.requestType"
:options="functionMockRequestTypeOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleFunctionMockRequestTypeChange(mockRow, $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
<div class="settings-hint">
Request Type: Choose <code>REST</code> for HTTP
mocks or <code>RPC</code> for Python patch mocks.
</div>
</div>
<div class="function-mock-subfield">
<input
:id="`function-mock-method-${mockRow.id}`"
v-model="mockRow.method"
class="native-input"
type="text"
:placeholder="mockRow.requestType === 'function' ? 'package.module.symbol' : 'GET'"
spellcheck="false"
@input="persistFunctionMocks({ silent: true })"
/>
<div class="settings-hint">
{{ functionMockMethodHint(mockRow) }}
</div>
</div>
<div
v-if="mockRow.requestType !== 'function'"
class="function-mock-subfield"
>
<input
:id="`function-mock-uri-${mockRow.id}`"
v-model="mockRow.uri"
class="native-input"
type="text"
placeholder="/path"
spellcheck="false"
@input="persistFunctionMocks({ silent: true })"
/>
<div class="settings-hint">
Path: Request path or URI matcher for REST mocks.
</div>
</div>
</div>
<template
v-if="functionMockRowMode(mockRow) === 'rest'"
>
<div class="function-mock-json-cell">
<textarea
:id="`function-mock-headers-${mockRow.id}`"
v-model="mockRow.headersText"
class="native-textarea"
placeholder='{"Authorization":"Bearer ..."}'
spellcheck="false"
@input="persistFunctionMocks({ silent: true })"
></textarea>
<div class="settings-hint q-mt-sm">
Headers JSON: JSON object of request headers that
must match.
</div>
</div>
<div class="function-mock-json-cell">
<textarea
:id="`function-mock-query-${mockRow.id}`"
v-model="mockRow.queryParamsText"
class="native-textarea"
placeholder='{"foo":"bar"}'
spellcheck="false"
@input="persistFunctionMocks({ silent: true })"
></textarea>
<div class="settings-hint q-mt-sm">
Query Params JSON: JSON object of expected
query-string parameters.
</div>
</div>
</template>
<template v-else>
<div class="function-mock-cell-stack">
<div class="function-mock-subfield">
<q-select
:id="`function-mock-response-type-${mockRow.id}`"
:model-value="mockRow.responseType"
:options="functionMockRpcResponseTypeOptions"
emit-value
map-options
outlined
dense
options-dense
behavior="menu"
hide-dropdown-icon
class="fixture-select"
@update:model-value="handleFunctionMockResponseTypeChange(mockRow, $event)"
>
<template v-slot:append>
<span
class="fixture-select-icon"
aria-hidden="true"
>expand_more</span
>
</template>
</q-select>
<div class="settings-hint">
Response Type: Optional RPC return mode such as
<code>data</code>, <code>function</code>,
<code>async-function</code>, <code>json</code>,
or <code>exception</code>.
</div>
</div>
<div class="function-mock-subfield">
<textarea
:id="`function-mock-response-${mockRow.id}`"
v-model="mockRow.responseText"
class="native-textarea"
placeholder='{"method_name":{"request_type":"function","response_type":"json","response":{}}}'
spellcheck="false"
@input="persistFunctionMocks({ silent: true })"
></textarea>
<div class="settings-hint">
Response: RPC mock object returned by the
patched function.
</div>
</div>
</div>
</template>
</div>
<div
v-if="mockRow.error"
class="settings-error function-mocks-note"
>
{{ mockRow.error }}
</div>
<div v-else class="settings-hint function-mocks-note">
Leave unused fields blank. JSON columns must contain
valid JSON objects or values.
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
No function-level mocks are defined yet for
<code>{{ currentTestFundingSource.name }}</code>
in
<code>{{ currentFunction.name }}</code>.
</div>
<div class="pill-row q-mt-md">
<q-btn
color="primary"
outline
label="Add Mock"
@click="addFunctionMockRow()"
></q-btn>
</div>
</div>
<div v-else class="empty-state">
Select a funding source and function first.
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-actions align="right" class="q-pa-lg">
<q-btn
color="primary"
unelevated
label="Done"
v-close-popup
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="editorDialog" maximized>
<q-card>
<q-card-section class="row items-start justify-between q-pa-lg">
<div>
<h2 class="q-my-none">Raw JSON Editor</h2>
<div class="section-caption">
Edit the current fixture file directly, then apply, save, or
download the changes.
</div>
</div>
<q-btn flat color="primary" label="Close" v-close-popup></q-btn>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pa-lg">
<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="Reset"
@click="resetEditor(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"
@input="syncEditorFromRawText(selectedDatasetKey, { silent: true })"
></textarea>
</q-card-section>
<q-separator></q-separator>
<q-card-actions align="right" class="q-pa-lg">
<q-btn
color="primary"
unelevated
label="Done"
v-close-popup
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<input
ref="loadFileInput"
class="hidden-input"
type="file"
accept=".json,application/json"
@change="handleSingleFileSelection($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 STORAGE_PREFIX = 'lnbits-wallet-fixture-studio'
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 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
}
}
function createTestMockItemDraftState(item = {}, mode = 'rest') {
const source = isPlainObject(item) ? item : {}
if (mode === 'rpc') {
const responseMap = isPlainObject(source.response)
? source.response
: {}
const [rpcCallName, rpcCallValue] = Object.entries(
responseMap
)[0] || ['', {}]
const rpcCall = isPlainObject(rpcCallValue) ? rpcCallValue : {}
const requestData = isPlainObject(rpcCall.request_data)
? rpcCall.request_data
: {}
const requestDataExtras = Object.fromEntries(
Object.entries(requestData).filter(
([key]) => !['args', 'kwargs', 'klass'].includes(key)
)
)
return {
descriptionText:
typeof source.description === 'string' ? source.description : '',
skip: false,
hasSkipField: false,
requestType:
typeof rpcCall.request_type === 'string'
? rpcCall.request_type
: 'function',
requestArgsText: Object.prototype.hasOwnProperty.call(
requestData,
'args'
)
? safeStringify(requestData.args)
: '',
requestArgsError: '',
requestKwargsText: Object.prototype.hasOwnProperty.call(
requestData,
'kwargs'
)
? safeStringify(requestData.kwargs)
: '',
requestKwargsError: '',
requestClassName:
typeof requestData.klass === 'string' ? requestData.klass : '',
requestDataExtras,
responseType:
typeof source.response_type === 'string'
? source.response_type
: 'data',
responseText:
rpcCall.response !== undefined
? safeStringify(rpcCall.response)
: '',
responseError: '',
rpcCallName,
rpcDescriptionText:
typeof rpcCall.description === 'string'
? rpcCall.description
: '',
rpcResponseType:
typeof rpcCall.response_type === 'string'
? rpcCall.response_type
: 'data',
error: ''
}
}
return {
descriptionText:
typeof source.description === 'string' ? source.description : '',
skip: typeof source.skip === 'boolean' ? source.skip : false,
hasSkipField: typeof source.skip === 'boolean',
requestType:
typeof source.request_type === 'string' ? source.request_type : '',
requestBodyText:
source.request_body !== undefined
? safeStringify(source.request_body)
: '',
requestBodyError: '',
responseType:
typeof source.response_type === 'string'
? source.response_type
: '',
responseText:
source.response !== undefined ? safeStringify(source.response) : '',
responseError: '',
error: ''
}
}
function inferDatasetKeyFromData(data) {
let rpcHint = false
for (const fn of Object.values(data?.functions || {})) {
for (const walletMocks of Object.values(fn?.mocks || {})) {
for (const mockDefinition of Object.values(walletMocks || {})) {
if (!isPlainObject(mockDefinition)) {
continue
}
if (Object.prototype.hasOwnProperty.call(mockDefinition, 'uri')) {
return 'rest'
}
if (
Object.prototype.hasOwnProperty.call(
mockDefinition,
'request_type'
) ||
Object.prototype.hasOwnProperty.call(
mockDefinition,
'response_type'
)
) {
rpcHint = true
}
}
}
}
return rpcHint ? 'rpc' : ''
}
function settingTypeFromValue(value) {
if (value === null) {
return 'null'
}
if (Array.isArray(value) || isPlainObject(value)) {
return 'json'
}
if (typeof value === 'boolean') {
return 'boolean'
}
if (typeof value === 'number') {
return 'number'
}
return 'string'
}
function settingValueToText(value, type = settingTypeFromValue(value)) {
if (type === 'null') {
return 'null'
}
if (type === 'boolean') {
return value === true ? 'true' : 'false'
}
if (type === 'number') {
return value === undefined || value === null ? '' : String(value)
}
if (type === 'json') {
return safeStringify(value)
}
return value === undefined || value === null ? '' : String(value)
}
const {createApp} = Vue
createApp({
components: {
JsonPanel
},
data() {
const datasets = {}
const functionMockDrafts = {}
const selectedFunctionByDataset = {}
const selectedFundingSourceByDataset = {}
const selectedRpcRequestDetailTabByScope = {}
const selectedTestMockBindingByScope = {}
const selectedTestFundingSourceByDataset = {}
const settingsDrafts = {}
DATASET_DEFS.forEach(def => {
datasets[def.key] = createDatasetState(def)
selectedFunctionByDataset[def.key] = ''
selectedFundingSourceByDataset[def.key] = ''
selectedTestFundingSourceByDataset[def.key] = ''
})
return {
datasets,
editorDialog: false,
fundingSourceSettingsDialog: false,
lastFilePickerHandle: null,
functionMockDraftRowId: 0,
functionMocksDialog: false,
functionMockDrafts,
settingsDraftRowId: 0,
settingsDrafts,
selectedDatasetKey: 'rest',
selectedRpcRequestDetailTabByScope,
selectedTestMockBindingByScope,
selectedTestFundingSourceByDataset,
selectedTestTypeByScope: {},
selectedFunctionByDataset,
selectedFundingSourceByDataset
}
},
computed: {
currentDataset() {
return this.datasets[this.selectedDatasetKey]
},
currentLoadedFileName() {
return (
this.currentDataset.fileHandle?.name ||
this.currentDataset.fileName
)
},
currentFundingSources() {
return this.currentDataset.analysis?.fundingSources || []
},
fundingSourceOptions() {
return this.currentFundingSources.map(fs => ({
label: `${fs.name} · ${fs.wallet_class}`,
value: fs.name
}))
},
settingTypeOptions() {
return [
{label: 'String', value: 'string'},
{label: 'Boolean', value: 'boolean'},
{label: 'Number', value: 'number'},
{label: 'Null', value: 'null'},
{label: 'JSON', value: 'json'}
]
},
functionMockRequestTypeOptions() {
return [
{label: 'REST', value: ''},
{label: 'RPC', value: 'function'}
]
},
functionMockRpcResponseTypeOptions() {
return [
{label: 'data', value: 'data'},
{label: 'function', value: 'function'},
{label: 'async-function', value: 'async-function'},
{label: 'json', value: 'json'},
{label: 'exception', value: 'exception'}
]
},
rpcTestMockRequestTypeOptions() {
return [
{label: 'function', value: 'function'},
{label: 'async-function', value: 'async-function'}
]
},
rpcTestMockResponseTypeOptions() {
return [
{label: 'data', value: 'data'},
{label: 'json', value: 'json'},
{label: 'exception', value: 'exception'},
{label: 'function', value: 'function'},
{label: '__aiter__', value: '__aiter__'}
]
},
restTestMockRequestTypeOptions() {
return [
{label: 'data', value: 'data'},
{label: 'json', value: 'json'}
]
},
restTestMockResponseTypeOptions() {
return [
{label: 'json', value: 'json'},
{label: 'data', value: 'data'},
{label: 'response', value: 'response'},
{label: 'stream', value: 'stream'}
]
},
currentFundingSourceName() {
return (
this.selectedFundingSourceByDataset[this.selectedDatasetKey] || ''
)
},
currentFundingSource() {
return (
this.currentFundingSources.find(
fs => fs.name === this.currentFundingSourceName
) || null
)
},
currentTestFundingSourceName() {
return (
this.selectedTestFundingSourceByDataset[
this.selectedDatasetKey
] || ''
)
},
currentTestFundingSource() {
return (
this.currentFundingSources.find(
fs => fs.name === this.currentTestFundingSourceName
) || null
)
},
currentFundingSourceSkip: {
get() {
return Boolean(this.currentFundingSource?.skip)
},
set(value) {
if (!this.currentFundingSourceName) {
return
}
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
const fundingSource =
data.funding_sources?.[this.currentFundingSourceName]
if (!fundingSource) {
return
}
fundingSource.skip = Boolean(value)
},
{
preserveSettingsDrafts: true,
preserveFunctionMockDrafts: true
}
)
}
},
currentFundingSourceSettingRows() {
if (!this.currentFundingSourceName) {
return []
}
return this.getSettingsDraftRows(
this.selectedDatasetKey,
this.currentFundingSourceName
)
},
functionOptions() {
const functions = this.currentDataset.analysis?.functions || []
return functions.map(fn => ({
label: `${fn.name} · ${fn.tests.length} tests`,
value: fn.name
}))
},
currentFunctionName() {
return this.selectedFunctionByDataset[this.selectedDatasetKey] || ''
},
currentFunction() {
return (
this.currentDataset.analysis?.functions.find(
fn => fn.name === this.currentFunctionName
) || null
)
},
editorDirty() {
return (
this.currentDataset.rawText !== this.currentDataset.snapshotText
)
},
currentFunctionBaseMockPath() {
if (!this.currentFunction || !this.currentTestFundingSource) {
return ''
}
return this.baseWalletPath(
this.currentFunction.name,
this.currentTestFundingSource.name
)
},
currentFunctionMockRows() {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return []
}
return this.getFunctionMockDraftRows(
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName
)
},
currentFunctionMockEditorMode() {
if (this.currentFunctionMockRows.length) {
return this.functionMockRowMode(this.currentFunctionMockRows[0])
}
return this.selectedDatasetKey === 'rpc' ? 'rpc' : 'rest'
},
currentFunctionMockNameOptions() {
const seen = new Set()
return this.currentFunctionMockRows
.map(row => row.name.trim())
.filter(name => {
if (!name || seen.has(name)) {
return false
}
seen.add(name)
return true
})
.map(name => ({
label: name,
value: name
}))
},
currentFunctionTestsForFundingSource() {
if (!this.currentFunction || !this.currentTestFundingSource) {
return []
}
return this.currentFunction.tests.map(test => {
const rawHasMocksBlock = Object.prototype.hasOwnProperty.call(
test.raw || {},
'mocks'
)
const hasMockEntry = Boolean(
rawHasMocksBlock &&
Object.prototype.hasOwnProperty.call(
test.raw.mocks || {},
this.currentTestFundingSource.name
)
)
const rawWalletMockValue = hasMockEntry
? test.raw.mocks[this.currentTestFundingSource.name]
: undefined
const hasExpectField = Object.prototype.hasOwnProperty.call(
test.raw || {},
'expect'
)
const expectedField = hasExpectField
? 'expect'
: Object.prototype.hasOwnProperty.call(
test.raw || {},
'expect_error'
)
? 'expect_error'
: 'expect'
const rawWalletPath = hasMockEntry
? this.testWalletPath(
this.currentFunction.name,
test.index,
this.currentTestFundingSource.name
)
: ''
const walletMockValueIsArray = Array.isArray(rawWalletMockValue)
const rawMockSets =
rawWalletMockValue === undefined
? []
: (walletMockValueIsArray
? rawWalletMockValue
: [rawWalletMockValue]
).map((mockSet, index) => ({
label: Array.isArray(rawWalletMockValue)
? `Mock set ${index + 1}`
: 'Mock set',
setIndex: index,
walletMockValueIsArray,
path: Array.isArray(rawWalletMockValue)
? `${rawWalletPath}[${index}]`
: rawWalletPath,
value: deepClone(mockSet),
metaFields: Object.fromEntries(
Object.entries(
isPlainObject(mockSet) ? mockSet : {}
).filter(([key]) => key === 'description')
),
bindings: Object.entries(
isPlainObject(mockSet) ? mockSet : {}
)
.filter(([key]) => key !== 'description')
.map(([key, value]) => ({
selectedMockName: key,
items: (Array.isArray(value) ? value : [value]).map(
item =>
createTestMockItemDraftState(
item,
this.functionMockModeByName(key)
)
)
})),
error: ''
}))
const variants = testsForFundingSource(
this.currentTestFundingSource,
this.currentFunction.name,
this.currentFunction.raw,
test.raw,
test.index
)
let status = {
label: 'Ready',
color: 'positive'
}
let note = ''
if (this.currentTestFundingSource.skip) {
status = {
label: 'Wallet skipped',
color: 'warning'
}
note =
'The selected funding source is marked with `skip: true` in the fixture configuration.'
} else if (rawHasMocksBlock && !hasMockEntry) {
status = {
label: 'Missing wallet mocks',
color: 'negative'
}
note =
'This test has a `mocks` block, but it does not define one for the selected funding source.'
} else if (
rawHasMocksBlock &&
hasMockEntry &&
!rawMockSets.length
) {
status = {
label: 'Empty wallet mocks',
color: 'grey-7'
}
note =
'This test defines the selected funding source, but the mock set is empty.'
} else if (!rawHasMocksBlock) {
status = {
label: 'No test-level mocks',
color: 'grey-7'
}
note = 'This test case does not include a `mocks` section.'
}
return {
...test,
rawWalletPath,
rawMockSets,
variants,
variantCount: variants.length,
status,
note,
callParamsText: safeStringify(test.call_params || {}),
callParamsError: '',
expectedField,
expectedBlock:
expectedField === 'expect'
? test.expect === undefined
? null
: test.expect
: test.expect_error === undefined
? null
: test.expect_error,
expectedText: safeStringify(
expectedField === 'expect'
? test.expect === undefined
? null
: test.expect
: test.expect_error === undefined
? null
: test.expect_error
),
expectedError: '',
expectedLabel:
expectedField === 'expect' ? 'Expect' : 'Expect Error'
}
})
},
currentTestTypeScopeKey() {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return ''
}
return [
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName
].join(':')
},
currentFunctionTestTypeGroups() {
const groups = []
const groupMap = new Map()
this.currentFunctionTestsForFundingSource.forEach(testEntry => {
const label =
(
testEntry.description || `Test ${testEntry.index + 1}`
).trim() || `Test ${testEntry.index + 1}`
if (!groupMap.has(label)) {
const group = {
key: label,
label,
entries: [],
totalVariants: 0
}
groupMap.set(label, group)
groups.push(group)
}
const group = groupMap.get(label)
group.entries.push(testEntry)
group.totalVariants += testEntry.variantCount
})
return groups
},
currentSelectedTestTypeKey: {
get() {
const scopeKey = this.currentTestTypeScopeKey
if (!scopeKey) {
return ''
}
const selectedKey = this.selectedTestTypeByScope[scopeKey]
if (
selectedKey &&
this.currentFunctionTestTypeGroups.some(
group => group.key === selectedKey
)
) {
return selectedKey
}
return this.currentFunctionTestTypeGroups[0]?.key || ''
},
set(value) {
if (!this.currentTestTypeScopeKey) {
return
}
this.selectedTestTypeByScope[this.currentTestTypeScopeKey] = value
}
},
currentSelectedTestTypeGroup() {
return (
this.currentFunctionTestTypeGroups.find(
group => group.key === this.currentSelectedTestTypeKey
) || null
)
}
},
watch: {
selectedDatasetKey() {
this.ensureSelections(this.selectedDatasetKey)
this.ensureSettingsDraftForCurrent()
this.ensureFunctionMockDraftForCurrent()
this.persistUiState()
},
currentFundingSourceName() {
this.ensureSettingsDraftForCurrent()
this.persistUiState()
},
currentTestFundingSourceName() {
this.ensureFunctionMockDraftForCurrent()
this.persistUiState()
},
currentFunctionName() {
this.ensureFunctionMockDraftForCurrent()
this.persistUiState()
},
currentSelectedTestTypeKey() {
this.persistUiState()
}
},
mounted() {
this.restoreUiState()
let restoredAny = this.restoreActiveDatasetFromStorage()
if (!restoredAny) {
for (const def of DATASET_DEFS) {
restoredAny =
this.restoreDatasetFromStorage(def.key) || restoredAny
}
}
if (!this.datasets[this.selectedDatasetKey].analysis) {
this.loadRelative(this.selectedDatasetKey)
} else if (!restoredAny) {
this.loadRelative(this.selectedDatasetKey)
}
window.addEventListener('keydown', this.handleGlobalKeydown)
},
beforeUnmount() {
window.removeEventListener('keydown', this.handleGlobalKeydown)
},
methods: {
storageKey(datasetKey) {
return `${STORAGE_PREFIX}:dataset:${datasetKey}`
},
activeDatasetStorageKey() {
return `${STORAGE_PREFIX}:active-dataset`
},
uiStateStorageKey() {
return `${STORAGE_PREFIX}:ui-state`
},
canUseLocalStorage() {
try {
return (
typeof window !== 'undefined' && Boolean(window.localStorage)
)
} catch (error) {
return false
}
},
clearPersistedFixtureState() {
if (!this.canUseLocalStorage()) {
return
}
DATASET_DEFS.forEach(def => {
window.localStorage.removeItem(this.storageKey(def.key))
})
window.localStorage.removeItem(this.activeDatasetStorageKey())
window.localStorage.removeItem(this.uiStateStorageKey())
},
resetInMemoryFixtureState(nextDatasetKey = this.selectedDatasetKey) {
DATASET_DEFS.forEach(def => {
this.datasets[def.key] = createDatasetState(def)
this.selectedFunctionByDataset[def.key] = ''
this.selectedFundingSourceByDataset[def.key] = ''
this.selectedTestFundingSourceByDataset[def.key] = ''
})
this.functionMockDrafts = {}
this.settingsDrafts = {}
this.selectedRpcRequestDetailTabByScope = {}
this.selectedTestMockBindingByScope = {}
this.selectedTestTypeByScope = {}
this.functionMockDraftRowId = 0
this.settingsDraftRowId = 0
this.lastFilePickerHandle = null
this.selectedDatasetKey = nextDatasetKey
},
persistDatasetToStorage(datasetKey) {
if (!this.canUseLocalStorage()) {
return
}
const dataset = this.datasets[datasetKey]
if (!dataset?.rawText) {
window.localStorage.removeItem(this.storageKey(datasetKey))
if (datasetKey === this.selectedDatasetKey) {
window.localStorage.removeItem(this.activeDatasetStorageKey())
}
return
}
const payload = JSON.stringify({
rawText: dataset.rawText,
snapshotText: dataset.snapshotText,
sourceLabel: dataset.sourceLabel || ''
})
window.localStorage.setItem(this.storageKey(datasetKey), payload)
if (datasetKey === this.selectedDatasetKey) {
window.localStorage.setItem(
this.activeDatasetStorageKey(),
JSON.stringify({
datasetKey,
rawText: dataset.rawText,
snapshotText: dataset.snapshotText,
sourceLabel: dataset.sourceLabel || ''
})
)
}
},
restoreActiveDatasetFromStorage() {
if (!this.canUseLocalStorage()) {
return false
}
const storedValue = window.localStorage.getItem(
this.activeDatasetStorageKey()
)
if (!storedValue) {
return false
}
const parsedStoredValue = parseJsonText(storedValue)
if (
!parsedStoredValue.ok ||
!isPlainObject(parsedStoredValue.data)
) {
window.localStorage.removeItem(this.activeDatasetStorageKey())
return false
}
const {datasetKey} = parsedStoredValue.data
if (
typeof datasetKey !== 'string' ||
!DATASET_DEFS.some(def => def.key === datasetKey)
) {
window.localStorage.removeItem(this.activeDatasetStorageKey())
return false
}
const rawText = String(parsedStoredValue.data.rawText || '').trim()
const snapshotText =
typeof parsedStoredValue.data.snapshotText === 'string'
? parsedStoredValue.data.snapshotText
: rawText
if (!rawText) {
window.localStorage.removeItem(this.activeDatasetStorageKey())
return false
}
const parsedDataset = parseJsonText(rawText)
if (!parsedDataset.ok) {
window.localStorage.removeItem(this.activeDatasetStorageKey())
return false
}
this.selectedDatasetKey = datasetKey
this.commitDataset(datasetKey, parsedDataset.data, {
rawText,
snapshotText,
sourceLabel:
parsedStoredValue.data.sourceLabel ||
`Local storage: ${this.datasets[datasetKey].fileName}`,
fileHandle: null,
loadError: ''
})
return true
},
restoreDatasetFromStorage(datasetKey) {
if (!this.canUseLocalStorage()) {
return false
}
const storedValue = window.localStorage.getItem(
this.storageKey(datasetKey)
)
if (!storedValue) {
return false
}
const parsedStoredValue = parseJsonText(storedValue)
if (
!parsedStoredValue.ok ||
!isPlainObject(parsedStoredValue.data)
) {
window.localStorage.removeItem(this.storageKey(datasetKey))
return false
}
const rawText = String(parsedStoredValue.data.rawText || '').trim()
const snapshotText =
typeof parsedStoredValue.data.snapshotText === 'string'
? parsedStoredValue.data.snapshotText
: rawText
if (!rawText) {
window.localStorage.removeItem(this.storageKey(datasetKey))
return false
}
const parsedDataset = parseJsonText(rawText)
if (!parsedDataset.ok) {
window.localStorage.removeItem(this.storageKey(datasetKey))
return false
}
this.commitDataset(datasetKey, parsedDataset.data, {
rawText,
snapshotText,
sourceLabel:
parsedStoredValue.data.sourceLabel ||
`Local storage: ${this.datasets[datasetKey].fileName}`,
fileHandle: null,
loadError: ''
})
return true
},
persistUiState() {
if (!this.canUseLocalStorage()) {
return
}
window.localStorage.setItem(
this.uiStateStorageKey(),
JSON.stringify({
selectedDatasetKey: this.selectedDatasetKey,
selectedFunctionByDataset: this.selectedFunctionByDataset,
selectedFundingSourceByDataset:
this.selectedFundingSourceByDataset,
selectedRpcRequestDetailTabByScope:
this.selectedRpcRequestDetailTabByScope,
selectedTestMockBindingByScope:
this.selectedTestMockBindingByScope,
selectedTestFundingSourceByDataset:
this.selectedTestFundingSourceByDataset,
selectedTestTypeByScope: this.selectedTestTypeByScope
})
)
},
restoreUiState() {
if (!this.canUseLocalStorage()) {
return
}
const storedValue = window.localStorage.getItem(
this.uiStateStorageKey()
)
if (!storedValue) {
return
}
const parsedStoredValue = parseJsonText(storedValue)
if (
!parsedStoredValue.ok ||
!isPlainObject(parsedStoredValue.data)
) {
window.localStorage.removeItem(this.uiStateStorageKey())
return
}
const state = parsedStoredValue.data
if (
typeof state.selectedDatasetKey === 'string' &&
DATASET_DEFS.some(def => def.key === state.selectedDatasetKey)
) {
this.selectedDatasetKey = state.selectedDatasetKey
}
if (isPlainObject(state.selectedFunctionByDataset)) {
Object.assign(
this.selectedFunctionByDataset,
state.selectedFunctionByDataset
)
}
if (isPlainObject(state.selectedFundingSourceByDataset)) {
Object.assign(
this.selectedFundingSourceByDataset,
state.selectedFundingSourceByDataset
)
}
if (isPlainObject(state.selectedRpcRequestDetailTabByScope)) {
this.selectedRpcRequestDetailTabByScope = {
...state.selectedRpcRequestDetailTabByScope
}
}
if (isPlainObject(state.selectedTestMockBindingByScope)) {
this.selectedTestMockBindingByScope = {
...state.selectedTestMockBindingByScope
}
}
if (isPlainObject(state.selectedTestFundingSourceByDataset)) {
Object.assign(
this.selectedTestFundingSourceByDataset,
state.selectedTestFundingSourceByDataset
)
}
if (isPlainObject(state.selectedTestTypeByScope)) {
this.selectedTestTypeByScope = {
...state.selectedTestTypeByScope
}
}
},
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)
}
},
prettyValue(value) {
if (typeof value === 'string') {
return value
}
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null
) {
return String(value)
}
return safeStringify(value)
},
makeSettingsDraftKey(datasetKey, fundingSourceName) {
return `${datasetKey}:${fundingSourceName}`
},
makeFunctionMockDraftKey(
datasetKey,
functionName,
fundingSourceName
) {
return `${datasetKey}:${functionName}:${fundingSourceName}`
},
createSettingsDraftRow(key = '', value = '') {
const type = settingTypeFromValue(value)
return {
id: ++this.settingsDraftRowId,
key,
type,
valueText: settingValueToText(value, type),
error: ''
}
},
nextGeneratedSettingKey() {
const existingKeys = new Set(
this.currentFundingSourceSettingRows
.map(row => (row.key || '').trim())
.filter(Boolean)
)
let index = 1
let candidate = `new_setting_${index}`
while (existingKeys.has(candidate)) {
index += 1
candidate = `new_setting_${index}`
}
return candidate
},
createFunctionMockDraftRow(name = '', value = {}) {
const mock = isPlainObject(value) ? value : {}
return {
id: ++this.functionMockDraftRowId,
originalName: name,
name,
uri: mock.uri ?? '',
method: mock.method ?? '',
headersText:
mock.headers !== undefined ? safeStringify(mock.headers) : '',
queryParamsText:
mock.query_params !== undefined
? safeStringify(mock.query_params)
: '',
requestType: mock.request_type ?? '',
responseType: mock.response_type ?? '',
responseText:
mock.response !== undefined ? safeStringify(mock.response) : '',
error: ''
}
},
nextGeneratedFunctionMockName() {
const existingNames = new Set(
this.currentFunctionMockRows
.map(row => (row.name || '').trim())
.filter(Boolean)
)
let index = 1
let candidate = `new_mock_${index}`
while (existingNames.has(candidate)) {
index += 1
candidate = `new_mock_${index}`
}
return candidate
},
findSampleFunctionMockValue(
datasetKey,
functionName,
fundingSourceName
) {
const mocks =
this.datasets[datasetKey].data?.functions?.[functionName]
?.mocks?.[fundingSourceName] || {}
return (
Object.values(mocks).find(value => isPlainObject(value)) || null
)
},
findSampleTestMockItem(datasetKey, functionName, fundingSourceName) {
const tests =
this.datasets[datasetKey].data?.functions?.[functionName]
?.tests || []
for (const test of tests) {
const fundingSourceMocks = test?.mocks?.[fundingSourceName]
if (!fundingSourceMocks) {
continue
}
const mockSets = Array.isArray(fundingSourceMocks)
? fundingSourceMocks
: [fundingSourceMocks]
for (const mockSet of mockSets) {
if (!isPlainObject(mockSet)) {
continue
}
for (const [mockName, items] of Object.entries(mockSet)) {
if (mockName === 'description') {
continue
}
const firstItem = Array.isArray(items) ? items[0] : items
if (isPlainObject(firstItem)) {
return firstItem
}
}
}
}
return null
},
createFunctionMockValueTemplate(
datasetKey,
functionName,
fundingSourceName
) {
const sample = this.findSampleFunctionMockValue(
datasetKey,
functionName,
fundingSourceName
)
if (isPlainObject(sample)) {
const template = {}
const sampleMethod =
typeof sample.method === 'string' ? sample.method : ''
const looksLikeHttpVerb = /^[A-Z]+$/.test(sampleMethod)
if (Object.prototype.hasOwnProperty.call(sample, 'uri')) {
template.uri = ''
}
if (Object.prototype.hasOwnProperty.call(sample, 'headers')) {
template.headers = {}
}
if (
Object.prototype.hasOwnProperty.call(sample, 'query_params')
) {
template.query_params = {}
}
if (Object.prototype.hasOwnProperty.call(sample, 'method')) {
template.method = looksLikeHttpVerb ? sampleMethod : ''
}
if (
Object.prototype.hasOwnProperty.call(sample, 'request_type')
) {
template.request_type =
typeof sample.request_type === 'string' &&
sample.request_type.trim()
? sample.request_type
: 'function'
}
if (
Object.prototype.hasOwnProperty.call(sample, 'response_type')
) {
template.response_type =
typeof sample.response_type === 'string' &&
sample.response_type.trim()
? sample.response_type
: 'data'
}
if (Object.prototype.hasOwnProperty.call(sample, 'response')) {
template.response = {}
}
return template
}
if (datasetKey === 'rest') {
return {
uri: '',
headers: {},
method: 'GET'
}
}
return {
method: '',
request_type: 'function',
response_type: 'data',
response: {}
}
},
createTestMockItemTemplate(
datasetKey,
functionName,
fundingSourceName,
functionMockValue = {}
) {
const sample = this.findSampleTestMockItem(
datasetKey,
functionName,
fundingSourceName
)
if (datasetKey === 'rpc') {
const template = {}
const sampleValue = isPlainObject(sample) ? sample : {}
const functionMockResponse = isPlainObject(
functionMockValue?.response
)
? functionMockValue.response
: {}
const sampleResponse = isPlainObject(sampleValue.response)
? sampleValue.response
: {}
const [sampleCallName, sampleCallValue] = Object.entries(
sampleResponse
)[0] ||
Object.entries(functionMockResponse)[0] || ['some_rpc_call', {}]
const innerSample = isPlainObject(sampleCallValue)
? sampleCallValue
: {}
if (
Object.prototype.hasOwnProperty.call(sampleValue, 'description')
) {
template.description = ''
}
template.response_type =
typeof sampleValue.response_type === 'string' &&
sampleValue.response_type.trim()
? sampleValue.response_type
: 'data'
template.response = {
[sampleCallName || 'some_rpc_call']: {
...(Object.prototype.hasOwnProperty.call(
innerSample,
'description'
)
? {description: ''}
: {}),
request_type:
typeof innerSample.request_type === 'string' &&
innerSample.request_type.trim()
? innerSample.request_type
: 'function',
...(Object.prototype.hasOwnProperty.call(
innerSample,
'request_data'
)
? {request_data: {}}
: {}),
response_type:
typeof innerSample.response_type === 'string' &&
innerSample.response_type.trim()
? innerSample.response_type
: 'data',
response: Array.isArray(innerSample.response)
? []
: innerSample.response === null
? null
: {}
}
}
return template
}
if (isPlainObject(sample)) {
const template = {}
if (
Object.prototype.hasOwnProperty.call(sample, 'request_type')
) {
template.request_type =
typeof sample.request_type === 'string'
? sample.request_type
: ''
}
if (
Object.prototype.hasOwnProperty.call(sample, 'request_body')
) {
template.request_body = {}
}
if (
Object.prototype.hasOwnProperty.call(sample, 'response_type')
) {
template.response_type =
typeof sample.response_type === 'string'
? sample.response_type
: 'json'
}
template.response = {}
return template
}
if (datasetKey === 'rest') {
return {
response_type: 'json',
response: {}
}
}
if (
isPlainObject(functionMockValue) &&
Object.prototype.hasOwnProperty.call(
functionMockValue,
'response_type'
)
) {
return {
response: {}
}
}
return {
response_type: 'data',
response: {}
}
},
withFundingSourceMockSets(
data,
functionName,
fundingSourceName,
visitor
) {
const tests = data?.functions?.[functionName]?.tests || []
tests.forEach((test, testIndex) => {
if (
!test.mocks ||
!Object.prototype.hasOwnProperty.call(
test.mocks,
fundingSourceName
)
) {
return
}
const fundingSourceMocks = test.mocks[fundingSourceName]
if (Array.isArray(fundingSourceMocks)) {
fundingSourceMocks.forEach((mockSet, mockSetIndex) => {
if (isPlainObject(mockSet)) {
visitor(mockSet, test, testIndex, mockSetIndex)
}
})
return
}
if (isPlainObject(fundingSourceMocks)) {
visitor(fundingSourceMocks, test, testIndex, 0)
}
})
},
addFunctionMockToTestSets(
data,
functionName,
fundingSourceName,
mockName,
functionMockValue
) {
const defaultItem = this.createTestMockItemTemplate(
this.selectedDatasetKey,
functionName,
fundingSourceName,
functionMockValue
)
this.withFundingSourceMockSets(
data,
functionName,
fundingSourceName,
mockSet => {
if (!Object.prototype.hasOwnProperty.call(mockSet, mockName)) {
mockSet[mockName] = [deepClone(defaultItem)]
}
}
)
},
renameFunctionMockInTestSets(
data,
functionName,
fundingSourceName,
previousName,
nextName
) {
if (!previousName || !nextName || previousName === nextName) {
return
}
this.withFundingSourceMockSets(
data,
functionName,
fundingSourceName,
mockSet => {
if (
!Object.prototype.hasOwnProperty.call(mockSet, previousName)
) {
return
}
if (!Object.prototype.hasOwnProperty.call(mockSet, nextName)) {
mockSet[nextName] = mockSet[previousName]
}
delete mockSet[previousName]
}
)
},
removeFunctionMockFromTestSets(
data,
functionName,
fundingSourceName,
mockName
) {
if (!mockName) {
return
}
this.withFundingSourceMockSets(
data,
functionName,
fundingSourceName,
mockSet => {
if (Object.prototype.hasOwnProperty.call(mockSet, mockName)) {
delete mockSet[mockName]
}
}
)
},
createTestMockBindingDraft(mockName = '', items = []) {
const normalizedItems = Array.isArray(items)
? items
: items === undefined
? []
: [items]
const mode = this.functionMockModeByName(mockName)
return {
selectedMockName: mockName,
items: normalizedItems.map(item =>
createTestMockItemDraftState(item, mode)
)
}
},
testMockBindingScopeKey(mockSet) {
if (!mockSet?.path) {
return ''
}
return [
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName,
mockSet.path
].join(':')
},
testMockBindingOptions(mockSet) {
const names = this.currentFunctionMockNameOptions.map(
option => option.value
)
const activeName = this.activeTestMockBindingName(mockSet)
;(mockSet?.bindings || []).forEach(binding => {
const name = (binding.selectedMockName || '').trim()
if (name && !names.includes(name)) {
names.push(name)
}
})
if (activeName && !names.includes(activeName)) {
names.unshift(activeName)
}
return names.map(name => ({
label: name,
value: name
}))
},
activeTestMockBindingName(mockSet) {
const scopeKey = this.testMockBindingScopeKey(mockSet)
const selectedName = scopeKey
? this.selectedTestMockBindingByScope[scopeKey]
: ''
const bindingNames = (mockSet?.bindings || [])
.map(binding => (binding.selectedMockName || '').trim())
.filter(Boolean)
if (selectedName && bindingNames.includes(selectedName)) {
return selectedName
}
return bindingNames[0] || ''
},
activeTestMockBinding(mockSet) {
const activeName = this.activeTestMockBindingName(mockSet)
return (
(mockSet?.bindings || []).find(
binding =>
(binding.selectedMockName || '').trim() === activeName
) || null
)
},
functionMockRowMode(row) {
return row?.requestType === 'function' ? 'rpc' : 'rest'
},
functionMockModeByName(mockName) {
const normalizedName = (mockName || '').trim()
if (!normalizedName) {
return this.selectedDatasetKey === 'rpc' ? 'rpc' : 'rest'
}
const draftRow = this.currentFunctionMockRows.find(
row => (row.name || '').trim() === normalizedName
)
if (draftRow) {
return this.functionMockRowMode(draftRow)
}
const mockValue =
this.datasets[this.selectedDatasetKey].data?.functions?.[
this.currentFunctionName
]?.mocks?.[this.currentTestFundingSourceName]?.[normalizedName] ||
{}
return mockValue?.request_type === 'function' ? 'rpc' : 'rest'
},
activeTestMockBindingMode(mockSet) {
return this.functionMockModeByName(
this.activeTestMockBindingName(mockSet)
)
},
nextAvailableTestMockName(mockSet) {
const usedNames = new Set(
(mockSet.bindings || [])
.map(binding => (binding.selectedMockName || '').trim())
.filter(Boolean)
)
const availableName = this.currentFunctionMockNameOptions.find(
option => !usedNames.has(option.value)
)
return availableName?.value || ''
},
createDefaultTestMockBindingItems(mockName) {
const functionMockValue =
this.datasets[this.selectedDatasetKey].data?.functions?.[
this.currentFunctionName
]?.mocks?.[this.currentTestFundingSourceName]?.[mockName] || {}
return [
this.createTestMockItemTemplate(
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName,
functionMockValue
)
]
},
testMockItemHasSkip(item) {
return Boolean(item?.hasSkipField || item?.skip === true)
},
testMockItemSkip(item) {
return Boolean(item?.skip)
},
setTestMockItemSkip(testEntry, mockSet, item, value) {
item.error = ''
item.hasSkipField = true
item.skip = Boolean(value)
this.persistTestMockSet(testEntry, mockSet)
},
handleTestMockBindingSelection(testEntry, mockSet, value) {
const nextName = typeof value === 'string' ? value : ''
const scopeKey = this.testMockBindingScopeKey(mockSet)
if (!nextName || !scopeKey) {
return
}
let binding = (mockSet.bindings || []).find(
candidate =>
(candidate.selectedMockName || '').trim() === nextName
)
if (!binding) {
binding = this.createTestMockBindingDraft(
nextName,
this.createDefaultTestMockBindingItems(nextName)
)
mockSet.bindings.push(binding)
this.selectedTestMockBindingByScope[scopeKey] = nextName
this.persistUiState()
this.persistTestMockSet(testEntry, mockSet)
return
}
if (!Array.isArray(binding.items)) {
binding.items = []
}
this.selectedTestMockBindingByScope[scopeKey] = nextName
this.persistUiState()
},
addTestMockBinding(testEntry, mockSet) {
const nextName = this.nextAvailableTestMockName(mockSet)
if (!nextName) {
return
}
mockSet.bindings.push(
this.createTestMockBindingDraft(
nextName,
this.createDefaultTestMockBindingItems(nextName)
)
)
const scopeKey = this.testMockBindingScopeKey(mockSet)
if (scopeKey) {
this.selectedTestMockBindingByScope[scopeKey] = nextName
this.persistUiState()
}
this.persistTestMockSet(testEntry, mockSet)
},
addTestMockBindingItem(testEntry, mockSet, binding) {
const functionMockValue =
this.datasets[this.selectedDatasetKey].data?.functions?.[
this.currentFunctionName
]?.mocks?.[this.currentTestFundingSourceName]?.[
binding.selectedMockName
] || {}
const mode = this.functionMockModeByName(binding.selectedMockName)
const template = this.createTestMockItemTemplate(
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName,
functionMockValue
)
binding.items.push(createTestMockItemDraftState(template, mode))
this.persistTestMockSet(testEntry, mockSet)
},
removeTestMockBindingItem(testEntry, mockSet, binding, itemIndex) {
binding.items.splice(itemIndex, 1)
this.persistTestMockSet(testEntry, mockSet)
},
getSettingsDraftRows(datasetKey, fundingSourceName) {
const draftKey = this.makeSettingsDraftKey(
datasetKey,
fundingSourceName
)
return this.settingsDrafts[draftKey] || []
},
getFunctionMockDraftRows(
datasetKey,
functionName,
fundingSourceName
) {
const draftKey = this.makeFunctionMockDraftKey(
datasetKey,
functionName,
fundingSourceName
)
return this.functionMockDrafts[draftKey] || []
},
ensureSettingsDraft(datasetKey, fundingSourceName, force = false) {
if (!datasetKey || !fundingSourceName) {
return []
}
const draftKey = this.makeSettingsDraftKey(
datasetKey,
fundingSourceName
)
if (!force && this.settingsDrafts[draftKey]) {
return this.settingsDrafts[draftKey]
}
const settings =
this.datasets[datasetKey].data?.funding_sources?.[
fundingSourceName
]?.settings || {}
this.settingsDrafts[draftKey] = Object.entries(settings).map(
([key, value]) => this.createSettingsDraftRow(key, value)
)
return this.settingsDrafts[draftKey]
},
ensureFunctionMockDraft(
datasetKey,
functionName,
fundingSourceName,
force = false
) {
if (!datasetKey || !functionName || !fundingSourceName) {
return []
}
const draftKey = this.makeFunctionMockDraftKey(
datasetKey,
functionName,
fundingSourceName
)
if (!force && this.functionMockDrafts[draftKey]) {
return this.functionMockDrafts[draftKey]
}
const mocks =
this.datasets[datasetKey].data?.functions?.[functionName]
?.mocks?.[fundingSourceName] || {}
this.functionMockDrafts[draftKey] = Object.entries(mocks).map(
([name, value]) => this.createFunctionMockDraftRow(name, value)
)
return this.functionMockDrafts[draftKey]
},
ensureSettingsDraftForCurrent(force = false) {
if (!this.currentFundingSourceName) {
return
}
this.ensureSettingsDraft(
this.selectedDatasetKey,
this.currentFundingSourceName,
force
)
},
ensureFunctionMockDraftForCurrent(force = false) {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return
}
this.ensureFunctionMockDraft(
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName,
force
)
},
clearSettingsDrafts(datasetKey) {
Object.keys(this.settingsDrafts).forEach(key => {
if (key.startsWith(`${datasetKey}:`)) {
delete this.settingsDrafts[key]
}
})
},
clearFunctionMockDrafts(datasetKey) {
Object.keys(this.functionMockDrafts).forEach(key => {
if (key.startsWith(`${datasetKey}:`)) {
delete this.functionMockDrafts[key]
}
})
},
parseSettingRowValue(row) {
if (row.type === 'boolean') {
return row.valueText === 'true'
}
if (row.type === 'number') {
if (row.valueText === '') {
throw new Error('Number values cannot be empty.')
}
const value = Number(row.valueText)
if (Number.isNaN(value)) {
throw new Error('Enter a valid number.')
}
return value
}
if (row.type === 'null') {
return null
}
if (row.type === 'json') {
if (row.valueText.trim() === '') {
throw new Error('Enter valid JSON for this value.')
}
return JSON.parse(row.valueText)
}
return row.valueText
},
parseFunctionMockRowBody(row) {
const mock = {}
const mode = this.functionMockRowMode(row)
const name = row.name.trim()
if (!name) {
throw new Error('Mock name cannot be empty.')
}
if (row.method.trim() !== '') {
mock.method = row.method
}
if (mode === 'rest') {
if (row.uri.trim() !== '') {
mock.uri = row.uri
}
if (row.headersText.trim() !== '') {
const parsedHeaders = JSON.parse(row.headersText)
if (!isPlainObject(parsedHeaders)) {
throw new Error('Headers must be a JSON object.')
}
mock.headers = parsedHeaders
}
if (row.queryParamsText.trim() !== '') {
const parsedQueryParams = JSON.parse(row.queryParamsText)
if (!isPlainObject(parsedQueryParams)) {
throw new Error('Query params must be a JSON object.')
}
mock.query_params = parsedQueryParams
}
} else {
if (row.requestType.trim() !== '') {
mock.request_type = row.requestType
}
if (row.responseType.trim() !== '') {
mock.response_type = row.responseType
}
if (row.responseText.trim() !== '') {
mock.response = JSON.parse(row.responseText)
}
}
return mock
},
functionMockMethodHint(row) {
if (row?.requestType === 'function') {
return 'Method: Fully qualified Python function or constructor path used by this RPC mock.'
}
return 'Method: HTTP verb used by this REST mock, such as GET or POST.'
},
handleFunctionMockRequestTypeChange(row, value) {
row.requestType = value === 'function' ? 'function' : ''
if (row.requestType === 'function') {
row.uri = ''
row.headersText = ''
row.queryParamsText = ''
} else {
row.responseType = ''
row.responseText = ''
}
this.$nextTick(() => {
this.persistFunctionMocks({silent: true})
})
},
handleFunctionMockResponseTypeChange(row, value) {
row.responseType = typeof value === 'string' ? value : ''
this.$nextTick(() => {
this.persistFunctionMocks({silent: true})
})
},
handleTestMockSelectChange(testEntry, mockSet, item, field, value) {
item[field] = typeof value === 'string' ? value : ''
this.$nextTick(() => {
this.persistTestMockSet(testEntry, mockSet, {silent: true})
})
},
rpcRequestDetailScopeKey(testEntry, mockSet, itemIndex) {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return ''
}
return [
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName,
testEntry.index,
mockSet.setIndex,
this.activeTestMockBindingName(mockSet),
itemIndex
].join(':')
},
rpcRequestDetailTab(testEntry, mockSet, itemIndex) {
const scopeKey = this.rpcRequestDetailScopeKey(
testEntry,
mockSet,
itemIndex
)
const currentValue =
this.selectedRpcRequestDetailTabByScope[scopeKey]
return ['class', 'args', 'kwargs'].includes(currentValue)
? currentValue
: 'class'
},
setRpcRequestDetailTab(testEntry, mockSet, itemIndex, value) {
const scopeKey = this.rpcRequestDetailScopeKey(
testEntry,
mockSet,
itemIndex
)
if (!scopeKey) {
return
}
this.selectedRpcRequestDetailTabByScope[scopeKey] = [
'class',
'args',
'kwargs'
].includes(value)
? value
: 'class'
this.persistUiState()
},
parseEditableTestJson(text, label) {
const parsed = parseJsonText(text)
if (!parsed.ok) {
throw new Error(`${label}: ${parsed.error}`)
}
return parsed.data
},
persistTestCallParams(testEntry, options = {}) {
if (!this.currentFunctionName) {
return
}
testEntry.callParamsError = ''
let nextValue
try {
nextValue = this.parseEditableTestJson(
testEntry.callParamsText,
'Call Params'
)
} catch (error) {
testEntry.callParamsError =
error instanceof Error ? error.message : String(error)
if (!options.silent) {
this.notify(
'Could not save call params.',
'negative',
testEntry.callParamsError
)
}
return
}
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
data.functions[this.currentFunctionName].tests[
testEntry.index
].call_params = nextValue
},
{
preserveSettingsDrafts: true,
preserveFunctionMockDrafts: true
}
)
},
persistTestExpected(testEntry, options = {}) {
if (!this.currentFunctionName) {
return
}
testEntry.expectedError = ''
let nextValue
try {
nextValue = this.parseEditableTestJson(
testEntry.expectedText,
testEntry.expectedLabel
)
} catch (error) {
testEntry.expectedError =
error instanceof Error ? error.message : String(error)
if (!options.silent) {
this.notify(
`Could not save ${testEntry.expectedLabel.toLowerCase()}.`,
'negative',
testEntry.expectedError
)
}
return
}
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
const rawTest =
data.functions[this.currentFunctionName].tests[
testEntry.index
]
rawTest[testEntry.expectedField || 'expect'] = nextValue
},
{
preserveSettingsDrafts: true,
preserveFunctionMockDrafts: true
}
)
},
persistTestMockSet(testEntry, mockSet, options = {}) {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return
}
mockSet.error = ''
;(mockSet.bindings || []).forEach(binding => {
;(binding.items || []).forEach(item => {
item.error = ''
item.requestBodyError = ''
item.requestArgsError = ''
item.requestKwargsError = ''
item.responseError = ''
})
})
const nextValue = deepClone(mockSet.metaFields || {})
for (const binding of mockSet.bindings || []) {
const mockName = (binding.selectedMockName || '').trim()
const bindingMode = this.functionMockModeByName(mockName)
if (!mockName) {
if ((binding.items || []).length) {
mockSet.error =
'Select a function mock before editing its overrides.'
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
continue
}
if (Object.prototype.hasOwnProperty.call(nextValue, mockName)) {
mockSet.error = `Duplicate function mock selected: ${mockName}`
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
const parsedItems = []
for (
let itemIndex = 0;
itemIndex < (binding.items || []).length;
itemIndex += 1
) {
const item = binding.items[itemIndex]
const nextItem = {}
if (
bindingMode === 'rest' &&
(item.descriptionText || '').trim() !== ''
) {
nextItem.description = item.descriptionText.trim()
}
if (
bindingMode === 'rest' &&
(item.hasSkipField || item.skip === true)
) {
nextItem.skip = Boolean(item.skip)
}
if (
bindingMode === 'rest' &&
(item.requestType || '').trim() !== ''
) {
nextItem.request_type = item.requestType.trim()
}
if (
bindingMode === 'rest' &&
(item.requestBodyText || '').trim() !== ''
) {
try {
nextItem.request_body = this.parseEditableTestJson(
item.requestBodyText,
`${mockName} request_body ${itemIndex + 1}`
)
} catch (error) {
item.requestBodyError =
error instanceof Error ? error.message : String(error)
mockSet.error = item.requestBodyError
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
}
if (
bindingMode === 'rest' &&
(item.responseType || '').trim() !== ''
) {
nextItem.response_type = item.responseType.trim()
}
if (
bindingMode === 'rest' &&
(item.responseText || '').trim() !== ''
) {
try {
nextItem.response = this.parseEditableTestJson(
item.responseText,
`${mockName} response ${itemIndex + 1}`
)
} catch (error) {
item.responseError =
error instanceof Error ? error.message : String(error)
mockSet.error = item.responseError
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
}
if (bindingMode !== 'rest') {
if ((item.descriptionText || '').trim() !== '') {
nextItem.description = item.descriptionText.trim()
}
if ((item.responseType || '').trim() !== '') {
nextItem.response_type = item.responseType.trim()
}
const rpcCallName = (item.rpcCallName || '').trim()
const rpcCallHasValues = Boolean(
rpcCallName ||
(item.rpcDescriptionText || '').trim() ||
(item.requestType || '').trim() ||
(item.requestArgsText || '').trim() ||
(item.requestKwargsText || '').trim() ||
(item.requestClassName || '').trim() ||
(item.rpcResponseType || '').trim() ||
(item.responseText || '').trim()
)
if (!rpcCallName && rpcCallHasValues) {
item.error =
'Enter the RPC call name before editing its mock data.'
mockSet.error = item.error
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
if (rpcCallName) {
const rpcCall = {}
if ((item.rpcDescriptionText || '').trim() !== '') {
rpcCall.description = item.rpcDescriptionText.trim()
}
if ((item.requestType || '').trim() !== '') {
rpcCall.request_type = item.requestType.trim()
}
const requestData = isPlainObject(item.requestDataExtras)
? deepClone(item.requestDataExtras)
: {}
if ((item.requestArgsText || '').trim() !== '') {
try {
const parsedArgs = this.parseEditableTestJson(
item.requestArgsText,
`${mockName} request_data.args ${itemIndex + 1}`
)
if (!Array.isArray(parsedArgs)) {
throw new Error(
`${mockName} request_data.args ${
itemIndex + 1
}: expected a JSON array`
)
}
requestData.args = parsedArgs
} catch (error) {
item.requestArgsError =
error instanceof Error ? error.message : String(error)
mockSet.error = item.requestArgsError
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
}
if ((item.requestKwargsText || '').trim() !== '') {
try {
const parsedKwargs = this.parseEditableTestJson(
item.requestKwargsText,
`${mockName} request_data.kwargs ${itemIndex + 1}`
)
if (!isPlainObject(parsedKwargs)) {
throw new Error(
`${mockName} request_data.kwargs ${
itemIndex + 1
}: expected a JSON object`
)
}
requestData.kwargs = parsedKwargs
} catch (error) {
item.requestKwargsError =
error instanceof Error ? error.message : String(error)
mockSet.error = item.requestKwargsError
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
}
if ((item.requestClassName || '').trim() !== '') {
requestData.klass = item.requestClassName.trim()
}
if (Object.keys(requestData).length > 0) {
rpcCall.request_data = requestData
}
if ((item.rpcResponseType || '').trim() !== '') {
rpcCall.response_type = item.rpcResponseType.trim()
}
if ((item.responseText || '').trim() !== '') {
try {
rpcCall.response = this.parseEditableTestJson(
item.responseText,
`${mockName} response ${itemIndex + 1}`
)
} catch (error) {
item.responseError =
error instanceof Error ? error.message : String(error)
mockSet.error = item.responseError
if (!options.silent) {
this.notify(
'Could not save test mock definition.',
'negative',
mockSet.error
)
}
return
}
}
nextItem.response = {
[rpcCallName]: rpcCall
}
}
}
parsedItems.push(nextItem)
}
nextValue[mockName] = parsedItems
}
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
const rawTest =
data.functions[this.currentFunctionName].tests[
testEntry.index
]
if (!rawTest.mocks) {
rawTest.mocks = {}
}
const walletMockValue =
rawTest.mocks[this.currentTestFundingSourceName]
if (
Array.isArray(walletMockValue) ||
mockSet.walletMockValueIsArray
) {
if (
!Array.isArray(
rawTest.mocks[this.currentTestFundingSourceName]
)
) {
rawTest.mocks[this.currentTestFundingSourceName] = []
}
rawTest.mocks[this.currentTestFundingSourceName][
mockSet.setIndex
] = nextValue
} else {
rawTest.mocks[this.currentTestFundingSourceName] = nextValue
}
},
{
preserveSettingsDrafts: true,
preserveFunctionMockDrafts: true
}
)
},
applyDatasetMutation(datasetKey, mutator, options = {}) {
const dataset = this.datasets[datasetKey]
if (!dataset.data) {
return
}
const nextData = deepClone(dataset.data)
mutator(nextData)
const rawText = JSON.stringify(nextData, null, 2)
this.commitDataset(datasetKey, nextData, {
rawText,
snapshotText: dataset.snapshotText,
sourceLabel: dataset.sourceLabel,
fileHandle: dataset.fileHandle,
loadError: dataset.loadError,
preserveSettingsDrafts: options.preserveSettingsDrafts === true,
preserveFunctionMockDrafts:
options.preserveFunctionMockDrafts === true
})
},
addFundingSourceSettingRow() {
if (!this.currentFundingSourceName) {
return
}
const rows = this.ensureSettingsDraft(
this.selectedDatasetKey,
this.currentFundingSourceName
)
rows.push(
this.createSettingsDraftRow(this.nextGeneratedSettingKey(), '')
)
this.persistFundingSourceSettings({silent: true})
},
handleFundingSourceSettingTypeChange(row) {
if (row.type === 'boolean') {
row.valueText =
row.valueText === 'true' || row.valueText === 'false'
? row.valueText
: 'false'
} else if (row.type === 'null') {
row.valueText = 'null'
} else if (row.type === 'number') {
row.valueText = row.valueText.trim() === '' ? '0' : row.valueText
} else if (row.type === 'json') {
row.valueText = row.valueText.trim() === '' ? '{}' : row.valueText
}
this.persistFundingSourceSettings()
},
removeFundingSourceSettingRow(rowIndex) {
const rows = this.currentFundingSourceSettingRows
rows.splice(rowIndex, 1)
this.persistFundingSourceSettings()
},
persistFundingSourceSettings(options = {}) {
if (!this.currentFundingSourceName) {
return
}
const rows = this.currentFundingSourceSettingRows
const nextSettings = {}
for (const row of rows) {
row.error = ''
}
for (const row of rows) {
const key = row.key.trim()
if (!key) {
continue
}
try {
nextSettings[key] = this.parseSettingRowValue(row)
} catch (error) {
row.error =
error instanceof Error ? error.message : String(error)
if (!options.silent) {
this.notify(
`Could not save setting "${key || 'new setting'}".`,
'negative',
row.error
)
}
return
}
}
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
data.funding_sources[this.currentFundingSourceName].settings =
nextSettings
},
{
preserveSettingsDrafts: true
}
)
},
addFunctionMockRow() {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return
}
const newMockName = this.nextGeneratedFunctionMockName()
const nextMockValue = this.createFunctionMockValueTemplate(
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName
)
const rows = this.ensureFunctionMockDraft(
this.selectedDatasetKey,
this.currentFunctionName,
this.currentTestFundingSourceName
)
rows.push(
this.createFunctionMockDraftRow(newMockName, nextMockValue)
)
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
if (!data.functions[this.currentFunctionName].mocks) {
data.functions[this.currentFunctionName].mocks = {}
}
if (
!data.functions[this.currentFunctionName].mocks[
this.currentTestFundingSourceName
]
) {
data.functions[this.currentFunctionName].mocks[
this.currentTestFundingSourceName
] = {}
}
data.functions[this.currentFunctionName].mocks[
this.currentTestFundingSourceName
][newMockName] = deepClone(nextMockValue)
this.addFunctionMockToTestSets(
data,
this.currentFunctionName,
this.currentTestFundingSourceName,
newMockName,
nextMockValue
)
},
{
preserveFunctionMockDrafts: true,
preserveSettingsDrafts: true
}
)
},
removeFunctionMockRow(rowIndex) {
const rows = this.currentFunctionMockRows
const [removedRow] = rows.splice(rowIndex, 1)
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
const functionMocks =
data.functions?.[this.currentFunctionName]?.mocks?.[
this.currentTestFundingSourceName
]
if (
functionMocks &&
removedRow?.originalName &&
Object.prototype.hasOwnProperty.call(
functionMocks,
removedRow.originalName
)
) {
delete functionMocks[removedRow.originalName]
}
this.removeFunctionMockFromTestSets(
data,
this.currentFunctionName,
this.currentTestFundingSourceName,
removedRow?.originalName || removedRow?.name || ''
)
},
{
preserveFunctionMockDrafts: true,
preserveSettingsDrafts: true
}
)
},
persistFunctionMocks(options = {}) {
if (
!this.currentFunctionName ||
!this.currentTestFundingSourceName
) {
return
}
const rows = this.currentFunctionMockRows
const nextMocks = {}
const renamePairs = []
for (const row of rows) {
row.error = ''
}
for (const row of rows) {
const name = row.name.trim()
const originalName = (row.originalName || '').trim()
if (!name && !originalName) {
continue
}
if (!name) {
row.error = 'Mock name cannot be empty.'
if (!options.silent) {
this.notify(
`Could not save mock "${originalName || 'new mock'}".`,
'negative',
row.error
)
}
return
}
if (Object.prototype.hasOwnProperty.call(nextMocks, name)) {
row.error = `Duplicate mock name: ${name}`
if (!options.silent) {
this.notify(
`Could not save mock "${name}".`,
'negative',
row.error
)
}
return
}
try {
nextMocks[name] = this.parseFunctionMockRowBody(row)
} catch (error) {
row.error =
error instanceof Error ? error.message : String(error)
if (!options.silent) {
this.notify(
`Could not save mock "${name || 'new mock'}".`,
'negative',
row.error
)
}
return
}
if (originalName && originalName !== name) {
renamePairs.push({
from: originalName,
to: name
})
}
}
this.applyDatasetMutation(
this.selectedDatasetKey,
data => {
const currentMocks =
data.functions?.[this.currentFunctionName]?.mocks?.[
this.currentTestFundingSourceName
] || {}
const currentNames = Object.keys(currentMocks)
const nextNames = Object.keys(nextMocks)
const renameFrom = new Set(renamePairs.map(item => item.from))
const renameTo = new Set(renamePairs.map(item => item.to))
const removedNames = currentNames.filter(
name => !nextNames.includes(name) && !renameFrom.has(name)
)
const addedNames = nextNames.filter(
name => !currentNames.includes(name) && !renameTo.has(name)
)
if (!data.functions[this.currentFunctionName].mocks) {
data.functions[this.currentFunctionName].mocks = {}
}
renamePairs.forEach(pair => {
this.renameFunctionMockInTestSets(
data,
this.currentFunctionName,
this.currentTestFundingSourceName,
pair.from,
pair.to
)
})
removedNames.forEach(name => {
this.removeFunctionMockFromTestSets(
data,
this.currentFunctionName,
this.currentTestFundingSourceName,
name
)
})
addedNames.forEach(name => {
this.addFunctionMockToTestSets(
data,
this.currentFunctionName,
this.currentTestFundingSourceName,
name,
nextMocks[name]
)
})
data.functions[this.currentFunctionName].mocks[
this.currentTestFundingSourceName
] = nextMocks
},
{
preserveFunctionMockDrafts: true,
preserveSettingsDrafts: true
}
)
rows.forEach(row => {
row.originalName = row.name.trim()
})
},
baseWalletPath(functionName, fundingSourceName) {
return `functions.${functionName}.mocks.${fundingSourceName}`
},
testWalletPath(functionName, testIndex, fundingSourceName) {
return `functions.${functionName}.tests[${testIndex}].mocks.${fundingSourceName}`
},
ensureSelections(datasetKey) {
const dataset = this.datasets[datasetKey]
const fundingSources = dataset.analysis?.fundingSources || []
const functions = dataset.analysis?.functions || []
if (!fundingSources.length) {
this.selectedFundingSourceByDataset[datasetKey] = ''
this.selectedTestFundingSourceByDataset[datasetKey] = ''
} else {
const currentFundingSourceName =
this.selectedFundingSourceByDataset[datasetKey]
const selectedFundingSource = fundingSources.find(
fs => fs.name === currentFundingSourceName
)
if (!selectedFundingSource) {
this.selectedFundingSourceByDataset[datasetKey] =
fundingSources[0].name
}
const currentTestFundingSourceName =
this.selectedTestFundingSourceByDataset[datasetKey]
const selectedTestFundingSource = fundingSources.find(
fs => fs.name === currentTestFundingSourceName
)
if (!selectedTestFundingSource) {
this.selectedTestFundingSourceByDataset[datasetKey] =
fundingSources[0].name
}
}
if (!functions.length) {
this.selectedFunctionByDataset[datasetKey] = ''
return
}
const currentFunctionName =
this.selectedFunctionByDataset[datasetKey]
const selectedFunction = functions.find(
fn => fn.name === currentFunctionName
)
if (!selectedFunction) {
this.selectedFunctionByDataset[datasetKey] = functions[0].name
}
},
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
}
if (options.preserveSettingsDrafts !== true) {
this.clearSettingsDrafts(datasetKey)
}
if (options.preserveFunctionMockDrafts !== true) {
this.clearFunctionMockDrafts(datasetKey)
}
this.ensureSelections(datasetKey)
if (
datasetKey === this.selectedDatasetKey &&
this.selectedFundingSourceByDataset[datasetKey]
) {
this.ensureSettingsDraft(
datasetKey,
this.selectedFundingSourceByDataset[datasetKey],
options.preserveSettingsDrafts !== true
)
if (this.selectedFunctionByDataset[datasetKey]) {
this.ensureFunctionMockDraft(
datasetKey,
this.selectedFunctionByDataset[datasetKey],
this.selectedTestFundingSourceByDataset[datasetKey],
options.preserveFunctionMockDrafts !== true
)
}
}
this.persistDatasetToStorage(datasetKey)
this.persistUiState()
},
syncEditorFromRawText(datasetKey, options = {}) {
const dataset = this.datasets[datasetKey]
const parsed = parseJsonText(dataset.rawText)
if (!parsed.ok) {
dataset.parseError = `Invalid JSON in ${dataset.fileName}: ${parsed.error}`
if (!options.silent) {
this.notify('Could not apply the editor contents.', 'negative')
}
return false
}
this.commitDataset(datasetKey, parsed.data, {
rawText: dataset.rawText,
snapshotText: dataset.snapshotText,
fileHandle: dataset.fileHandle,
sourceLabel: dataset.sourceLabel,
loadError: dataset.loadError
})
return true
},
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]
const parsed = parseJsonText(dataset.snapshotText)
if (!parsed.ok) {
dataset.parseError = `Invalid JSON in ${dataset.fileName}: ${parsed.error}`
this.notify(`Could not reset ${dataset.fileName}.`, 'negative')
return
}
this.commitDataset(datasetKey, parsed.data, {
rawText: dataset.snapshotText,
snapshotText: dataset.snapshotText,
fileHandle: dataset.fileHandle,
sourceLabel: dataset.sourceLabel,
loadError: dataset.loadError
})
this.notify(
`Reset ${dataset.fileName} to the last saved snapshot.`,
'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 File” to pick it manually.'
if (!dataset.analysis) {
dataset.parseError = ''
}
console.error(error)
}
},
inferDatasetKey(fileName, data = null) {
const normalizedName = String(fileName || '').toLowerCase()
const exactMatch = DATASET_DEFS.find(
def => def.fileName.toLowerCase() === normalizedName
)
if (exactMatch) {
return exactMatch.key
}
if (
normalizedName.includes('fixtures_rest') ||
normalizedName.includes('_rest')
) {
return 'rest'
}
if (
normalizedName.includes('fixtures_rpc') ||
normalizedName.includes('_rpc')
) {
return 'rpc'
}
if (data) {
return inferDatasetKeyFromData(data)
}
return ''
},
openFilePickerOptions() {
const options = {
id: 'lnbits-wallet-fixture-studio',
multiple: false,
types: [
{
description: 'JSON fixture file',
accept: {
'application/json': ['.json']
}
}
]
}
const startIn =
this.currentDataset.fileHandle || this.lastFilePickerHandle
if (startIn) {
options.startIn = startIn
}
return options
},
async openFilePicker() {
if (window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker(
this.openFilePickerOptions()
)
if (!handle) {
return
}
const file = await handle.getFile()
await this.loadChosenDatasetFile(file, {
fileHandle: handle,
sourceLabel: `File picker: ${file.name}`
})
return
} catch (error) {
if (error && error.name !== 'AbortError') {
console.error(error)
}
}
}
this.$refs.loadFileInput.value = ''
this.$refs.loadFileInput.click()
},
async handleSingleFileSelection(event) {
const file = event.target.files?.[0]
if (!file) {
return
}
await this.loadChosenDatasetFile(file, {
sourceLabel: `Manual file load: ${file.name}`
})
},
async loadChosenDatasetFile(file, options = {}) {
const text = await file.text()
const parsed = parseJsonText(text)
const datasetKey = this.inferDatasetKey(
file.name,
parsed.ok ? parsed.data : null
)
if (!datasetKey) {
this.notify(
'Could not determine whether that fixture file is REST or RPC.',
'negative',
'Use fixtures_rest.json or fixtures_rpc.json.'
)
return
}
if (!parsed.ok) {
this.datasets[datasetKey].parseError =
`Invalid JSON in ${file.name}: ${parsed.error}`
this.notify(`Could not read ${file.name}.`, 'negative')
return
}
this.clearPersistedFixtureState()
this.resetInMemoryFixtureState(datasetKey)
const formatted = JSON.stringify(parsed.data, null, 2)
this.commitDataset(datasetKey, parsed.data, {
rawText: formatted,
snapshotText: formatted,
fileHandle: options.fileHandle || null,
sourceLabel: options.sourceLabel || `Loaded from ${file.name}`,
loadError: ''
})
if (options.fileHandle) {
this.lastFilePickerHandle = options.fileHandle
}
this.notify(`Loaded ${file.name}.`, 'positive')
},
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 savePickerOptions = {
id: 'lnbits-wallet-fixture-studio',
suggestedName: dataset.fileName,
types: [
{
description: 'JSON fixture file',
accept: {
'application/json': ['.json']
}
}
]
}
const saveStartIn =
dataset.fileHandle || this.lastFilePickerHandle
if (saveStartIn) {
savePickerOptions.startIn = saveStartIn
}
const handle =
await window.showSaveFilePicker(savePickerOptions)
const writable = await handle.createWritable()
await writable.write(contents)
await writable.close()
dataset.fileHandle = handle
this.lastFilePickerHandle = 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>