bearings 0.3.4 → 0.4.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.
- package/dist/{chunk-GEIRG7UT.js → chunk-KQPOIH7N.js} +13 -1
- package/dist/cli.js +24 -3
- package/dist/{update-XOUXGD2Z.js → update-TBMTUJUT.js} +1 -1
- package/package.json +1 -1
- package/templates/agents/skills/manual-testplan/SKILL.md +50 -0
- package/templates/agents/skills/manual-testplan/schema.reference.json +67 -0
- package/templates/agents/skills/manual-testplan/scripts/validate.mjs +131 -0
- package/templates/agents/skills/manual-testplan/scripts/view.mjs +132 -0
- package/templates/agents/skills/manual-testplan/viewer.html +313 -0
|
@@ -13,6 +13,13 @@ var STARTER_SKILL_NAMES = [
|
|
|
13
13
|
"recording-decisions"
|
|
14
14
|
];
|
|
15
15
|
var SKILLS = STARTER_SKILL_NAMES;
|
|
16
|
+
var MANUAL_TESTPLAN_FILES = [
|
|
17
|
+
"SKILL.md",
|
|
18
|
+
"schema.reference.json",
|
|
19
|
+
"scripts/validate.mjs",
|
|
20
|
+
"scripts/view.mjs",
|
|
21
|
+
"viewer.html"
|
|
22
|
+
];
|
|
16
23
|
function starterSkillNameFromTarget(targetPath) {
|
|
17
24
|
return STARTER_SKILL_NAMES.find((skillName) => targetPath === `.agents/skills/${skillName}/SKILL.md`);
|
|
18
25
|
}
|
|
@@ -40,6 +47,11 @@ var SCAFFOLD = [
|
|
|
40
47
|
template: `agents/skills/${skill}/SKILL.md`,
|
|
41
48
|
target: `.agents/skills/${skill}/SKILL.md`,
|
|
42
49
|
owner: "bearings"
|
|
50
|
+
})),
|
|
51
|
+
...MANUAL_TESTPLAN_FILES.map((file) => ({
|
|
52
|
+
template: `agents/skills/manual-testplan/${file}`,
|
|
53
|
+
target: `.agents/skills/manual-testplan/${file}`,
|
|
54
|
+
owner: "bearings"
|
|
43
55
|
}))
|
|
44
56
|
];
|
|
45
57
|
|
|
@@ -680,7 +692,7 @@ async function runFreshInit(repoDir, flags, version) {
|
|
|
680
692
|
async function runInit(repoDir, flags, version) {
|
|
681
693
|
const state = await inspectManifest(repoDir);
|
|
682
694
|
if (state.kind === "absent") return runFreshInit(repoDir, flags, version);
|
|
683
|
-
const { runUpdate } = await import("./update-
|
|
695
|
+
const { runUpdate } = await import("./update-TBMTUJUT.js");
|
|
684
696
|
return runUpdate(repoDir, flags, version, state);
|
|
685
697
|
}
|
|
686
698
|
|
package/dist/cli.js
CHANGED
|
@@ -8,13 +8,13 @@ import {
|
|
|
8
8
|
skillBaselinePath,
|
|
9
9
|
starterSkillNameFromTarget,
|
|
10
10
|
validateHarnesses
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-KQPOIH7N.js";
|
|
12
12
|
|
|
13
13
|
// src/cli.ts
|
|
14
14
|
import { Command } from "commander";
|
|
15
15
|
import { readFileSync } from "fs";
|
|
16
16
|
import { fileURLToPath } from "url";
|
|
17
|
-
import { dirname as dirname2, join as
|
|
17
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
18
18
|
|
|
19
19
|
// src/verifier.ts
|
|
20
20
|
import { access, lstat, readFile, readdir, readlink, stat } from "fs/promises";
|
|
@@ -150,9 +150,27 @@ async function runVerify(repoDir) {
|
|
|
150
150
|
return v.failures.length ? 1 : 0;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// src/commands/testplan.ts
|
|
154
|
+
import { access as access2 } from "fs/promises";
|
|
155
|
+
import { join as join2 } from "path";
|
|
156
|
+
import { spawn } from "child_process";
|
|
157
|
+
async function runTestplan(repoDir) {
|
|
158
|
+
const launcher = join2(repoDir, ".agents/skills/manual-testplan/scripts/view.mjs");
|
|
159
|
+
try {
|
|
160
|
+
await access2(launcher);
|
|
161
|
+
} catch {
|
|
162
|
+
throw new Error("Manual test-plan viewer is not installed. Run `bearings init` to update this repository.");
|
|
163
|
+
}
|
|
164
|
+
return new Promise((resolve2, reject) => {
|
|
165
|
+
const child = spawn(process.execPath, [launcher], { cwd: repoDir, stdio: "inherit" });
|
|
166
|
+
child.once("error", reject);
|
|
167
|
+
child.once("exit", (code) => resolve2(code ?? 1));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
153
171
|
// src/cli.ts
|
|
154
172
|
var pkg = JSON.parse(
|
|
155
|
-
readFileSync(
|
|
173
|
+
readFileSync(join3(dirname2(fileURLToPath(import.meta.url)), "../package.json"), "utf8")
|
|
156
174
|
);
|
|
157
175
|
var program = new Command("bearings").version(pkg.version);
|
|
158
176
|
program.command("init").description("Scaffold the agent-friendly setup in the current repository").option("--harness <name...>", "claude and/or opencode").option("--copy", "copy instead of symlink").option("-y, --yes", "accept defaults, no prompts").action(async (o) => {
|
|
@@ -166,6 +184,9 @@ program.command("init").description("Scaffold the agent-friendly setup in the cu
|
|
|
166
184
|
program.command("verify").description("Check the agent-friendly setup for drift and breakage").action(async () => {
|
|
167
185
|
process.exitCode = await runVerify(process.cwd());
|
|
168
186
|
});
|
|
187
|
+
program.command("testplan").description("Open the manual test-plan visualizer").action(async () => {
|
|
188
|
+
process.exitCode = await runTestplan(process.cwd());
|
|
189
|
+
});
|
|
169
190
|
program.parseAsync().catch((error) => {
|
|
170
191
|
console.error(error instanceof Error ? error.message : String(error));
|
|
171
192
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: manual-testplan
|
|
3
|
+
description: Generate a human-run browser or curl happy-path test checklist from agreed product scope. Use when the developer asks for a manual test plan, acceptance checklist, smoke-test guide, or release verification flow.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Manual Test Plan
|
|
7
|
+
|
|
8
|
+
Generate one evidence-backed JSON plan for a human tester. The plan describes
|
|
9
|
+
reachable behavior; the bundled viewer owns presentation and completion state.
|
|
10
|
+
|
|
11
|
+
## Workflow
|
|
12
|
+
|
|
13
|
+
1. Read the repository's state/source maps and
|
|
14
|
+
`.agents/skills/manual-testplan/schema.reference.json`.
|
|
15
|
+
2. Resolve the requested scope to actual public routes, commands, API contracts,
|
|
16
|
+
or UI flows. Inspect runtime wiring for authentication, feature flags, stub
|
|
17
|
+
adapters, workers, tenancy, seed data, and external integrations.
|
|
18
|
+
3. If the interface set is empty, broad, or uncertain, list the interfaces you
|
|
19
|
+
found and ask the developer to confirm or narrow the scope.
|
|
20
|
+
4. Write exactly one plan at `testplans/<scope>.testplan.json` after the scope is
|
|
21
|
+
clear. Create the directory when needed.
|
|
22
|
+
5. Run
|
|
23
|
+
`node .agents/skills/manual-testplan/scripts/validate.mjs testplans/<scope>.testplan.json`.
|
|
24
|
+
Fix every reported violation until it exits zero and prints `OK`.
|
|
25
|
+
6. Run `node .agents/skills/manual-testplan/scripts/view.mjs`. It opens the
|
|
26
|
+
browser automatically; keep the process available while the developer tests.
|
|
27
|
+
Report the printed localhost URL only when automatic browser launch fails.
|
|
28
|
+
|
|
29
|
+
## Content Policy
|
|
30
|
+
|
|
31
|
+
- Start with section `0`, "Prerequisites & bootstrap". Include only setup needed
|
|
32
|
+
by this scope; use sub-checks for independent confirmations.
|
|
33
|
+
- Cover happy paths through real interfaces and actual request, response, and UI
|
|
34
|
+
shapes. Fold cheap negative checks into success expectations instead of
|
|
35
|
+
creating separate rows.
|
|
36
|
+
- End with "Known limitations" as an `info` section. State stubs, mocks, feature
|
|
37
|
+
flags, external-delivery behavior, required seed data, and incomplete wiring.
|
|
38
|
+
- Set the top-level `callout` to the single biggest caveat a tester must not
|
|
39
|
+
mistake for a failure.
|
|
40
|
+
- Use raw HTML only in rich-text fields. Keep it small and semantic; the viewer
|
|
41
|
+
sanitizes it before rendering. Keep code as `[language, text]` tuples.
|
|
42
|
+
- Never invent interfaces or expected behavior. Requirements describe intent;
|
|
43
|
+
running code, configuration, schemas, and tests establish what is reachable.
|
|
44
|
+
- Keep completion state out of the plan. The viewer stores it by `scope`.
|
|
45
|
+
|
|
46
|
+
## Completion
|
|
47
|
+
|
|
48
|
+
Finish only when the validator prints `OK`, every test maps to a reachable
|
|
49
|
+
interface, limitations match runtime wiring, and the localhost viewer loads the
|
|
50
|
+
generated plan.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://bearings.dev/schemas/manual-testplan.json",
|
|
4
|
+
"title": "Manual test plan",
|
|
5
|
+
"$comment": "validate.mjs is the executable authority and additionally enforces globally unique test and sub-check IDs.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["scope", "title", "sections"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"scope": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
12
|
+
},
|
|
13
|
+
"title": { "type": "string", "pattern": "\\S" },
|
|
14
|
+
"subtitle": { "type": "string" },
|
|
15
|
+
"intro": { "type": "string" },
|
|
16
|
+
"callout": { "type": "string" },
|
|
17
|
+
"conventions": { "type": "string" },
|
|
18
|
+
"sections": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"minItems": 1,
|
|
21
|
+
"items": { "$ref": "#/$defs/section" }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"$defs": {
|
|
25
|
+
"pair": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"prefixItems": [{ "type": "string" }, { "type": "string" }],
|
|
28
|
+
"items": false,
|
|
29
|
+
"minItems": 2,
|
|
30
|
+
"maxItems": 2
|
|
31
|
+
},
|
|
32
|
+
"test": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"required": ["id", "title"],
|
|
35
|
+
"properties": {
|
|
36
|
+
"id": { "type": "string", "pattern": "\\S" },
|
|
37
|
+
"title": { "type": "string", "pattern": "\\S" },
|
|
38
|
+
"note": { "type": "string" },
|
|
39
|
+
"code": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": { "$ref": "#/$defs/pair" }
|
|
42
|
+
},
|
|
43
|
+
"expect": { "type": "string" },
|
|
44
|
+
"subs": {
|
|
45
|
+
"type": "array",
|
|
46
|
+
"items": { "$ref": "#/$defs/pair" }
|
|
47
|
+
},
|
|
48
|
+
"optional": { "type": "boolean" }
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"section": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["n", "title", "kind", "tests"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"n": { "type": "string", "pattern": "\\S" },
|
|
56
|
+
"title": { "type": "string", "pattern": "\\S" },
|
|
57
|
+
"kind": { "enum": ["checklist", "info"] },
|
|
58
|
+
"intro": { "type": "string" },
|
|
59
|
+
"tests": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"minItems": 1,
|
|
62
|
+
"items": { "$ref": "#/$defs/test" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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();
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
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>
|