Skip to content

Commit d281805

Browse files
committed
result section and result website generator
Signed-off-by: Marc Juchli <marc.juchli@digitalasset.com>
1 parent 6a4ec29 commit d281805

3 files changed

Lines changed: 355 additions & 1 deletion

File tree

tools/cip103-conformance/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,43 @@ See `provider.config.browser-extension.example.json` for a copy-ready template.
174174

175175
- `sync` -> bundled `openrpc-dapp-api.json`
176176
- `async` -> bundled `openrpc-dapp-remote-api.json`
177+
178+
## Result format
179+
180+
This section refers to the **generated conformance artifact JSON** written by `conformance-cli run` (the file at `--out`, defaulting to `dist/conformance/result.json`).
181+
182+
Inside that JSON, each test produces an entry in the `results` array with a stable identifier at `results[].id`:
183+
184+
```json
185+
{
186+
"results": [
187+
{
188+
"id": "CIP103-RPC-001",
189+
"status": "pass",
190+
"title": "",
191+
"category": "protocol"
192+
}
193+
]
194+
}
195+
```
196+
197+
Use these IDs to:
198+
199+
- link to a specific failing check in CI logs
200+
- build allow/deny lists in downstream tooling
201+
- aggregate results across many runs/providers
202+
203+
### Protocol
204+
205+
- **`CIP103-RPC-001`**: Unknown method returns JSON-RPC “method not found” (`-32601`).
206+
- **`CIP103-RPC-002`**: Invalid JSON-RPC request returns an error response (may be `skip` for injected transport which doesn't expose raw envelopes).
207+
208+
### Schema
209+
210+
- **`CIP103-SCHEMA-<methodName>`**: Existence probe for each required OpenRPC method in the selected profile.
211+
Example: `CIP103-SCHEMA-listAccounts`.
212+
213+
### Behavior
214+
215+
- **`CIP103-BEH-001`**: Sync profile smoke test that the provider exposes the `connect` lifecycle method.
216+
- **`CIP103-BEH-101`**: Async profile smoke test that the provider exposes the `connect` lifecycle method.
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import fs from 'node:fs'
5+
import path from 'node:path'
6+
import url from 'node:url'
7+
8+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
9+
10+
function readJson(filePath) {
11+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
12+
}
13+
14+
function escapeHtml(s) {
15+
return String(s)
16+
.replaceAll('&', '&amp;')
17+
.replaceAll('<', '&lt;')
18+
.replaceAll('>', '&gt;')
19+
.replaceAll('"', '&quot;')
20+
.replaceAll("'", '&#39;')
21+
}
22+
23+
function normalizeStatus(s) {
24+
if (s === 'pass' || s === 'fail' || s === 'skip') return s
25+
return 'fail'
26+
}
27+
28+
function statusBadge(status) {
29+
const cls =
30+
status === 'pass'
31+
? 'badge pass'
32+
: status === 'skip'
33+
? 'badge skip'
34+
: 'badge fail'
35+
return `<span class="${cls}">${escapeHtml(status.toUpperCase())}</span>`
36+
}
37+
38+
function summarizeMethods(results) {
39+
// schema tests are of the form CIP103-SCHEMA-<methodName>
40+
const methods = new Map()
41+
for (const r of results) {
42+
if (!r?.id || typeof r.id !== 'string') continue
43+
if (!r.id.startsWith('CIP103-SCHEMA-')) continue
44+
const method = r.id.slice('CIP103-SCHEMA-'.length)
45+
if (!method) continue
46+
methods.set(method, normalizeStatus(r.status))
47+
}
48+
const all = Array.from(methods.entries()).sort(([a], [b]) =>
49+
a.localeCompare(b)
50+
)
51+
const covered = all.filter(([, st]) => st === 'pass').length
52+
return { all, covered, total: all.length }
53+
}
54+
55+
function groupByCategory(results) {
56+
const groups = new Map()
57+
for (const r of results) {
58+
const cat = r?.category ?? 'unknown'
59+
if (!groups.has(cat)) groups.set(cat, [])
60+
groups.get(cat).push(r)
61+
}
62+
for (const [, arr] of groups) {
63+
arr.sort((a, b) => String(a.id).localeCompare(String(b.id)))
64+
}
65+
return groups
66+
}
67+
68+
const resultFiles = fs
69+
.readdirSync(__dirname)
70+
.filter((f) => f.startsWith('result-') && f.endsWith('.json'))
71+
.map((f) => path.join(__dirname, f))
72+
73+
const artifacts = resultFiles.map((filePath) => {
74+
const artifact = readJson(filePath)
75+
return {
76+
fileName: path.basename(filePath),
77+
filePath,
78+
suite: artifact.suite,
79+
profile: artifact.profile,
80+
provider: artifact.provider,
81+
generatedAt: artifact.generatedAt,
82+
summary: artifact.summary,
83+
results: artifact.results ?? [],
84+
}
85+
})
86+
87+
artifacts.sort((a, b) =>
88+
String(a.provider?.name ?? a.fileName).localeCompare(
89+
String(b.provider?.name ?? b.fileName)
90+
)
91+
)
92+
93+
const generatedAt = new Date().toISOString()
94+
const outPath = path.join(__dirname, 'conformance-report.html')
95+
96+
const rowsHtml = artifacts
97+
.map((a, idx) => {
98+
const providerName = a.provider?.name ?? a.fileName
99+
const providerTransport = a.provider?.transport ?? 'unknown'
100+
const providerEndpoint =
101+
a.provider?.endpoint ?? a.provider?.appUrl ?? ''
102+
const overall = a.summary?.status ?? 'fail'
103+
const methods = summarizeMethods(a.results)
104+
const groups = groupByCategory(a.results)
105+
106+
const methodsHtml =
107+
methods.total === 0
108+
? '<span class="muted">No schema checks found.</span>'
109+
: `<span class="mono">${methods.covered}/${methods.total}</span>`
110+
111+
const detailId = `detail-${idx}`
112+
const compactChecks = ['protocol', 'behavior', 'stability']
113+
.flatMap((cat) => (groups.get(cat) ?? []).map((r) => r))
114+
.map((r) => {
115+
const st = normalizeStatus(r.status)
116+
return `<tr>
117+
<td class="mono">${escapeHtml(r.id)}</td>
118+
<td>${escapeHtml(r.title ?? '')}</td>
119+
<td>${escapeHtml(r.category ?? '')}</td>
120+
<td>${statusBadge(st)}</td>
121+
<td class="mono">${escapeHtml(r.details ?? '')}</td>
122+
</tr>`
123+
})
124+
.join('\n')
125+
126+
const methodRows = methods.all
127+
.map(([m, st]) => {
128+
return `<tr>
129+
<td class="mono">${escapeHtml(m)}</td>
130+
<td>${statusBadge(st)}</td>
131+
</tr>`
132+
})
133+
.join('\n')
134+
135+
return `<tbody class="wallet">
136+
<tr class="wallet-row" data-wallet-index="${idx}">
137+
<td>
138+
<div class="wallet-name">${escapeHtml(providerName)}</div>
139+
<div class="muted small">${escapeHtml(providerTransport)} ${providerEndpoint ? '• ' + escapeHtml(providerEndpoint) : ''}</div>
140+
</td>
141+
<td>${statusBadge(overall)}</td>
142+
<td class="mono">${escapeHtml(a.profile ?? '')}</td>
143+
<td>${methodsHtml}</td>
144+
<td class="mono">${escapeHtml(a.fileName)}</td>
145+
<td><button class="btn" data-toggle="${detailId}">Details</button></td>
146+
</tr>
147+
<tr id="${detailId}" class="wallet-detail hidden">
148+
<td colspan="6">
149+
<div class="detail-grid">
150+
<div>
151+
<h3>Method coverage (schema)</h3>
152+
<table class="mini">
153+
<thead><tr><th>Method</th><th>Status</th></tr></thead>
154+
<tbody>
155+
${methodRows || '<tr><td colspan="2" class="muted">No schema checks.</td></tr>'}
156+
</tbody>
157+
</table>
158+
</div>
159+
<div>
160+
<h3>Other checks</h3>
161+
<table class="mini">
162+
<thead><tr><th>ID</th><th>Title</th><th>Category</th><th>Status</th><th>Details</th></tr></thead>
163+
<tbody>
164+
${compactChecks || '<tr><td colspan="5" class="muted">No checks.</td></tr>'}
165+
</tbody>
166+
</table>
167+
</div>
168+
</div>
169+
</td>
170+
</tr>
171+
</tbody>`
172+
})
173+
.join('\n')
174+
175+
const html = `<!doctype html>
176+
<html lang="en">
177+
<head>
178+
<meta charset="utf-8" />
179+
<meta name="viewport" content="width=device-width,initial-scale=1" />
180+
<title>CIP-103 Conformance Report</title>
181+
<style>
182+
:root {
183+
--bg: #0b1020;
184+
--card: #111a33;
185+
--text: #e7ecff;
186+
--muted: #a6b0d6;
187+
--border: rgba(231,236,255,0.12);
188+
--pass: #24c08a;
189+
--fail: #ff4d5f;
190+
--skip: #f1b44c;
191+
--btn: #1c2a55;
192+
--btnHover: #24356d;
193+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
194+
}
195+
body { margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
196+
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px; }
197+
.header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
198+
h1 { margin: 0; font-size: 20px; }
199+
.meta { color: var(--muted); font-size: 12px; }
200+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-top: 16px; }
201+
.controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
202+
input[type="search"] { flex: 1; min-width: 280px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: rgba(0,0,0,0.2); color: var(--text); }
203+
select { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: rgba(0,0,0,0.2); color: var(--text); }
204+
table { width: 100%; border-collapse: collapse; }
205+
th, td { padding: 10px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
206+
th { color: var(--muted); font-weight: 600; text-align: left; font-size: 12px; }
207+
.wallet-name { font-weight: 700; }
208+
.muted { color: var(--muted); }
209+
.small { font-size: 12px; }
210+
.mono { font-family: var(--mono); font-size: 12px; }
211+
.badge { display: inline-block; padding: 4px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
212+
.badge.pass { background: rgba(36,192,138,0.15); color: var(--pass); border: 1px solid rgba(36,192,138,0.35); }
213+
.badge.fail { background: rgba(255,77,95,0.12); color: var(--fail); border: 1px solid rgba(255,77,95,0.35); }
214+
.badge.skip { background: rgba(241,180,76,0.12); color: var(--skip); border: 1px solid rgba(241,180,76,0.35); }
215+
.btn { padding: 8px 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--btn); color: var(--text); cursor: pointer; }
216+
.btn:hover { background: var(--btnHover); }
217+
.hidden { display: none; }
218+
.detail-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; padding: 12px 0; }
219+
h3 { margin: 0 0 10px 0; font-size: 14px; }
220+
.mini th, .mini td { padding: 8px 8px; }
221+
.mini td { font-size: 12px; }
222+
@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }
223+
</style>
224+
</head>
225+
<body>
226+
<div class="wrap">
227+
<div class="header">
228+
<h1>CIP-103 Conformance Report</h1>
229+
<div class="meta">Generated ${escapeHtml(generatedAt)} • Source: tools/cip103-conformance/result-*.json</div>
230+
</div>
231+
232+
<div class="card">
233+
<div class="controls">
234+
<input id="q" type="search" placeholder="Filter wallets / endpoints / filenames…" />
235+
<select id="status">
236+
<option value="all">All statuses</option>
237+
<option value="pass">Pass</option>
238+
<option value="fail">Fail</option>
239+
</select>
240+
<select id="profile">
241+
<option value="all">All profiles</option>
242+
<option value="sync">sync</option>
243+
<option value="async">async</option>
244+
</select>
245+
</div>
246+
</div>
247+
248+
<div class="card">
249+
<table id="tbl">
250+
<thead>
251+
<tr>
252+
<th>Wallet</th>
253+
<th>Overall</th>
254+
<th>Profile</th>
255+
<th>Methods covered</th>
256+
<th>Artifact</th>
257+
<th></th>
258+
</tr>
259+
</thead>
260+
${rowsHtml}
261+
</table>
262+
</div>
263+
</div>
264+
265+
<script>
266+
const q = document.getElementById('q');
267+
const statusSel = document.getElementById('status');
268+
const profileSel = document.getElementById('profile');
269+
270+
function textOfRow(tbody) {
271+
return tbody.innerText.toLowerCase();
272+
}
273+
274+
function applyFilter() {
275+
const needle = (q.value || '').trim().toLowerCase();
276+
const status = statusSel.value;
277+
const profile = profileSel.value;
278+
279+
for (const tbody of document.querySelectorAll('tbody.wallet')) {
280+
const row = tbody.querySelector('.wallet-row');
281+
const overallText = row.children[1].innerText.trim().toLowerCase();
282+
const profileText = row.children[2].innerText.trim().toLowerCase();
283+
const hay = textOfRow(tbody);
284+
285+
const okNeedle = !needle || hay.includes(needle);
286+
const okStatus = status === 'all' || overallText.includes(status);
287+
const okProfile = profile === 'all' || profileText === profile;
288+
289+
tbody.style.display = (okNeedle && okStatus && okProfile) ? '' : 'none';
290+
}
291+
}
292+
293+
q.addEventListener('input', applyFilter);
294+
statusSel.addEventListener('change', applyFilter);
295+
profileSel.addEventListener('change', applyFilter);
296+
applyFilter();
297+
298+
document.addEventListener('click', (e) => {
299+
const btn = e.target.closest('button[data-toggle]');
300+
if (!btn) return;
301+
const id = btn.getAttribute('data-toggle');
302+
const detail = document.getElementById(id);
303+
if (!detail) return;
304+
detail.classList.toggle('hidden');
305+
btn.textContent = detail.classList.contains('hidden') ? 'Details' : 'Hide';
306+
});
307+
</script>
308+
</body>
309+
</html>
310+
`
311+
312+
fs.writeFileSync(outPath, html, 'utf8')
313+
console.log(`Wrote ${outPath}`)

tools/cip103-conformance/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"postbuild": "mkdir -p dist/specs && cp ../../api-specs/openrpc-dapp-api.json ../../api-specs/openrpc-dapp-remote-api.json dist/specs/",
2525
"dev": "tsup --watch --onSuccess \"tsc -p tsconfig.types.json\"",
2626
"clean": "tsc -b --clean; rm -rf dist",
27-
"run": "tsx src/cli.ts"
27+
"run": "tsx src/cli.ts",
28+
"report:html": "node ./generate-report.mjs"
2829
},
2930
"dependencies": {
3031
"commander": "^14.0.3",

0 commit comments

Comments
 (0)