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