fhirsmith 0.14.0 → 0.14.2
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.
- package/CHANGELOG.md +64 -0
- package/README.md +3 -2
- package/config-template.json +20 -0
- package/library/fhir-openapi-schema.js +441 -0
- package/library/html-server.js +9 -3
- package/library/languages.js +15 -0
- package/library/npm-audit.js +265 -0
- package/library/openapi-doc.js +557 -0
- package/library/same-origin.js +62 -0
- package/library/utilities.js +21 -0
- package/nginx.md +17 -9
- package/package.json +10 -4
- package/packages/openapi.js +13 -0
- package/packages/openapi.yaml +603 -0
- package/packages/packages-template.html +4 -2
- package/packages/packages.js +286 -81
- package/packages/readme.md +120 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +95 -42
- package/registry/api.js +117 -41
- package/registry/crawler.js +24 -0
- package/registry/fhirsmith-releases.js +170 -0
- package/registry/model.js +10 -0
- package/registry/openapi.js +13 -0
- package/registry/openapi.yaml +311 -0
- package/registry/readme.md +107 -208
- package/registry/registry-template.html +4 -1
- package/registry/registry.js +183 -20
- package/server.js +25 -6
- package/testing/openapi-schemas.config.js +58 -0
- package/testing/openapi-schemas.json +1920 -0
- package/testing/openapi.js +15 -0
- package/testing/openapi.yaml +273 -0
- package/testing/readme.md +75 -1
- package/testing/render.js +270 -29
- package/testing/search.js +1 -1
- package/testing/store.js +152 -8
- package/testing/testing-template.html +2 -0
- package/testing/testing.js +307 -9
- package/translations/Messages.properties +1 -0
- package/tx/README.md +58 -0
- package/tx/cm/cm-database.js +10 -1
- package/tx/cm/cm-package.js +8 -1
- package/tx/cs/cs-rxnorm.js +23 -6
- package/tx/cs/cs-snomed.js +3 -72
- package/tx/html/tx-template.html +2 -0
- package/tx/library/canonical-resource.js +50 -1
- package/tx/library/codesystem.js +1 -0
- package/tx/library/conceptmap.js +1 -0
- package/tx/library/valueset.js +16 -0
- package/tx/library.js +17 -2
- package/tx/openapi-operations.js +363 -0
- package/tx/openapi-schemas.config.js +118 -0
- package/tx/openapi-schemas.json +7903 -0
- package/tx/openapi.js +289 -0
- package/tx/openapi.yaml +151 -0
- package/tx/operation-context.js +15 -0
- package/tx/params.js +3 -12
- package/tx/provider.js +10 -1
- package/tx/sct/editions.js +70 -0
- package/tx/tx-html.js +6 -0
- package/tx/tx.js +55 -0
- package/tx/vs/vs-database.js +14 -5
- package/tx/vs/vs-package.js +10 -0
- package/tx/workers/expand.js +37 -18
- package/tx/workers/metadata.js +0 -13
- package/tx/workers/translate.js +25 -1
- package/tx/workers/validate.js +74 -24
- package/tx/workers/worker.js +32 -43
- package/utilities/extract-schema-json.js +167 -0
- package/utilities/generate-openapi-schemas.js +49 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +2 -4
package/testing/render.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const escape = require('escape-html');
|
|
20
|
+
const { runLengthOf } = require('./store');
|
|
20
21
|
|
|
21
22
|
const RESULT_COLOURS = {
|
|
22
23
|
pass: '#1e7e34',
|
|
@@ -44,6 +45,9 @@ const STYLE = `
|
|
|
44
45
|
.tr-filter .tr-lbl { color: #555; }
|
|
45
46
|
.tr-filter .tr-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
|
|
46
47
|
.tr-date { white-space: nowrap; }
|
|
48
|
+
/* operations and assertions; nested inside the striped tests table, so reset its colours */
|
|
49
|
+
table.tr-steps { margin-top: 6px; margin-bottom: 0; }
|
|
50
|
+
table.tr-steps > tbody > tr > td, table.tr-steps > tbody > tr > th { background-color: #fff !important; color: #333 !important; padding: 2px 6px; }
|
|
47
51
|
.tr-grid td { text-align: center; }
|
|
48
52
|
.tr-grid th.tr-col { font-size: 80%; word-break: break-all; min-width: 90px; }
|
|
49
53
|
</style>`;
|
|
@@ -102,8 +106,81 @@ function qs(params) {
|
|
|
102
106
|
return s ? '?' + s : '';
|
|
103
107
|
}
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
/**
|
|
110
|
+
* A canonical URL (or participant uri), as its configured name where there is one: the
|
|
111
|
+
* name links to the configured link, the full URL is in the title, and any |version is
|
|
112
|
+
* shown after the name. Otherwise just the URL.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} url
|
|
115
|
+
* @param {Map<string, {name: string, link: string|null}>} [names] - by unversioned canonical
|
|
116
|
+
* @param {string} [version] - a version to show that is not part of the url (participant.version)
|
|
117
|
+
*/
|
|
118
|
+
function canonicalHtml(url, names, version) {
|
|
119
|
+
const s = text(url);
|
|
120
|
+
if (!s) {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
const bar = s.indexOf('|');
|
|
124
|
+
const bare = bar < 0 ? s : s.substring(0, bar);
|
|
125
|
+
const ver = version || (bar < 0 ? '' : s.substring(bar + 1));
|
|
126
|
+
const known = names && names.get(bare);
|
|
127
|
+
let h;
|
|
128
|
+
if (known) {
|
|
129
|
+
h = known.link && /^https?:\/\/[^\s"'<>]+$/i.test(known.link)
|
|
130
|
+
? `<a href="${escape(known.link)}" title="${escape(s)}" rel="nofollow noopener">${escape(known.name)}</a>`
|
|
131
|
+
: `<span title="${escape(s)}">${escape(known.name)}</span>`;
|
|
132
|
+
return h + (ver ? ` <small>${escape(ver)}</small>` : '');
|
|
133
|
+
}
|
|
134
|
+
return uriLink(s) + (version ? ` <small>${escape(version)}</small>` : '');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function testScriptLabel(ts, names) {
|
|
138
|
+
return ts ? canonicalHtml(ts, names) : '<i>(no test script)</i>';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "850 ms", "12.3 s", "4m 05s", "1h 02m" */
|
|
142
|
+
function runLength(ms) {
|
|
143
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
if (ms < 1000) {
|
|
147
|
+
return `${Math.round(ms)} ms`;
|
|
148
|
+
}
|
|
149
|
+
if (ms < 60000) {
|
|
150
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
151
|
+
}
|
|
152
|
+
const secs = Math.round(ms / 1000);
|
|
153
|
+
if (secs < 3600) {
|
|
154
|
+
return `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`;
|
|
155
|
+
}
|
|
156
|
+
const mins = Math.round(secs / 60);
|
|
157
|
+
return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The hidden field every POST form carries: the CSRF token (lusca checks it). */
|
|
161
|
+
function csrfField(token) {
|
|
162
|
+
return token ? `<input type="hidden" name="_csrf" value="${escape(token)}"/>` : '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The line at the top of every page: who is logged in, and what they can get to.
|
|
167
|
+
* @param {{base: string, user?: Object, csrf?: string}} ctx
|
|
168
|
+
*/
|
|
169
|
+
function userBar(ctx) {
|
|
170
|
+
const base = escape(ctx.base);
|
|
171
|
+
const u = ctx.user;
|
|
172
|
+
if (!u) {
|
|
173
|
+
return `<div style="float: right; font-size: 85%"><a href="${base}/login">Login</a></div>`;
|
|
174
|
+
}
|
|
175
|
+
const parts = [`Logged in as <b>${escape(u.name)}</b>`];
|
|
176
|
+
if (u.canEditLinks) {
|
|
177
|
+
parts.push(`<a href="${base}/admin/links">Named links</a>`);
|
|
178
|
+
}
|
|
179
|
+
if (u.isAdmin) {
|
|
180
|
+
parts.push(`<a href="${base}/admin/users">Users</a>`);
|
|
181
|
+
}
|
|
182
|
+
parts.push(`<form method="post" action="${base}/logout" style="display: inline">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-link" style="padding: 0; font-size: 100%; vertical-align: baseline">Logout</button></form>`);
|
|
183
|
+
return `<div style="float: right; font-size: 85%">${parts.join(' | ')}</div>`;
|
|
107
184
|
}
|
|
108
185
|
|
|
109
186
|
// ---- the list ------------------------------------------------------------------------
|
|
@@ -171,21 +248,26 @@ function dateOnly(v) {
|
|
|
171
248
|
return /^\d{4}(-\d{2}(-\d{2})?)?/.test(s) ? s.match(/^\d{4}(-\d{2}(-\d{2})?)?/)[0] : s;
|
|
172
249
|
}
|
|
173
250
|
|
|
251
|
+
// [sort parameter, heading]; null parameter = not sortable. Participants are split by type
|
|
174
252
|
const LIST_COLUMNS = [
|
|
175
|
-
['name', 'Name'], ['
|
|
176
|
-
['tester', 'Tester'], ['testscript', 'Test Script'],
|
|
177
|
-
['
|
|
253
|
+
['name', 'Name'], ['result', 'Result'], ['score', 'Score'], [null, 'Tests'],
|
|
254
|
+
['tester', 'Tester'], ['testscript', 'Test Script'],
|
|
255
|
+
[null, 'Test Engine', 'test-engine'], [null, 'Client', 'client'], [null, 'Server', 'server'],
|
|
256
|
+
['issued', 'Issued'], [null, 'Run Length'], ['_lastUpdated', 'Received']
|
|
178
257
|
];
|
|
179
258
|
|
|
180
259
|
/**
|
|
181
260
|
* @param {Object} query - the page's own query (form fields included)
|
|
182
261
|
* @param {{total: number, rows: Object[]}} results
|
|
183
262
|
* @param {Object} search - the parsed search (for count/offset/sort)
|
|
184
|
-
* @param {{
|
|
263
|
+
* @param {{results: string[], base: string, names?: Map, user?: Object, showClient?: boolean}} options -
|
|
264
|
+
* base is the module's path, e.g. /testing; names the named links; showClient whether any
|
|
265
|
+
* report has a client participant
|
|
185
266
|
*/
|
|
186
267
|
function renderList(query, results, search, options) {
|
|
187
268
|
const base = escape(options.base);
|
|
188
|
-
|
|
269
|
+
const names = options.names;
|
|
270
|
+
let h = STYLE + userBar(options);
|
|
189
271
|
h += `<p><a href="${base}/summary">Summary grid</a> | <a href="${base}/metadata">CapabilityStatement</a>` +
|
|
190
272
|
' | FHIR API: <code>POST TestReport</code>, <code>GET TestReport?...</code></p>';
|
|
191
273
|
|
|
@@ -195,7 +277,6 @@ function renderList(query, results, search, options) {
|
|
|
195
277
|
h += input(query, 'tester:contains', 'Tester contains', 'text', 'Tester');
|
|
196
278
|
h += input(query, 'testscript:below', 'Test script starts with', 'text', 'Test script');
|
|
197
279
|
h += input(query, 'participant:below', 'Participant starts with', 'text', 'Participant');
|
|
198
|
-
h += select(query, 'status', options.statuses, 'Status');
|
|
199
280
|
h += select(query, 'result', options.results, 'Result');
|
|
200
281
|
h += select(query, '_count', ['20', '50', '100', '200', '500'], 'Page size', 'Page: 50');
|
|
201
282
|
h += '</div><div class="tr-line">';
|
|
@@ -217,7 +298,8 @@ function renderList(query, results, search, options) {
|
|
|
217
298
|
// the table; column headers sort
|
|
218
299
|
const curSort = field(query, '_sort');
|
|
219
300
|
h += '<table class="table table-sm table-striped tr-table"><tr>';
|
|
220
|
-
|
|
301
|
+
const columns = LIST_COLUMNS.filter(c => c[2] !== 'client' || options.showClient);
|
|
302
|
+
for (const [param, label] of columns) {
|
|
221
303
|
if (!param) {
|
|
222
304
|
h += `<th>${escape(label)}</th>`;
|
|
223
305
|
continue;
|
|
@@ -237,13 +319,19 @@ function renderList(query, results, search, options) {
|
|
|
237
319
|
for (const r of results.rows) {
|
|
238
320
|
h += '<tr>';
|
|
239
321
|
h += `<td><a href="${base}/TestReport/${encodeURIComponent(r.id)}">${escape(r.name)}</a></td>`;
|
|
240
|
-
h += `<td>${escape(r.status)}</td>`;
|
|
241
322
|
h += `<td>${badge(r.result)}</td>`;
|
|
242
323
|
h += `<td>${score(r.score)}</td>`;
|
|
324
|
+
h += `<td>${typeof r.test_count === 'number' ? r.test_count : ''}</td>`;
|
|
243
325
|
h += `<td>${escape(r.tester)}</td>`;
|
|
244
|
-
h += `<td>${testScriptLabel(r.test_script)}</td>`;
|
|
245
|
-
|
|
326
|
+
h += `<td>${testScriptLabel(r.test_script, names)}</td>`;
|
|
327
|
+
for (const [, , type] of columns.filter(c => c[2])) {
|
|
328
|
+
// anything that is not a test engine or a client is taken to be the server
|
|
329
|
+
const ps = r.participants.filter(p => type === 'server'
|
|
330
|
+
? p.type !== 'test-engine' && p.type !== 'client' : p.type === type);
|
|
331
|
+
h += `<td>${ps.map(p => canonicalHtml(p.uri, names, p.version)).join('<br/>')}</td>`;
|
|
332
|
+
}
|
|
246
333
|
h += `<td class="tr-date" title="${escape(r.issued)}">${escape(dateOnly(r.issued))}</td>`;
|
|
334
|
+
h += `<td class="tr-date">${escape(runLength(r.run_ms))}</td>`;
|
|
247
335
|
h += `<td class="tr-date" title="${escape(r.received)}">${escape(dateOnly(r.received))}</td>`;
|
|
248
336
|
h += '</tr>';
|
|
249
337
|
}
|
|
@@ -273,10 +361,11 @@ function renderList(query, results, search, options) {
|
|
|
273
361
|
* @param {'participant'|'tester'} by
|
|
274
362
|
* @param {string} baseUrl
|
|
275
363
|
* @param {Array} testers - from store.testerCounts()
|
|
364
|
+
* @param {{names?: Map, user?: Object}} [ctx]
|
|
276
365
|
*/
|
|
277
|
-
function renderSummary(cells, by, baseUrl, testers = []) {
|
|
366
|
+
function renderSummary(cells, by, baseUrl, testers = [], ctx = {}) {
|
|
278
367
|
const base = escape(baseUrl);
|
|
279
|
-
let h = STYLE;
|
|
368
|
+
let h = STYLE + userBar({ ...ctx, base: baseUrl });
|
|
280
369
|
const other = by === 'tester' ? 'participant' : 'tester';
|
|
281
370
|
h += `<p><a href="${base}">All reports</a> | Columns are ${by === 'tester' ? 'testers' : 'participants'} ` +
|
|
282
371
|
`(<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) ` +
|
|
@@ -284,7 +373,7 @@ function renderSummary(cells, by, baseUrl, testers = []) {
|
|
|
284
373
|
if (cells.length === 0) {
|
|
285
374
|
return h + '<p>No reports have been received.</p>';
|
|
286
375
|
}
|
|
287
|
-
return h + summaryGrid(cells, by, base) + testerTable(testers, base);
|
|
376
|
+
return h + summaryGrid(cells, by, base, ctx.names) + testerTable(testers, base);
|
|
288
377
|
}
|
|
289
378
|
|
|
290
379
|
/** How many reports have come from each tester. */
|
|
@@ -302,18 +391,18 @@ function testerTable(testers, base) {
|
|
|
302
391
|
return h + '</table>';
|
|
303
392
|
}
|
|
304
393
|
|
|
305
|
-
function summaryGrid(cells, by, base) {
|
|
394
|
+
function summaryGrid(cells, by, base, names) {
|
|
306
395
|
let h = '';
|
|
307
396
|
const cols = [...new Set(cells.map(c => c.col))].sort();
|
|
308
397
|
const scripts = [...new Set(cells.map(c => c.test_script))];
|
|
309
398
|
const at = new Map(cells.map(c => [c.test_script + '\u0000' + c.col, c]));
|
|
310
399
|
h += '<div style="overflow-x: auto"><table class="table table-sm table-bordered tr-table tr-grid"><tr><th>Test Script</th>';
|
|
311
400
|
for (const c of cols) {
|
|
312
|
-
h += `<th class="tr-col">${by === 'tester' ? escape(c) :
|
|
401
|
+
h += `<th class="tr-col">${by === 'tester' ? escape(c) : canonicalHtml(c, names)}</th>`;
|
|
313
402
|
}
|
|
314
403
|
h += '</tr>';
|
|
315
404
|
for (const s of scripts) {
|
|
316
|
-
h += `<tr><th style="text-align: left">${testScriptLabel(s)}</th>`;
|
|
405
|
+
h += `<tr><th style="text-align: left">${testScriptLabel(s, names)}</th>`;
|
|
317
406
|
for (const c of cols) {
|
|
318
407
|
const cell = at.get(s + '\u0000' + c);
|
|
319
408
|
if (!cell) {
|
|
@@ -384,6 +473,51 @@ function testResult(t) {
|
|
|
384
473
|
return best ? best.r : '';
|
|
385
474
|
}
|
|
386
475
|
|
|
476
|
+
/**
|
|
477
|
+
* The operations and assertions in a list of actions, in order, each as
|
|
478
|
+
* {type: 'operation'|'assertion', message, detail, result}.
|
|
479
|
+
*/
|
|
480
|
+
function actionSteps(actions) {
|
|
481
|
+
const steps = [];
|
|
482
|
+
for (const action of asArray(actions)) {
|
|
483
|
+
if (!isObject(action)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
for (const [kind, type] of [['operation', 'operation'], ['assert', 'assertion']]) {
|
|
487
|
+
const a = action[kind];
|
|
488
|
+
if (isObject(a)) {
|
|
489
|
+
steps.push({ type, message: text(a.message), detail: text(a.detail), result: text(a.result) });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return steps;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** A table of operations and assertions: type, message, details link, result. */
|
|
497
|
+
function actionsTable(actions) {
|
|
498
|
+
const steps = actionSteps(actions);
|
|
499
|
+
if (steps.length === 0) {
|
|
500
|
+
return '';
|
|
501
|
+
}
|
|
502
|
+
let h = '<table class="table table-sm table-bordered tr-table tr-steps"><tr><th>Type</th><th>Message</th><th>Details</th><th>Result</th></tr>';
|
|
503
|
+
for (const st of steps) {
|
|
504
|
+
const details = /^https?:\/\/[^\s"'<>]+$/i.test(st.detail)
|
|
505
|
+
? `<a href="${escape(st.detail)}" rel="nofollow noopener">details</a>`
|
|
506
|
+
: (st.detail ? `<span title="${escape(st.detail)}">details</span>` : '');
|
|
507
|
+
h += `<tr><td>${st.type}</td><td class="tr-msg">${escape(st.message)}</td><td>${details}</td><td>${badge(st.result)}</td></tr>`;
|
|
508
|
+
}
|
|
509
|
+
return h + '</table>';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Whether a test's actions are worth a table: more than one operation or assertion, or a
|
|
514
|
+
* single one that says more than just its result.
|
|
515
|
+
*/
|
|
516
|
+
function hasActionDetail(t) {
|
|
517
|
+
const steps = actionSteps(t.action);
|
|
518
|
+
return steps.length > 1 || (steps.length === 1 && !!(steps[0].message || steps[0].detail));
|
|
519
|
+
}
|
|
520
|
+
|
|
387
521
|
function identifierText(ids) {
|
|
388
522
|
return (Array.isArray(ids) ? ids : [ids]).filter(isObject).map(i =>
|
|
389
523
|
(text(i.system) ? escape(text(i.system)) + ' | ' : '') + escape(text(i.value))).join('<br/>');
|
|
@@ -391,20 +525,28 @@ function identifierText(ids) {
|
|
|
391
525
|
|
|
392
526
|
/**
|
|
393
527
|
* @param {Object} report - the stored TestReport
|
|
528
|
+
* @param {string} baseUrl
|
|
529
|
+
* @param {{names?: Map, user?: Object, csrf?: string}} [ctx]
|
|
394
530
|
*/
|
|
395
|
-
function renderReport(report, baseUrl) {
|
|
531
|
+
function renderReport(report, baseUrl, ctx = {}) {
|
|
396
532
|
const base = escape(baseUrl);
|
|
397
|
-
|
|
533
|
+
const names = ctx.names;
|
|
534
|
+
let h = STYLE + userBar({ ...ctx, base: baseUrl });
|
|
398
535
|
h += `<p><a href="${base}">All reports</a> | <a href="${base}/summary">Summary grid</a> | ` +
|
|
399
536
|
`<a href="${base}/TestReport/${encodeURIComponent(report.id)}?_format=json">JSON</a> | <a href="#raw">Raw JSON below</a></p>`;
|
|
537
|
+
if (ctx.user && ctx.user.canDeleteReports) {
|
|
538
|
+
h += `<form method="post" action="${base}/TestReport/${encodeURIComponent(report.id)}/delete" ` +
|
|
539
|
+
'onsubmit="return confirm(\'Delete this report?\')" style="margin-bottom: 10px">' + csrfField(ctx.csrf) +
|
|
540
|
+
'<button type="submit" class="btn btn-danger tr-btn" style="font-size: 85%; padding: 1px 10px">Delete this report</button></form>';
|
|
541
|
+
}
|
|
400
542
|
|
|
401
543
|
// header
|
|
402
544
|
const ts = report.testScript;
|
|
403
545
|
let tsHtml;
|
|
404
546
|
if (typeof ts === 'string') {
|
|
405
|
-
tsHtml =
|
|
547
|
+
tsHtml = canonicalHtml(ts, names);
|
|
406
548
|
} else if (isObject(ts)) {
|
|
407
|
-
tsHtml =
|
|
549
|
+
tsHtml = canonicalHtml(ts.reference, names) + (text(ts.display) ? ' (' + escape(ts.display) + ')' : '');
|
|
408
550
|
} else {
|
|
409
551
|
tsHtml = '';
|
|
410
552
|
}
|
|
@@ -416,6 +558,7 @@ function renderReport(report, baseUrl) {
|
|
|
416
558
|
['Tester', escape(text(report.tester))],
|
|
417
559
|
['Test Script', tsHtml],
|
|
418
560
|
['Issued', escape(text(report.issued))],
|
|
561
|
+
['Run Length', escape(runLength(runLengthOf(report)))],
|
|
419
562
|
['Received', escape(text(report.meta && report.meta.lastUpdated))],
|
|
420
563
|
['Identifier', report.identifier ? identifierText(report.identifier) : '']
|
|
421
564
|
];
|
|
@@ -440,25 +583,34 @@ function renderReport(report, baseUrl) {
|
|
|
440
583
|
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
584
|
for (const p of asArray(report.participant)) {
|
|
442
585
|
if (isObject(p)) {
|
|
443
|
-
h += `<tr><td>${escape(text(p.type))}</td><td>${
|
|
586
|
+
h += `<tr><td>${escape(text(p.type))}</td><td>${canonicalHtml(p.uri, names)}</td><td>${escape(text(p.version))}</td><td>${escape(text(p.display))}</td></tr>`;
|
|
444
587
|
}
|
|
445
588
|
}
|
|
446
589
|
h += '</table>';
|
|
447
590
|
|
|
448
|
-
// tests
|
|
591
|
+
// setup, then the tests (one row each, with their operations and assertions under the
|
|
592
|
+
// description when there is more to them than a single result), then teardown. The log
|
|
593
|
+
// is left to the raw JSON
|
|
594
|
+
if (isObject(report.setup) && actionSteps(report.setup.action).length > 0) {
|
|
595
|
+
h += '<h3>Setup</h3>' + actionsTable(report.setup.action);
|
|
596
|
+
}
|
|
449
597
|
if (tests.length > 0) {
|
|
450
|
-
const
|
|
598
|
+
const detailed = tests.map(t => hasActionDetail(t));
|
|
599
|
+
const hasDesc = tests.some((t, i) => text(t.description) || detailed[i]);
|
|
451
600
|
const hasPeriod = tests.some(t => periodText(t.period));
|
|
452
601
|
h += '<h3>Tests</h3><table class="table table-sm table-striped tr-table"><tr><th>Name</th>' +
|
|
453
602
|
(hasDesc ? '<th>Description</th>' : '') + '<th>Result</th>' + (hasPeriod ? '<th>Period</th>' : '') + '</tr>';
|
|
454
|
-
|
|
603
|
+
tests.forEach((t, i) => {
|
|
455
604
|
h += `<tr><td>${escape(text(t.name))}</td>` +
|
|
456
|
-
(hasDesc ? `<td class="tr-msg">${escape(text(t.description))}</td>` : '') +
|
|
605
|
+
(hasDesc ? `<td><div class="tr-msg">${escape(text(t.description))}</div>${detailed[i] ? actionsTable(t.action) : ''}</td>` : '') +
|
|
457
606
|
`<td>${badge(testResult(t))}</td>` +
|
|
458
607
|
(hasPeriod ? `<td>${periodText(t.period)}</td>` : '') + '</tr>';
|
|
459
|
-
}
|
|
608
|
+
});
|
|
460
609
|
h += '</table>';
|
|
461
610
|
}
|
|
611
|
+
if (isObject(report.teardown) && actionSteps(report.teardown.action).length > 0) {
|
|
612
|
+
h += '<h3>Teardown</h3>' + actionsTable(report.teardown.action);
|
|
613
|
+
}
|
|
462
614
|
|
|
463
615
|
// anything else
|
|
464
616
|
const others = Object.keys(report).filter(k => !HANDLED.has(k));
|
|
@@ -477,6 +629,95 @@ function renderReport(report, baseUrl) {
|
|
|
477
629
|
return h;
|
|
478
630
|
}
|
|
479
631
|
|
|
632
|
+
// ---- login and administration --------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
function renderLogin(baseUrl, error, csrf) {
|
|
635
|
+
const base = escape(baseUrl);
|
|
636
|
+
let h = STYLE;
|
|
637
|
+
if (error) {
|
|
638
|
+
h += `<div class="alert alert-danger">${escape(error)}</div>`;
|
|
639
|
+
}
|
|
640
|
+
h += `<form method="post" action="${base}/login" style="max-width: 360px">${csrfField(csrf)}` +
|
|
641
|
+
'<div class="mb-3"><label for="login" class="form-label">Username</label>' +
|
|
642
|
+
'<input type="text" class="form-control" id="login" name="login" required autocomplete="username"/></div>' +
|
|
643
|
+
'<div class="mb-3"><label for="password" class="form-label">Password</label>' +
|
|
644
|
+
'<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password"/></div>' +
|
|
645
|
+
'<button type="submit" class="btn btn-primary">Login</button></form>';
|
|
646
|
+
return h;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The named links: canonical URLs that are shown by name.
|
|
651
|
+
* @param {Array} links - from store.links()
|
|
652
|
+
* @param {{base: string, user: Object}} ctx
|
|
653
|
+
*/
|
|
654
|
+
function renderLinks(links, ctx, message) {
|
|
655
|
+
const base = escape(ctx.base);
|
|
656
|
+
let h = STYLE + userBar(ctx);
|
|
657
|
+
h += `<p><a href="${base}">All reports</a> | <a href="${base}/summary">Summary grid</a></p>`;
|
|
658
|
+
h += '<p>Where a test script or participant URL (ignoring any <code>|version</code>) matches a canonical here, ' +
|
|
659
|
+
'the name is shown instead, linking to the link.</p>';
|
|
660
|
+
if (message) {
|
|
661
|
+
h += `<div class="alert alert-warning">${escape(message)}</div>`;
|
|
662
|
+
}
|
|
663
|
+
h += '<table class="table table-sm tr-table"><tr><th>Canonical</th><th>Name</th><th>Link</th><th></th></tr>';
|
|
664
|
+
const row = (l) => {
|
|
665
|
+
const action = l ? `${base}/admin/links/${l.id}` : `${base}/admin/links`;
|
|
666
|
+
const form = l ? `link-${l.id}` : 'link-new';
|
|
667
|
+
return `<tr><td><input form="${form}" type="text" name="canonical" required class="form-control" style="font-size: 85%" value="${escape(l ? l.canonical : '')}" placeholder="http://..."/></td>` +
|
|
668
|
+
`<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(l ? l.name : '')}"/></td>` +
|
|
669
|
+
`<td><input form="${form}" type="url" name="link" class="form-control" style="font-size: 85%" value="${escape(l ? l.link || '' : '')}" placeholder="https://..."/></td>` +
|
|
670
|
+
`<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" style="display: inline">${csrfField(ctx.csrf)}` +
|
|
671
|
+
`<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">${l ? 'Save' : 'Add'}</button></form>` +
|
|
672
|
+
(l ? ` <form method="post" action="${action}/delete" style="display: inline" onsubmit="return confirm('Delete this link?')">${csrfField(ctx.csrf)}` +
|
|
673
|
+
'<button type="submit" class="btn btn-default tr-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form>' : '') +
|
|
674
|
+
'</td></tr>';
|
|
675
|
+
};
|
|
676
|
+
for (const l of links) {
|
|
677
|
+
h += row(l);
|
|
678
|
+
}
|
|
679
|
+
h += row(null);
|
|
680
|
+
return h + '</table>';
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* User administration (the administrator only).
|
|
685
|
+
* @param {Array} users - from store.users()
|
|
686
|
+
* @param {{base: string, user: Object}} ctx
|
|
687
|
+
*/
|
|
688
|
+
function renderUsers(users, ctx, message) {
|
|
689
|
+
const base = escape(ctx.base);
|
|
690
|
+
let h = STYLE + userBar(ctx);
|
|
691
|
+
h += `<p><a href="${base}">All reports</a> | <a href="${base}/admin/links">Named links</a></p>`;
|
|
692
|
+
h += '<p>The administrator (login <code>admin</code>, password in the configuration) can do everything. ' +
|
|
693
|
+
'Other users get the rights ticked here. Leave the password blank to keep the current one.</p>';
|
|
694
|
+
if (message) {
|
|
695
|
+
h += `<div class="alert alert-warning">${escape(message)}</div>`;
|
|
696
|
+
}
|
|
697
|
+
h += '<table class="table table-sm tr-table"><tr><th>Login</th><th>Name</th><th>Password</th><th>Edit named links</th><th>Delete reports</th><th></th></tr>';
|
|
698
|
+
const cb = (form, name, on) => `<input form="${form}" type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}/>`;
|
|
699
|
+
for (const u of users) {
|
|
700
|
+
const form = `user-${u.id}`;
|
|
701
|
+
const action = `${base}/admin/users/${u.id}`;
|
|
702
|
+
h += `<tr><td>${escape(u.login)}</td>` +
|
|
703
|
+
`<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(u.name)}"/></td>` +
|
|
704
|
+
`<td><input form="${form}" type="password" name="password" class="form-control" style="font-size: 85%" autocomplete="new-password" placeholder="(unchanged)"/></td>` +
|
|
705
|
+
`<td>${cb(form, 'editLinks', u.can_edit_links)}</td><td>${cb(form, 'deleteReports', u.can_delete_reports)}</td>` +
|
|
706
|
+
`<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" style="display: inline">${csrfField(ctx.csrf)}` +
|
|
707
|
+
'<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
|
|
708
|
+
`<form method="post" action="${action}/delete" style="display: inline" onsubmit="return confirm('Delete this user?')">${csrfField(ctx.csrf)}` +
|
|
709
|
+
'<button type="submit" class="btn btn-default tr-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
|
|
710
|
+
}
|
|
711
|
+
h += `<tr><td><input form="user-new" type="text" name="login" required class="form-control" style="font-size: 85%" autocomplete="off"/></td>` +
|
|
712
|
+
'<td><input form="user-new" type="text" name="name" required class="form-control" style="font-size: 85%"/></td>' +
|
|
713
|
+
'<td><input form="user-new" type="password" name="password" required class="form-control" style="font-size: 85%" autocomplete="new-password"/></td>' +
|
|
714
|
+
`<td>${cb('user-new', 'editLinks', false)}</td><td>${cb('user-new', 'deleteReports', false)}</td>` +
|
|
715
|
+
`<td><form id="user-new" method="post" action="${base}/admin/users" style="display: inline">${csrfField(ctx.csrf)}` +
|
|
716
|
+
'<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">Add</button></form></td></tr>';
|
|
717
|
+
return h + '</table>';
|
|
718
|
+
}
|
|
719
|
+
|
|
480
720
|
module.exports = {
|
|
481
|
-
renderList, renderSummary, renderReport,
|
|
721
|
+
renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers,
|
|
722
|
+
listQueryToSearch, uriLink, badge, canonicalHtml, runLength
|
|
482
723
|
};
|
package/testing/search.js
CHANGED