bearings 0.4.0 → 0.5.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.
@@ -1,131 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { readFile } from 'node:fs/promises';
4
- import { pathToFileURL } from 'node:url';
5
-
6
- const SCOPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
- const TOP_LEVEL_STRINGS = ['title', 'subtitle', 'intro', 'callout', 'conventions'];
8
-
9
- function isObject(value) {
10
- return value !== null && typeof value === 'object' && !Array.isArray(value);
11
- }
12
-
13
- function nonEmptyString(value) {
14
- return typeof value === 'string' && value.trim().length > 0;
15
- }
16
-
17
- function optionalString(value) {
18
- return value === undefined || typeof value === 'string';
19
- }
20
-
21
- function validatePairs(value, path, errors) {
22
- if (value === undefined) return;
23
- if (!Array.isArray(value)) {
24
- errors.push(`${path} must be an array`);
25
- return;
26
- }
27
- value.forEach((pair, index) => {
28
- if (!Array.isArray(pair) || pair.length !== 2 || pair.some((part) => typeof part !== 'string')) {
29
- errors.push(`${path}[${index}] must be a two-string tuple`);
30
- }
31
- });
32
- }
33
-
34
- export function validatePlan(plan) {
35
- const errors = [];
36
- const ids = new Set();
37
-
38
- if (!isObject(plan)) return ['$ must be an object'];
39
- if (!nonEmptyString(plan.scope) || !SCOPE.test(plan.scope)) {
40
- errors.push('$.scope must be a lowercase slug');
41
- }
42
- for (const field of TOP_LEVEL_STRINGS) {
43
- if (field === 'title' ? !nonEmptyString(plan[field]) : !optionalString(plan[field])) {
44
- errors.push(`$.${field} must be ${field === 'title' ? 'a non-empty string' : 'a string'}`);
45
- }
46
- }
47
- if (!Array.isArray(plan.sections) || plan.sections.length === 0) {
48
- errors.push('$.sections must be a non-empty array');
49
- return errors;
50
- }
51
-
52
- plan.sections.forEach((section, sectionIndex) => {
53
- const sectionPath = `$.sections[${sectionIndex}]`;
54
- if (!isObject(section)) {
55
- errors.push(`${sectionPath} must be an object`);
56
- return;
57
- }
58
- for (const field of ['n', 'title']) {
59
- if (!nonEmptyString(section[field])) errors.push(`${sectionPath}.${field} must be a non-empty string`);
60
- }
61
- if (!['checklist', 'info'].includes(section.kind)) {
62
- errors.push(`${sectionPath}.kind must be "checklist" or "info"`);
63
- }
64
- if (!optionalString(section.intro)) errors.push(`${sectionPath}.intro must be a string`);
65
- if (!Array.isArray(section.tests) || section.tests.length === 0) {
66
- errors.push(`${sectionPath}.tests must be a non-empty array`);
67
- return;
68
- }
69
-
70
- section.tests.forEach((test, testIndex) => {
71
- const testPath = `${sectionPath}.tests[${testIndex}]`;
72
- if (!isObject(test)) {
73
- errors.push(`${testPath} must be an object`);
74
- return;
75
- }
76
- for (const field of ['id', 'title']) {
77
- if (!nonEmptyString(test[field])) errors.push(`${testPath}.${field} must be a non-empty string`);
78
- }
79
- if (nonEmptyString(test.id)) {
80
- if (ids.has(test.id)) errors.push(`${testPath}.id duplicates "${test.id}"`);
81
- ids.add(test.id);
82
- }
83
- for (const field of ['note', 'expect']) {
84
- if (!optionalString(test[field])) errors.push(`${testPath}.${field} must be a string`);
85
- }
86
- if (test.optional !== undefined && typeof test.optional !== 'boolean') {
87
- errors.push(`${testPath}.optional must be a boolean`);
88
- }
89
- validatePairs(test.code, `${testPath}.code`, errors);
90
- validatePairs(test.subs, `${testPath}.subs`, errors);
91
- if (Array.isArray(test.subs)) {
92
- test.subs.forEach((sub, subIndex) => {
93
- if (!Array.isArray(sub) || !nonEmptyString(sub[0])) return;
94
- if (ids.has(sub[0])) errors.push(`${testPath}.subs[${subIndex}][0] duplicates "${sub[0]}"`);
95
- ids.add(sub[0]);
96
- });
97
- }
98
- });
99
- });
100
-
101
- return errors;
102
- }
103
-
104
- export async function loadAndValidatePlan(path) {
105
- let plan;
106
- try {
107
- plan = JSON.parse(await readFile(path, 'utf8'));
108
- } catch (error) {
109
- return { errors: [`Unable to read valid JSON: ${error.message}`] };
110
- }
111
- const errors = validatePlan(plan);
112
- return errors.length === 0 ? { plan, errors } : { errors };
113
- }
114
-
115
- async function main() {
116
- const path = process.argv[2];
117
- if (!path || process.argv.length !== 3) {
118
- console.error('Usage: node validate.mjs <testplan.json>');
119
- process.exitCode = 1;
120
- return;
121
- }
122
- const { errors } = await loadAndValidatePlan(path);
123
- if (errors.length > 0) {
124
- for (const error of errors) console.error(`ERROR ${error}`);
125
- process.exitCode = 1;
126
- return;
127
- }
128
- console.log('OK');
129
- }
130
-
131
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
@@ -1,132 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawn } from 'node:child_process';
4
- import { createServer } from 'node:http';
5
- import { readFile, readdir } from 'node:fs/promises';
6
- import { basename, dirname, join, resolve } from 'node:path';
7
- import { fileURLToPath, pathToFileURL } from 'node:url';
8
- import { loadAndValidatePlan } from './validate.mjs';
9
-
10
- const viewerPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'viewer.html');
11
-
12
- export async function listPlans(repoDir) {
13
- const plansDir = join(repoDir, 'testplans');
14
- const entries = await readdir(plansDir, { withFileTypes: true }).catch((error) => {
15
- if (error.code === 'ENOENT') return [];
16
- throw error;
17
- });
18
- return Promise.all(entries
19
- .filter((entry) => entry.isFile() && entry.name.endsWith('.testplan.json'))
20
- .map(async ({ name: file }) => {
21
- const result = await loadAndValidatePlan(join(plansDir, file));
22
- return result.plan
23
- ? { file, scope: result.plan.scope, title: result.plan.title, valid: true, plan: result.plan }
24
- : { file, valid: false, errors: result.errors };
25
- }))
26
- .then((plans) => plans.sort((a, b) => a.file.localeCompare(b.file)));
27
- }
28
-
29
- function sendJson(response, status, value) {
30
- response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
31
- response.end(`${JSON.stringify(value)}\n`);
32
- }
33
-
34
- export async function startViewer(repoDir = process.cwd(), port = 0) {
35
- const viewer = await readFile(viewerPath);
36
- const server = createServer(async (request, response) => {
37
- try {
38
- const url = new URL(request.url ?? '/', 'http://localhost');
39
- response.setHeader('Cache-Control', 'no-store');
40
- response.setHeader('X-Content-Type-Options', 'nosniff');
41
- if (request.method === 'GET' && url.pathname === '/') {
42
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
43
- response.end(viewer);
44
- return;
45
- }
46
- if (request.method === 'GET' && url.pathname === '/plans') {
47
- const plans = await listPlans(repoDir);
48
- sendJson(response, 200, plans.map(({ plan: _, ...summary }) => summary));
49
- return;
50
- }
51
- if (request.method === 'GET' && url.pathname === '/plan') {
52
- const file = url.searchParams.get('file');
53
- if (!file || basename(file) !== file || !file.endsWith('.testplan.json')) {
54
- sendJson(response, 400, { errors: ['Invalid test-plan filename'] });
55
- return;
56
- }
57
- const selected = (await listPlans(repoDir)).find((plan) => plan.file === file);
58
- if (!selected) {
59
- sendJson(response, 404, { errors: ['Test plan not found'] });
60
- return;
61
- }
62
- if (!selected.valid) {
63
- sendJson(response, 422, { errors: selected.errors });
64
- return;
65
- }
66
- sendJson(response, 200, selected.plan);
67
- return;
68
- }
69
- response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
70
- response.end('Not found\n');
71
- } catch (error) {
72
- sendJson(response, 500, { errors: [error.message] });
73
- }
74
- });
75
-
76
- await new Promise((resolveListening, reject) => {
77
- server.once('error', reject);
78
- server.listen(port, '127.0.0.1', resolveListening);
79
- });
80
- const address = server.address();
81
- if (!address || typeof address === 'string') throw new Error('Unable to determine viewer address');
82
- return { server, url: `http://127.0.0.1:${address.port}/` };
83
- }
84
-
85
- export function browserCommand(url, platform = process.platform) {
86
- if (platform === 'darwin') return { command: 'open', args: [url] };
87
- if (platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '', url] };
88
- return { command: 'xdg-open', args: [url] };
89
- }
90
-
91
- export function openBrowser(url, platform = process.platform, spawnProcess = spawn) {
92
- const { command, args } = browserCommand(url, platform);
93
- return new Promise((resolveOpen) => {
94
- const child = spawnProcess(command, args, { stdio: 'ignore', windowsHide: true });
95
- child.once('error', () => resolveOpen(false));
96
- child.once('close', (code) => resolveOpen(code === 0));
97
- });
98
- }
99
-
100
- export async function runViewer(repoDir = process.cwd(), port = 0, open = openBrowser, log = console.log) {
101
- const running = await startViewer(repoDir, port);
102
- log(running.url);
103
- if (!await open(running.url)) log('Could not open the browser. Open the URL above manually.');
104
- log('Press Ctrl-C to stop the viewer.');
105
- return running;
106
- }
107
-
108
- async function main() {
109
- const args = process.argv.slice(2);
110
- const portIndex = args.indexOf('--port');
111
- let port = 0;
112
- if (portIndex !== -1) {
113
- const value = args[portIndex + 1];
114
- if (!value || !/^\d+$/.test(value) || Number(value) > 65535) {
115
- throw new Error('--port must be an integer from 0 to 65535');
116
- }
117
- port = Number(value);
118
- args.splice(portIndex, 2);
119
- }
120
- if (args.length !== 0) throw new Error('Usage: node view.mjs [--port <port>]');
121
- const { server } = await runViewer(process.cwd(), port);
122
- const close = () => server.close(() => process.exit(0));
123
- process.once('SIGINT', close);
124
- process.once('SIGTERM', close);
125
- }
126
-
127
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
128
- main().catch((error) => {
129
- console.error(error.message);
130
- process.exitCode = 1;
131
- });
132
- }
@@ -1,313 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
7
- <title>Manual test plan</title>
8
- <style>
9
- :root {
10
- color-scheme: light;
11
- --ink: #17202a;
12
- --muted: #59636e;
13
- --paper: #f5f1e8;
14
- --card: #fffdf8;
15
- --line: #d8d0c2;
16
- --navy: #16324f;
17
- --orange: #d45b2c;
18
- --green: #27745a;
19
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
- }
21
- * { box-sizing: border-box; }
22
- body { margin: 0; color: var(--ink); background: var(--paper); }
23
- header { color: #fff; background: var(--navy); border-bottom: 5px solid var(--orange); }
24
- .header-inner, main { width: min(1080px, calc(100% - 32px)); margin: 0 auto; }
25
- .header-inner { padding: 38px 0 30px; }
26
- .eyebrow { margin: 0 0 8px; color: #c7d6e5; font: 700 12px/1.2 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
27
- h1 { max-width: 850px; margin: 0; font: 750 clamp(30px, 6vw, 58px)/1.03 Georgia, serif; letter-spacing: -.025em; }
28
- .subtitle { max-width: 760px; margin: 12px 0 0; color: #dbe6ef; font-size: 16px; }
29
- .toolbar { position: sticky; top: 0; z-index: 5; background: rgba(245, 241, 232, .96); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }
30
- .toolbar-inner { width: min(1080px, calc(100% - 32px)); min-height: 68px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
31
- .plan-select { display: flex; align-items: center; gap: 8px; color: var(--muted); font: 750 12px/1 ui-monospace, monospace; text-transform: uppercase; }
32
- select { max-width: min(360px, 35vw); padding: 9px 32px 9px 10px; color: var(--ink); background: var(--card); border: 1px solid var(--line); border-radius: 4px; font: 650 14px/1.2 Inter, ui-sans-serif, system-ui, sans-serif; text-transform: none; }
33
- .progress-copy { min-width: 110px; font-weight: 750; }
34
- .progress-track { height: 9px; flex: 1; overflow: hidden; background: #ddd6ca; border-radius: 99px; }
35
- .progress-fill { width: 0; height: 100%; background: var(--green); transition: width .18s ease; }
36
- button { border: 1px solid var(--navy); padding: 8px 13px; color: var(--navy); background: transparent; border-radius: 4px; font: inherit; font-weight: 700; cursor: pointer; }
37
- button:hover { color: #fff; background: var(--navy); }
38
- main { padding: 34px 0 80px; }
39
- .intro, .conventions, .callout { max-width: 850px; margin: 0 0 20px; }
40
- .conventions { padding: 18px 20px; background: #e7edf1; border-left: 4px solid var(--navy); }
41
- .callout { padding: 18px 20px; background: #fff0e8; border: 1px solid #e2aa91; border-left: 6px solid var(--orange); }
42
- section { margin-top: 52px; }
43
- .section-heading { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: baseline; padding-bottom: 12px; border-bottom: 2px solid var(--navy); }
44
- .section-number { color: var(--orange); font: 800 14px/1 ui-monospace, monospace; }
45
- h2 { margin: 0; font: 750 clamp(22px, 4vw, 32px)/1.1 Georgia, serif; }
46
- .section-intro { max-width: 820px; margin: 15px 0 0; color: var(--muted); }
47
- .tests { display: grid; gap: 14px; margin-top: 20px; }
48
- article { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; padding: 20px; background: var(--card); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 3px 0 rgba(23, 32, 42, .06); }
49
- article.info { grid-template-columns: 1fr; border-left: 5px solid var(--navy); }
50
- article.checked { border-color: #91b7a8; background: #f4faf6; }
51
- input[type="checkbox"] { width: 21px; height: 21px; margin: 2px 0 0; accent-color: var(--green); cursor: pointer; }
52
- h3 { margin: 0; font-size: 18px; line-height: 1.3; }
53
- .test-id { margin-right: 8px; color: var(--orange); font: 800 13px/1 ui-monospace, monospace; }
54
- .optional { display: inline-block; margin-left: 8px; padding: 2px 6px; color: var(--muted); border: 1px solid var(--line); border-radius: 3px; font: 700 10px/1.4 ui-monospace, monospace; text-transform: uppercase; }
55
- .rich { line-height: 1.58; }
56
- .rich > :first-child { margin-top: 0; }
57
- .rich > :last-child { margin-bottom: 0; }
58
- .note { margin-top: 10px; color: var(--muted); }
59
- pre { overflow-x: auto; margin: 14px 0 0; padding: 15px; color: #e9f0f5; background: #111c26; border-radius: 5px; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
60
- .language { display: block; margin-bottom: 8px; color: #92adc4; font-size: 10px; letter-spacing: .1em; text-transform: uppercase; }
61
- .expect { margin-top: 15px; padding: 14px 16px; background: #edf5f1; border-left: 4px solid var(--green); }
62
- .expect::before { display: block; margin-bottom: 5px; color: var(--green); content: "EXPECT"; font: 800 10px/1 ui-monospace, monospace; letter-spacing: .1em; }
63
- .subs { display: grid; gap: 9px; margin-top: 15px; }
64
- .sub { display: grid; grid-template-columns: 24px 1fr; gap: 8px; align-items: start; }
65
- .sub input { width: 17px; height: 17px; }
66
- .sub-id { color: var(--muted); font: 700 12px/1.5 ui-monospace, monospace; }
67
- .error { margin: 50px auto; padding: 22px; color: #761e16; background: #fff0ee; border: 1px solid #d88f87; white-space: pre-wrap; }
68
- .empty { margin: 50px auto; padding: 28px; text-align: center; color: var(--muted); background: var(--card); border: 1px dashed var(--line); }
69
- a { color: #145d85; }
70
- code, kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
71
- @media (max-width: 620px) {
72
- .header-inner, main, .toolbar-inner { width: min(100% - 22px, 1080px); }
73
- .header-inner { padding: 28px 0 24px; }
74
- .toolbar-inner { gap: 10px; }
75
- .toolbar-inner { flex-wrap: wrap; padding: 10px 0; }
76
- .plan-select { width: 100%; }
77
- select { max-width: none; flex: 1; }
78
- .progress-copy { min-width: 86px; font-size: 13px; }
79
- article { padding: 16px 14px; }
80
- section { margin-top: 38px; }
81
- }
82
- </style>
83
- </head>
84
- <body>
85
- <header><div class="header-inner"><p class="eyebrow" id="scope">Test-plan library</p><h1 id="title">Select a test plan</h1><p class="subtitle" id="subtitle">Plans are loaded from the repository's testplans directory.</p></div></header>
86
- <div class="toolbar"><div class="toolbar-inner"><label class="plan-select">Plan <select id="plan-picker"><option>Loading plans...</option></select></label><div class="progress-copy" id="progress">0 / 0</div><div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div><button id="reset" type="button">Reset</button></div></div>
87
- <main id="content"></main>
88
- <script>
89
- const allowedTags = new Set(['A', 'B', 'BR', 'CODE', 'EM', 'I', 'KBD', 'LI', 'OL', 'P', 'SPAN', 'STRONG', 'UL']);
90
- const lastPlanKey = 'manual-testplan:last-plan';
91
- let storageKey = '';
92
- let completed = new Set();
93
- let total = 0;
94
-
95
- function sanitizedFragment(html) {
96
- const template = document.createElement('template');
97
- template.innerHTML = html || '';
98
- for (const element of [...template.content.querySelectorAll('*')]) {
99
- const href = element.getAttribute('href') || '';
100
- if (['EMBED', 'IFRAME', 'MATH', 'OBJECT', 'SCRIPT', 'STYLE', 'SVG'].includes(element.tagName)) {
101
- element.remove();
102
- continue;
103
- }
104
- if (!allowedTags.has(element.tagName)) {
105
- element.replaceWith(...element.childNodes);
106
- continue;
107
- }
108
- for (const attribute of [...element.attributes]) element.removeAttribute(attribute.name);
109
- if (element.tagName === 'A') {
110
- if (/^(https?:|mailto:|#)/i.test(href)) {
111
- element.setAttribute('href', href);
112
- element.setAttribute('rel', 'noreferrer');
113
- }
114
- }
115
- }
116
- return template.content;
117
- }
118
-
119
- function rich(className, html) {
120
- const element = document.createElement('div');
121
- element.className = `rich ${className}`;
122
- element.append(sanitizedFragment(html));
123
- return element;
124
- }
125
-
126
- function save() {
127
- try { localStorage.setItem(storageKey, JSON.stringify([...completed])); } catch {}
128
- }
129
-
130
- function updateProgress() {
131
- const done = completed.size;
132
- document.getElementById('progress').textContent = `${done} / ${total}`;
133
- document.getElementById('progress-fill').style.width = `${total ? (done / total) * 100 : 0}%`;
134
- }
135
-
136
- function checkbox(id, article) {
137
- const input = document.createElement('input');
138
- input.type = 'checkbox';
139
- input.checked = completed.has(id);
140
- input.setAttribute('aria-label', `Mark ${id} complete`);
141
- input.addEventListener('change', () => {
142
- if (input.checked) completed.add(id); else completed.delete(id);
143
- if (article) article.classList.toggle('checked', input.checked);
144
- save();
145
- updateProgress();
146
- });
147
- return input;
148
- }
149
-
150
- function renderTest(test, kind) {
151
- const article = document.createElement('article');
152
- if (kind === 'info') article.className = 'info';
153
- const body = document.createElement('div');
154
- if (kind === 'checklist') {
155
- article.append(checkbox(test.id, article));
156
- article.classList.toggle('checked', completed.has(test.id));
157
- }
158
- const heading = document.createElement('h3');
159
- const id = document.createElement('span');
160
- id.className = 'test-id';
161
- id.textContent = test.id;
162
- heading.append(id, document.createTextNode(test.title));
163
- if (test.optional) {
164
- const optional = document.createElement('span');
165
- optional.className = 'optional';
166
- optional.textContent = 'Optional';
167
- heading.append(optional);
168
- }
169
- body.append(heading);
170
- if (test.note) body.append(rich('note', test.note));
171
- for (const [language, code] of test.code || []) {
172
- const pre = document.createElement('pre');
173
- const label = document.createElement('span');
174
- label.className = 'language';
175
- label.textContent = language;
176
- const content = document.createElement('code');
177
- content.textContent = code;
178
- pre.append(label, content);
179
- body.append(pre);
180
- }
181
- if (test.expect) body.append(rich('expect', test.expect));
182
- if (test.subs?.length) {
183
- const subs = document.createElement('div');
184
- subs.className = 'subs';
185
- for (const [subId, copy] of test.subs) {
186
- const row = document.createElement('label');
187
- row.className = 'sub';
188
- if (kind === 'checklist') row.append(checkbox(subId));
189
- const text = document.createElement('span');
190
- const idText = document.createElement('span');
191
- idText.className = 'sub-id';
192
- idText.textContent = `${subId} `;
193
- text.append(idText, sanitizedFragment(copy));
194
- row.append(text);
195
- subs.append(row);
196
- }
197
- body.append(subs);
198
- }
199
- article.append(body);
200
- return article;
201
- }
202
-
203
- function render(plan) {
204
- document.title = `${plan.title} - Manual test plan`;
205
- document.getElementById('scope').textContent = plan.scope;
206
- document.getElementById('title').textContent = plan.title;
207
- document.getElementById('subtitle').textContent = plan.subtitle || '';
208
- storageKey = `manual-testplan:${plan.scope}`;
209
- const allIds = plan.sections.flatMap((section) => section.kind === 'checklist'
210
- ? section.tests.flatMap((test) => [test.id, ...(test.subs || []).map(([id]) => id)])
211
- : []);
212
- total = allIds.length;
213
- try {
214
- completed = new Set(JSON.parse(localStorage.getItem(storageKey) || '[]').filter((id) => allIds.includes(id)));
215
- } catch { completed = new Set(); }
216
-
217
- const content = document.getElementById('content');
218
- content.className = '';
219
- content.replaceChildren();
220
- if (plan.intro) content.append(rich('intro', plan.intro));
221
- if (plan.callout) content.append(rich('callout', plan.callout));
222
- if (plan.conventions) content.append(rich('conventions', plan.conventions));
223
- for (const section of plan.sections) {
224
- const sectionElement = document.createElement('section');
225
- const heading = document.createElement('div');
226
- heading.className = 'section-heading';
227
- const number = document.createElement('span');
228
- number.className = 'section-number';
229
- number.textContent = section.n;
230
- const title = document.createElement('h2');
231
- title.textContent = section.title;
232
- heading.append(number, title);
233
- sectionElement.append(heading);
234
- if (section.intro) sectionElement.append(rich('section-intro', section.intro));
235
- const tests = document.createElement('div');
236
- tests.className = 'tests';
237
- for (const test of section.tests) tests.append(renderTest(test, section.kind));
238
- sectionElement.append(tests);
239
- content.append(sectionElement);
240
- }
241
- updateProgress();
242
- }
243
-
244
- function showMessage(copy, className = 'empty') {
245
- storageKey = '';
246
- completed = new Set();
247
- total = 0;
248
- updateProgress();
249
- document.getElementById('scope').textContent = 'Test-plan library';
250
- document.getElementById('title').textContent = 'Select a test plan';
251
- document.getElementById('subtitle').textContent = 'Plans are loaded from the repository\'s testplans directory.';
252
- const content = document.getElementById('content');
253
- content.className = className;
254
- content.textContent = copy;
255
- }
256
-
257
- async function loadPlan(file) {
258
- const response = await fetch(`/plan?file=${encodeURIComponent(file)}`);
259
- const body = await response.json();
260
- if (!response.ok) throw new Error((body.errors || [`Plan request failed (${response.status})`]).join('\n'));
261
- try { localStorage.setItem(lastPlanKey, file); } catch {}
262
- render(body);
263
- }
264
-
265
- async function loadLibrary() {
266
- const response = await fetch('/plans');
267
- if (!response.ok) throw new Error(`Plan-list request failed (${response.status})`);
268
- const plans = await response.json();
269
- const picker = document.getElementById('plan-picker');
270
- picker.replaceChildren(new Option(plans.length ? 'Choose a plan...' : 'No test plans found', ''));
271
- for (const plan of plans) {
272
- const suffix = plan.valid ? '' : ' (invalid)';
273
- picker.append(new Option(`${plan.title || plan.file}${suffix}`, plan.file));
274
- }
275
- if (plans.length === 0) {
276
- showMessage('No *.testplan.json files were found in testplans/.');
277
- return;
278
- }
279
- let lastPlan = '';
280
- try { lastPlan = localStorage.getItem(lastPlanKey) || ''; } catch {}
281
- const selected = plans.find((plan) => plan.file === lastPlan && plan.valid);
282
- if (selected) {
283
- picker.value = selected.file;
284
- await loadPlan(selected.file);
285
- } else {
286
- showMessage('Choose a test plan from the Plan menu above.');
287
- }
288
- picker.addEventListener('change', async () => {
289
- if (!picker.value) {
290
- showMessage('Choose a test plan from the Plan menu above.');
291
- return;
292
- }
293
- const plan = plans.find((item) => item.file === picker.value);
294
- if (!plan?.valid) {
295
- showMessage(`Cannot open ${picker.value}:\n${(plan?.errors || ['Invalid test plan']).join('\n')}`, 'error');
296
- return;
297
- }
298
- try { await loadPlan(picker.value); }
299
- catch (error) { showMessage(`Unable to load plan: ${error.message}`, 'error'); }
300
- });
301
- }
302
-
303
- document.getElementById('reset').addEventListener('click', () => {
304
- if (!storageKey || !confirm('Clear completion state for this plan?')) return;
305
- completed.clear();
306
- save();
307
- location.reload();
308
- });
309
-
310
- loadLibrary().catch((error) => showMessage(`Unable to load test-plan library: ${error.message}`, 'error'));
311
- </script>
312
- </body>
313
- </html>