fhirsmith 0.13.4 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +16 -3
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +22 -3
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/operation-outcome.js +3 -1
  31. package/tx/library/renderer.js +42 -5
  32. package/tx/operation-context.js +80 -17
  33. package/tx/provider.js +85 -0
  34. package/tx/sct/expressions.js +128 -44
  35. package/tx/tx-html.js +426 -44
  36. package/tx/tx.js +117 -9
  37. package/tx/workers/cache-control.js +2 -2
  38. package/tx/workers/closure.js +483 -13
  39. package/tx/workers/metadata.js +8 -3
  40. package/tx/workers/read.js +18 -3
  41. package/tx/workers/subsumes.js +18 -2
  42. package/tx/workers/translate.js +167 -20
  43. package/tx/workers/validate.js +61 -182
  44. package/tx/workers/worker.js +9 -0
  45. package/tx/xversion/xv-conceptmap.js +17 -8
  46. package/utilities/submit-test-report.js +150 -0
@@ -0,0 +1,112 @@
1
+ # Testing module
2
+
3
+ Receives FHIR TestReports - from TxTester, and from any other tool that produces them - and
4
+ makes them available through a FHIR API and a set of web pages. It runs at `/testing` when
5
+ `modules.testing.enabled` is true.
6
+
7
+ ## Submitting reports
8
+
9
+ POST /testing/TestReport
10
+ Content-Type: application/fhir+json
11
+ Authorization: Bearer <token> (only if a token is configured)
12
+
13
+ R4 and R5 TestReports are treated the same. A report is accepted if it is JSON, has
14
+ `resourceType: TestReport`, and has `name`, `status`, `result`, `tester`, `issued` (a valid
15
+ dateTime) and at least one `participant` with a `uri`. `score`, if present, must be a number.
16
+ Nothing else is checked.
17
+
18
+ The server gives the report a new id (any id sent is ignored), sets `meta.lastUpdated` to the
19
+ time it was received, and drops `meta.versionId`: reports can't be changed once received, so
20
+ there are no versions. Sending the same report twice stores it twice.
21
+
22
+ Responses follow the spec: `201 Created` with a `Location` header, and the stored report,
23
+ or nothing (`Prefer: return=minimal`), or an OperationOutcome (`Prefer: return=OperationOutcome`).
24
+ Errors are OperationOutcomes: 400 (not JSON, or not an acceptable TestReport), 401 (token),
25
+ 413 (too big), 415 (not JSON), 429 (rate limit).
26
+
27
+ The submitter's IP address is recorded, for tracing abuse. It is never returned by the API or
28
+ shown on any page.
29
+
30
+ FHIRsmith's own tx test run writes its results as `test-cases-report.json` in the
31
+ FHIRsmith root; `node utilities/submit-test-report.js [-server url] [-token token]` sends it
32
+ to a /testing server (by default https://testing.fhir.org/testing).
33
+
34
+ ## Reading and searching
35
+
36
+ GET /testing/TestReport/{id}
37
+ GET /testing/TestReport?{params}
38
+ GET /testing/metadata
39
+
40
+ Both return HTML to a browser (an `Accept` that includes `text/html`); `_format=json` or
41
+ `_format=html` overrides that.
42
+
43
+ None of the search parameters are formally defined yet, and the CapabilityStatement lists them
44
+ without definitions. What they do:
45
+
46
+ | Parameter | Type | Searches | Notes |
47
+ |---|---|---|---|
48
+ | `_id` | token | id | |
49
+ | `name` | string | name | starts with, case-insensitive; `:exact`, `:contains`, `:missing` |
50
+ | `tester` | string | tester | as for name |
51
+ | `status` | token | status | `:not`, `:missing` |
52
+ | `result` | token | result | `:not`, `:missing` |
53
+ | `testscript` | uri | testScript (R5 canonical, or R4 `reference`) | `:below` (starts with), `:missing` |
54
+ | `participant` | uri | any participant.uri | `:below` |
55
+ | `score` | number | score | prefixes; implicit precision for eq (`90` means 89.5 to 90.5); `:missing` |
56
+ | `issued` | date | issued | prefixes; dates are ranges, so `issued=2026-09` is all of September |
57
+ | `_lastUpdated` | date | when the report was received | prefixes |
58
+
59
+ Commas OR values, repeated parameters AND. `_sort` takes any of these except `_id`, comma
60
+ separated, `-` for descending; the default is newest received first. `_count` defaults to 50
61
+ and is capped at 500; paging is by `_offset`, with first/previous/next/last links. `total` is
62
+ always returned, and `_summary=count` returns only that.
63
+
64
+ Unknown parameters are ignored, with a warning OperationOutcome in the Bundle, unless the
65
+ request has `Prefer: handling=strict`, in which case they're a 400. Values that can't be
66
+ understood (a bad date or number) are always a 400.
67
+
68
+ ## Web pages
69
+
70
+ * `/testing` - the list of reports, with filters (these map onto the search parameters above),
71
+ sortable columns and paging
72
+ * `/testing/summary` - a grid of test script x participant, with the latest report (by issued
73
+ date) in each cell and a link to all the runs. `?by=tester` makes the columns testers.
74
+ * `/testing/TestReport/{id}` - the report: header, participants, setup / tests / teardown with
75
+ each action's result and message, anything else in the report as JSON, and the raw JSON.
76
+
77
+ Everything in a report comes from whoever sent it, so all of it is escaped, and only http(s)
78
+ URLs become links. The narrative (`text.div`) is never rendered as HTML - it appears only as
79
+ text in the raw JSON.
80
+
81
+ ## Deleting
82
+
83
+ DELETE /testing/TestReport/{id}
84
+ Authorization: Bearer <adminToken>
85
+
86
+ Only when `adminToken` is configured; without it, nothing can be deleted through the API.
87
+
88
+ ## Configuration
89
+
90
+ ```json
91
+ "testing": {
92
+ "enabled": true,
93
+ "database": "testing.db",
94
+ "token": "secret",
95
+ "tokenHeader": "Authorization",
96
+ "adminToken": "another-secret",
97
+ "maxSize": 512000,
98
+ "rateLimit": { "windowMinutes": 1, "max": 60 },
99
+ "retentionDays": 365
100
+ }
101
+ ```
102
+
103
+ * `database` - relative paths are under the data folder's `databases` directory
104
+ * `token` - if set, every POST must carry it in `tokenHeader` (a `Bearer ` prefix is optional).
105
+ If not set, anyone can submit.
106
+ * `tokenHeader` - defaults to `Authorization`. `adminToken` uses the same header.
107
+ * `maxSize` - bytes; default 500 KB. Note that the server's body parsers accept up to 50 MB
108
+ before this module sees the request; a `Content-Length` over the limit is refused before
109
+ the body is looked at.
110
+ * `rateLimit` - POSTs per IP address per window; `max: 0` turns it off. Default 60 a minute.
111
+ * `retentionDays` - if set, reports received longer ago than this are deleted, at startup and
112
+ daily. Default: keep everything.
@@ -0,0 +1,482 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * HTML for the /testing module: the filterable list, the summary grid, and the
9
+ * best-effort rendering of a single report.
10
+ *
11
+ * Everything in a TestReport came from an anonymous sender, so every value goes through
12
+ * escape() and only http(s) URLs ever become links. The report's narrative (text.div)
13
+ * is deliberately not rendered - it is XHTML from a stranger. It is still visible, as
14
+ * text, in the raw JSON view.
15
+ *
16
+ * @module testing/render
17
+ */
18
+
19
+ const escape = require('escape-html');
20
+
21
+ const RESULT_COLOURS = {
22
+ pass: '#1e7e34',
23
+ fail: '#c82333',
24
+ error: '#c82333',
25
+ warning: '#d39e00',
26
+ pending: '#6c757d',
27
+ skip: '#6c757d'
28
+ };
29
+
30
+ const STYLE = `
31
+ <style>
32
+ .tr-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; color: white; font-size: 85%; white-space: nowrap; }
33
+ .tr-table { font-size: 90%; }
34
+ .tr-table td, .tr-table th { vertical-align: top; }
35
+ .tr-uri { word-break: break-all; font-size: 90%; }
36
+ .tr-msg { white-space: pre-wrap; }
37
+ .tr-json { white-space: pre-wrap; word-break: break-all; font-size: 85%; background: #f8f9fa; padding: 8px; border: 1px solid #dee2e6; }
38
+ .tr-filter { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; font-size: 85%; }
39
+ .tr-filter .tr-line { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; width: 100%; }
40
+ .tr-filter input, .tr-filter select { font-size: 100%; padding: 1px 4px; height: auto; }
41
+ .tr-filter input[type=text] { width: 9em; }
42
+ .tr-filter input[type=number] { width: 4.5em; }
43
+ .tr-filter input[type=date] { width: 9.5em; }
44
+ .tr-filter .tr-lbl { color: #555; }
45
+ .tr-filter .tr-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
46
+ .tr-date { white-space: nowrap; }
47
+ .tr-grid td { text-align: center; }
48
+ .tr-grid th.tr-col { font-size: 80%; word-break: break-all; min-width: 90px; }
49
+ </style>`;
50
+
51
+ function text(v) {
52
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
53
+ return String(v);
54
+ }
55
+ return '';
56
+ }
57
+
58
+ function asArray(v) {
59
+ return Array.isArray(v) ? v : [];
60
+ }
61
+
62
+ function isObject(v) {
63
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
64
+ }
65
+
66
+ /** A link if the value is an http(s) URL, otherwise just the escaped text. */
67
+ function uriLink(v, cls = 'tr-uri') {
68
+ const s = text(v);
69
+ if (!s) {
70
+ return '';
71
+ }
72
+ if (/^https?:\/\/[^\s"'<>]+$/i.test(s)) {
73
+ return `<a class="${cls}" href="${escape(s)}" rel="nofollow noopener">${escape(s)}</a>`;
74
+ }
75
+ return `<span class="${cls}">${escape(s)}</span>`;
76
+ }
77
+
78
+ function badge(result) {
79
+ const r = text(result);
80
+ if (!r) {
81
+ return '';
82
+ }
83
+ const colour = RESULT_COLOURS[r] || '#495057';
84
+ return `<span class="tr-badge" style="background-color: ${colour}">${escape(r)}</span>`;
85
+ }
86
+
87
+ function score(v) {
88
+ return typeof v === 'number' && Number.isFinite(v) ? escape(String(v)) : '';
89
+ }
90
+
91
+ /** Build a query string from a plain object, dropping empty values. */
92
+ function qs(params) {
93
+ const u = new URLSearchParams();
94
+ for (const [k, v] of Object.entries(params)) {
95
+ for (const x of (Array.isArray(v) ? v : [v])) {
96
+ if (typeof x === 'string' && x !== '') {
97
+ u.append(k, x);
98
+ }
99
+ }
100
+ }
101
+ const s = u.toString();
102
+ return s ? '?' + s : '';
103
+ }
104
+
105
+ function testScriptLabel(ts) {
106
+ return ts ? uriLink(ts) : '<i>(no test script)</i>';
107
+ }
108
+
109
+ // ---- the list ------------------------------------------------------------------------
110
+
111
+ // form fields that are not FHIR search parameters, and what they turn into
112
+ const HELPERS = {
113
+ 'issued-from': ['issued', 'ge'],
114
+ 'issued-to': ['issued', 'le'],
115
+ 'received-from': ['_lastUpdated', 'ge'],
116
+ 'received-to': ['_lastUpdated', 'le'],
117
+ 'score-min': ['score', 'ge'],
118
+ 'score-max': ['score', 'le']
119
+ };
120
+
121
+ /**
122
+ * Turn the list page's query (FHIR parameters plus the form's helper fields) into a
123
+ * pure FHIR search query.
124
+ */
125
+ function listQueryToSearch(query) {
126
+ const out = {};
127
+ const add = (k, v) => {
128
+ if (out[k] === undefined) {
129
+ out[k] = v;
130
+ } else {
131
+ out[k] = [].concat(out[k], v);
132
+ }
133
+ };
134
+ for (const [k, v] of Object.entries(query || {})) {
135
+ if (HELPERS[k]) {
136
+ const [param, prefix] = HELPERS[k];
137
+ for (const x of (Array.isArray(v) ? v : [v])) {
138
+ if (typeof x === 'string' && x.trim() !== '') {
139
+ add(param, prefix + x.trim());
140
+ }
141
+ }
142
+ } else {
143
+ add(k, v);
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function field(query, name) {
150
+ const v = query[name];
151
+ return typeof v === 'string' ? v : (Array.isArray(v) && typeof v[0] === 'string' ? v[0] : '');
152
+ }
153
+
154
+ function select(query, name, values, label, anyLabel) {
155
+ const cur = field(query, name);
156
+ let h = `<select id="f-${escape(name)}" name="${escape(name)}" title="${escape(label)}"><option value="">${escape(anyLabel || label + ': any')}</option>`;
157
+ for (const v of values) {
158
+ h += `<option value="${escape(v)}"${v === cur ? ' selected' : ''}>${escape(v)}</option>`;
159
+ }
160
+ return h + '</select>';
161
+ }
162
+
163
+ function input(query, name, label, type = 'text', placeholder = label) {
164
+ return `<input type="${type}" id="f-${escape(name)}" name="${escape(name)}" placeholder="${escape(placeholder)}" ` +
165
+ `title="${escape(label)}" aria-label="${escape(label)}" value="${escape(field(query, name))}"/>`;
166
+ }
167
+
168
+ /** Just the date part of a FHIR date/dateTime/instant, as sent. */
169
+ function dateOnly(v) {
170
+ const s = text(v);
171
+ return /^\d{4}(-\d{2}(-\d{2})?)?/.test(s) ? s.match(/^\d{4}(-\d{2}(-\d{2})?)?/)[0] : s;
172
+ }
173
+
174
+ const LIST_COLUMNS = [
175
+ ['name', 'Name'], ['status', 'Status'], ['result', 'Result'], ['score', 'Score'],
176
+ ['tester', 'Tester'], ['testscript', 'Test Script'], [null, 'Participants'],
177
+ ['issued', 'Issued'], ['_lastUpdated', 'Received']
178
+ ];
179
+
180
+ /**
181
+ * @param {Object} query - the page's own query (form fields included)
182
+ * @param {{total: number, rows: Object[]}} results
183
+ * @param {Object} search - the parsed search (for count/offset/sort)
184
+ * @param {{statuses: string[], results: string[], base: string}} options - base is the module's path, e.g. /testing
185
+ */
186
+ function renderList(query, results, search, options) {
187
+ const base = escape(options.base);
188
+ let h = STYLE;
189
+ h += `<p><a href="${base}/summary">Summary grid</a> &nbsp;|&nbsp; <a href="${base}/metadata">CapabilityStatement</a>` +
190
+ ' &nbsp;|&nbsp; FHIR API: <code>POST TestReport</code>, <code>GET TestReport?...</code></p>';
191
+
192
+ // filter form: two lines - text filters and drop-downs, then ranges and buttons
193
+ h += '<form method="get" class="tr-filter mb-3"><div class="tr-line">';
194
+ h += input(query, 'name:contains', 'Name contains', 'text', 'Name');
195
+ h += input(query, 'tester:contains', 'Tester contains', 'text', 'Tester');
196
+ h += input(query, 'testscript:below', 'Test script starts with', 'text', 'Test script');
197
+ h += input(query, 'participant:below', 'Participant starts with', 'text', 'Participant');
198
+ h += select(query, 'status', options.statuses, 'Status');
199
+ h += select(query, 'result', options.results, 'Result');
200
+ h += select(query, '_count', ['20', '50', '100', '200', '500'], 'Page size', 'Page: 50');
201
+ h += '</div><div class="tr-line">';
202
+ h += `<span class="tr-lbl">Score</span>${input(query, 'score-min', 'Score ≥', 'number', 'min')}-${input(query, 'score-max', 'Score ≤', 'number', 'max')}`;
203
+ h += `<span class="tr-lbl">Issued</span>${input(query, 'issued-from', 'Issued from', 'date')}-${input(query, 'issued-to', 'Issued to', 'date')}`;
204
+ h += `<span class="tr-lbl">Received</span>${input(query, 'received-from', 'Received from', 'date')}-${input(query, 'received-to', 'Received to', 'date')}`;
205
+ if (field(query, '_sort')) {
206
+ h += `<input type="hidden" name="_sort" value="${escape(field(query, '_sort'))}"/>`;
207
+ }
208
+ h += '<button type="submit" class="btn btn-primary tr-btn">Filter</button>' +
209
+ `<a class="btn btn-default tr-btn" href="${base}">Clear</a>`;
210
+ h += '</div></form>';
211
+
212
+ // anything the search couldn't use
213
+ const from = results.total === 0 ? 0 : search.offset + 1;
214
+ const to = search.offset + results.rows.length;
215
+ h += `<p>${from}-${to} of ${results.total} report${results.total === 1 ? '' : 's'}</p>`;
216
+
217
+ // the table; column headers sort
218
+ const curSort = field(query, '_sort');
219
+ h += '<table class="table table-sm table-striped tr-table"><tr>';
220
+ for (const [param, label] of LIST_COLUMNS) {
221
+ if (!param) {
222
+ h += `<th>${escape(label)}</th>`;
223
+ continue;
224
+ }
225
+ let next = param;
226
+ let arrow = '';
227
+ if (curSort === param) {
228
+ next = '-' + param;
229
+ arrow = ' ▲';
230
+ } else if (curSort === '-' + param) {
231
+ arrow = ' ▼';
232
+ }
233
+ const link = qs({ ...query, _sort: next, _offset: '' });
234
+ h += `<th><a href="${escape(link || '?')}">${escape(label)}</a>${arrow}</th>`;
235
+ }
236
+ h += '</tr>';
237
+ for (const r of results.rows) {
238
+ h += '<tr>';
239
+ h += `<td><a href="${base}/TestReport/${encodeURIComponent(r.id)}">${escape(r.name)}</a></td>`;
240
+ h += `<td>${escape(r.status)}</td>`;
241
+ h += `<td>${badge(r.result)}</td>`;
242
+ h += `<td>${score(r.score)}</td>`;
243
+ h += `<td>${escape(r.tester)}</td>`;
244
+ h += `<td>${testScriptLabel(r.test_script)}</td>`;
245
+ h += `<td>${r.participants.map(p => uriLink(p.uri) + (p.version ? ` <small>${escape(p.version)}</small>` : '')).join('<br/>')}</td>`;
246
+ h += `<td class="tr-date" title="${escape(r.issued)}">${escape(dateOnly(r.issued))}</td>`;
247
+ h += `<td class="tr-date" title="${escape(r.received)}">${escape(dateOnly(r.received))}</td>`;
248
+ h += '</tr>';
249
+ }
250
+ h += '</table>';
251
+
252
+ // paging
253
+ const links = [];
254
+ if (search.offset > 0) {
255
+ links.push(`<a href="${escape(qs({ ...query, _offset: '' }) || '?')}">First</a>`);
256
+ links.push(`<a href="${escape(qs({ ...query, _offset: String(Math.max(0, search.offset - search.count)) }))}">Previous</a>`);
257
+ }
258
+ if (search.count > 0 && search.offset + search.count < results.total) {
259
+ links.push(`<a href="${escape(qs({ ...query, _offset: String(search.offset + search.count) }))}">Next</a>`);
260
+ const last = Math.floor((results.total - 1) / search.count) * search.count;
261
+ links.push(`<a href="${escape(qs({ ...query, _offset: String(last) }))}">Last</a>`);
262
+ }
263
+ if (links.length > 0) {
264
+ h += `<p>${links.join(' &nbsp;|&nbsp; ')}</p>`;
265
+ }
266
+ return h;
267
+ }
268
+
269
+ // ---- the summary grid ----------------------------------------------------------------
270
+
271
+ /**
272
+ * @param {Array} cells - from store.summary()
273
+ * @param {'participant'|'tester'} by
274
+ * @param {string} baseUrl
275
+ * @param {Array} testers - from store.testerCounts()
276
+ */
277
+ function renderSummary(cells, by, baseUrl, testers = []) {
278
+ const base = escape(baseUrl);
279
+ let h = STYLE;
280
+ const other = by === 'tester' ? 'participant' : 'tester';
281
+ h += `<p><a href="${base}">All reports</a> &nbsp;|&nbsp; Columns are ${by === 'tester' ? 'testers' : 'participants'} ` +
282
+ `(<a href="${base}/summary?by=${other}">show by ${other}</a>${by === 'tester' ? '' : '; test engines are left out'}). Each cell is the latest report (by issued date) ` +
283
+ 'for that test script; the run count links to all of them.</p>';
284
+ if (cells.length === 0) {
285
+ return h + '<p>No reports have been received.</p>';
286
+ }
287
+ return h + summaryGrid(cells, by, base) + testerTable(testers, base);
288
+ }
289
+
290
+ /** How many reports have come from each tester. */
291
+ function testerTable(testers, base) {
292
+ if (testers.length === 0) {
293
+ return '';
294
+ }
295
+ let h = '<h3>Reports by Tester</h3><table class="table table-sm table-striped tr-table" style="width: auto">' +
296
+ `<tr><th>Tester</th><th>Reports</th><th>${badge('pass')}</th><th>${badge('fail')}</th><th>Other</th><th>Latest</th></tr>`;
297
+ for (const t of testers) {
298
+ h += `<tr><td><a href="${base}${escape(qs({ 'tester:exact': t.tester, _sort: '-_lastUpdated' }))}">${escape(t.tester)}</a></td>` +
299
+ `<td>${t.reports}</td><td>${t.pass}</td><td>${t.fail}</td><td>${t.other}</td>` +
300
+ `<td class="tr-date" title="${escape(t.latest)}">${escape(dateOnly(t.latest))}</td></tr>`;
301
+ }
302
+ return h + '</table>';
303
+ }
304
+
305
+ function summaryGrid(cells, by, base) {
306
+ let h = '';
307
+ const cols = [...new Set(cells.map(c => c.col))].sort();
308
+ const scripts = [...new Set(cells.map(c => c.test_script))];
309
+ const at = new Map(cells.map(c => [c.test_script + '\u0000' + c.col, c]));
310
+ h += '<div style="overflow-x: auto"><table class="table table-sm table-bordered tr-table tr-grid"><tr><th>Test Script</th>';
311
+ for (const c of cols) {
312
+ h += `<th class="tr-col">${by === 'tester' ? escape(c) : uriLink(c)}</th>`;
313
+ }
314
+ h += '</tr>';
315
+ for (const s of scripts) {
316
+ h += `<tr><th style="text-align: left">${testScriptLabel(s)}</th>`;
317
+ for (const c of cols) {
318
+ const cell = at.get(s + '\u0000' + c);
319
+ if (!cell) {
320
+ h += '<td></td>';
321
+ continue;
322
+ }
323
+ const filter = { [by === 'tester' ? 'tester:exact' : 'participant']: c };
324
+ if (s) {
325
+ filter.testscript = s;
326
+ } else {
327
+ filter['testscript:missing'] = 'true';
328
+ }
329
+ const sc = score(cell.score);
330
+ h += `<td><a href="${base}/TestReport/${encodeURIComponent(cell.id)}" title="${escape(cell.name)} - ${escape(dateOnly(cell.issued))}">${badge(cell.result)}</a>` +
331
+ (sc ? `<br/>${sc}` : '') +
332
+ `<br/><a href="${base}${escape(qs({ ...filter, _sort: '-issued' }))}" style="font-size: 80%">${cell.runs} run${cell.runs === 1 ? '' : 's'}</a></td>`;
333
+ }
334
+ h += '</tr>';
335
+ }
336
+ return h + '</table></div>';
337
+ }
338
+
339
+ // ---- a single report -----------------------------------------------------------------
340
+
341
+ // top level elements the report view shows, or deliberately leaves to the raw JSON
342
+ const HANDLED = new Set([
343
+ 'resourceType', 'id', 'meta', 'text', 'identifier', 'name', 'status', 'testScript', 'result', 'score',
344
+ 'tester', 'issued', 'participant', 'setup', 'test', 'teardown', 'log'
345
+ ]);
346
+
347
+ function jsonBlock(v) {
348
+ return `<div class="tr-json">${escape(JSON.stringify(v, null, 2))}</div>`;
349
+ }
350
+
351
+ function periodText(p) {
352
+ if (!isObject(p)) {
353
+ return '';
354
+ }
355
+ const start = text(p.start);
356
+ const end = text(p.end);
357
+ if (start && end) {
358
+ return `${escape(start)} &ndash; ${escape(end)}`;
359
+ }
360
+ return start ? escape(start) + ' &ndash;' : (end ? '&ndash; ' + escape(end) : '');
361
+ }
362
+
363
+ /**
364
+ * A test's result. test.result where there is one; otherwise - reports in the R5 shape
365
+ * have none - the worst result among its actions, so that those still show something.
366
+ */
367
+ const RESULT_RANK = ['error', 'fail', 'warning', 'pending', 'pass', 'skip'];
368
+ function testResult(t) {
369
+ if (text(t.result)) {
370
+ return text(t.result);
371
+ }
372
+ let best = null;
373
+ for (const action of asArray(t.action)) {
374
+ for (const kind of ['operation', 'assert']) {
375
+ const r = isObject(action) && isObject(action[kind]) ? text(action[kind].result) : '';
376
+ if (r) {
377
+ const rank = RESULT_RANK.indexOf(r) < 0 ? RESULT_RANK.length : RESULT_RANK.indexOf(r);
378
+ if (best === null || rank < best.rank) {
379
+ best = { r, rank };
380
+ }
381
+ }
382
+ }
383
+ }
384
+ return best ? best.r : '';
385
+ }
386
+
387
+ function identifierText(ids) {
388
+ return (Array.isArray(ids) ? ids : [ids]).filter(isObject).map(i =>
389
+ (text(i.system) ? escape(text(i.system)) + ' | ' : '') + escape(text(i.value))).join('<br/>');
390
+ }
391
+
392
+ /**
393
+ * @param {Object} report - the stored TestReport
394
+ */
395
+ function renderReport(report, baseUrl) {
396
+ const base = escape(baseUrl);
397
+ let h = STYLE;
398
+ h += `<p><a href="${base}">All reports</a> &nbsp;|&nbsp; <a href="${base}/summary">Summary grid</a> &nbsp;|&nbsp; ` +
399
+ `<a href="${base}/TestReport/${encodeURIComponent(report.id)}?_format=json">JSON</a> &nbsp;|&nbsp; <a href="#raw">Raw JSON below</a></p>`;
400
+
401
+ // header
402
+ const ts = report.testScript;
403
+ let tsHtml;
404
+ if (typeof ts === 'string') {
405
+ tsHtml = uriLink(ts);
406
+ } else if (isObject(ts)) {
407
+ tsHtml = uriLink(ts.reference) + (text(ts.display) ? ' (' + escape(ts.display) + ')' : '');
408
+ } else {
409
+ tsHtml = '';
410
+ }
411
+ const rows = [
412
+ ['Name', escape(text(report.name))],
413
+ ['Status', escape(text(report.status))],
414
+ ['Result', badge(report.result)],
415
+ ['Score', score(report.score)],
416
+ ['Tester', escape(text(report.tester))],
417
+ ['Test Script', tsHtml],
418
+ ['Issued', escape(text(report.issued))],
419
+ ['Received', escape(text(report.meta && report.meta.lastUpdated))],
420
+ ['Identifier', report.identifier ? identifierText(report.identifier) : '']
421
+ ];
422
+ h += '<table class="table table-sm tr-table" style="width: auto">';
423
+ for (const [k, v] of rows) {
424
+ if (v) {
425
+ h += `<tr><th>${escape(k)}</th><td>${v}</td></tr>`;
426
+ }
427
+ }
428
+ const tests = asArray(report.test).filter(isObject);
429
+ if (tests.length > 0) {
430
+ const tally = {};
431
+ for (const t of tests) {
432
+ const r = testResult(t) || '(none)';
433
+ tally[r] = (tally[r] || 0) + 1;
434
+ }
435
+ h += '<tr><th>Tests</th><td>' + Object.keys(tally).sort().map(k => `${badge(k)} ${tally[k]}`).join(' &nbsp; ') + '</td></tr>';
436
+ }
437
+ h += '</table>';
438
+
439
+ // participants
440
+ h += '<h3>Participants</h3><table class="table table-sm tr-table" style="width: auto"><tr><th>Type</th><th>URI</th><th>Version</th><th>Display</th></tr>';
441
+ for (const p of asArray(report.participant)) {
442
+ if (isObject(p)) {
443
+ h += `<tr><td>${escape(text(p.type))}</td><td>${uriLink(p.uri)}</td><td>${escape(text(p.version))}</td><td>${escape(text(p.display))}</td></tr>`;
444
+ }
445
+ }
446
+ h += '</table>';
447
+
448
+ // tests: one row each. Actions and the log are left to the raw JSON for now
449
+ if (tests.length > 0) {
450
+ const hasDesc = tests.some(t => text(t.description));
451
+ const hasPeriod = tests.some(t => periodText(t.period));
452
+ h += '<h3>Tests</h3><table class="table table-sm table-striped tr-table"><tr><th>Name</th>' +
453
+ (hasDesc ? '<th>Description</th>' : '') + '<th>Result</th>' + (hasPeriod ? '<th>Period</th>' : '') + '</tr>';
454
+ for (const t of tests) {
455
+ h += `<tr><td>${escape(text(t.name))}</td>` +
456
+ (hasDesc ? `<td class="tr-msg">${escape(text(t.description))}</td>` : '') +
457
+ `<td>${badge(testResult(t))}</td>` +
458
+ (hasPeriod ? `<td>${periodText(t.period)}</td>` : '') + '</tr>';
459
+ }
460
+ h += '</table>';
461
+ }
462
+
463
+ // anything else
464
+ const others = Object.keys(report).filter(k => !HANDLED.has(k));
465
+ if (others.length > 0) {
466
+ h += '<h3>Other Content</h3><table class="table table-sm tr-table"><tr><th>Element</th><th>Value</th></tr>';
467
+ for (const k of others) {
468
+ h += `<tr><td>${escape(k)}</td><td>${jsonBlock(report[k])}</td></tr>`;
469
+ }
470
+ h += '</table>';
471
+ }
472
+ if (isObject(report.text)) {
473
+ h += '<p><i>The report has a narrative; it is not displayed here, but is in the raw JSON.</i></p>';
474
+ }
475
+
476
+ h += '<h3 id="raw">Raw JSON</h3>' + jsonBlock(report);
477
+ return h;
478
+ }
479
+
480
+ module.exports = {
481
+ renderList, renderSummary, renderReport, listQueryToSearch, uriLink, badge
482
+ };