edsger 0.84.0 → 0.85.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/README.md +37 -0
- package/assets/README.md +18 -9
- package/assets/manifest.yaml +5 -1
- package/assets/mindmap-viewer/assets/{mindmap-Di5RM-4N.js → mindmap-C50Y8qu4.js} +1 -1
- package/assets/mindmap-viewer/mindmap.html +1 -1
- package/assets/mindmap-viewer-single.html +1 -1
- package/assets/schema/testcases.schema.json +69 -0
- package/assets/skills/testcases/SKILL.md +73 -0
- package/assets/testcases/base.yaml +147 -0
- package/assets/testcases-viewer/assets/testcases-DaNbQxip.js +2 -0
- package/assets/testcases-viewer/assets/testcases-f2mIbfYU.css +1 -0
- package/assets/testcases-viewer/testcases.html +31 -0
- package/assets/testcases-viewer-single.html +32 -0
- package/dist/index.js +533 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFileSync as readFileSync4 } from "fs";
|
|
5
5
|
import { resolve as resolve2 } from "path";
|
|
6
6
|
import { Command, Option } from "commander";
|
|
7
|
-
import
|
|
7
|
+
import pc13 from "picocolors";
|
|
8
8
|
|
|
9
9
|
// src/commands/audit.ts
|
|
10
10
|
import { appendFileSync, mkdirSync, writeFileSync } from "fs";
|
|
@@ -127,7 +127,7 @@ function resolveStandard(kind, opts, config, configDir, forcedLanguages) {
|
|
|
127
127
|
const roots = [
|
|
128
128
|
{ path: bundled, label: opts.assetsPath ? `assets:${opts.assetsPath}` : "bundled" }
|
|
129
129
|
];
|
|
130
|
-
const perKind = kind === "benchmark" ? config.benchmark?.standards : kind === "diagrams" ? config.diagrams?.standards : kind === "mindmap" ? config.mindmap?.standards : kind === "audit" ? config.audit?.standards : config.review?.standards;
|
|
130
|
+
const perKind = kind === "benchmark" ? config.benchmark?.standards : kind === "diagrams" ? config.diagrams?.standards : kind === "mindmap" ? config.mindmap?.standards : kind === "testcases" ? config.testcases?.standards : kind === "audit" ? config.audit?.standards : config.review?.standards;
|
|
131
131
|
const overrideSpec = perKind ?? config.standards;
|
|
132
132
|
if (overrideSpec && !opts.assetsPath) {
|
|
133
133
|
roots.push({ path: resolveRootPath(overrideSpec, configDir), label: `config:${overrideSpec}` });
|
|
@@ -188,6 +188,12 @@ function mergeLayers(kind, layers) {
|
|
|
188
188
|
{}
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
|
+
if (kind === "testcases") {
|
|
192
|
+
return layers.reduce(
|
|
193
|
+
(acc, layer) => mergeTestcases(acc, layer),
|
|
194
|
+
{}
|
|
195
|
+
);
|
|
196
|
+
}
|
|
191
197
|
if (kind === "audit") {
|
|
192
198
|
return layers.reduce(
|
|
193
199
|
(acc, layer) => mergeAudit(acc, layer),
|
|
@@ -238,6 +244,24 @@ function mergeMindmap(base, overlay) {
|
|
|
238
244
|
aspects
|
|
239
245
|
};
|
|
240
246
|
}
|
|
247
|
+
function mergeTestcases(base, overlay) {
|
|
248
|
+
const aspects = mergeById(base.aspects ?? [], overlay.aspects ?? [], (b, o) => ({
|
|
249
|
+
...b,
|
|
250
|
+
...o
|
|
251
|
+
}));
|
|
252
|
+
return {
|
|
253
|
+
...base,
|
|
254
|
+
...overlay,
|
|
255
|
+
// Preserve the base layer's identity rather than the last overlay's.
|
|
256
|
+
id: base.id ?? overlay.id,
|
|
257
|
+
name: base.name ?? overlay.name,
|
|
258
|
+
version: base.version ?? overlay.version,
|
|
259
|
+
kind: "testcases",
|
|
260
|
+
guidance: overlay.guidance ?? base.guidance,
|
|
261
|
+
maxDepth: overlay.maxDepth ?? base.maxDepth,
|
|
262
|
+
aspects
|
|
263
|
+
};
|
|
264
|
+
}
|
|
241
265
|
function mergeBenchmark(base, overlay) {
|
|
242
266
|
const categories = mergeById(
|
|
243
267
|
base.categories ?? [],
|
|
@@ -593,6 +617,9 @@ function assembleDiagramData(diagramsDir) {
|
|
|
593
617
|
function assembleMindmapData(mindmapDir) {
|
|
594
618
|
return assembleFrom(mindmapDir, "mindmap.json");
|
|
595
619
|
}
|
|
620
|
+
function assembleTestcasesData(testcasesDir) {
|
|
621
|
+
return assembleFrom(testcasesDir, "testcases.json");
|
|
622
|
+
}
|
|
596
623
|
function assembleAuditData(auditDir) {
|
|
597
624
|
return assembleFrom(auditDir, "report.json");
|
|
598
625
|
}
|
|
@@ -614,6 +641,9 @@ function findDiagramViewerDir() {
|
|
|
614
641
|
function findMindmapViewerDir() {
|
|
615
642
|
return findViewerDir("mindmap-viewer", "mindmap.html");
|
|
616
643
|
}
|
|
644
|
+
function findTestcasesViewerDir() {
|
|
645
|
+
return findViewerDir("testcases-viewer", "testcases.html");
|
|
646
|
+
}
|
|
617
647
|
function findAuditViewerDir() {
|
|
618
648
|
return findViewerDir("audit-viewer", "audit.html");
|
|
619
649
|
}
|
|
@@ -635,6 +665,9 @@ function readDiagramViewerSingleTemplate() {
|
|
|
635
665
|
function readMindmapViewerSingleTemplate() {
|
|
636
666
|
return readSingleTemplate("mindmap-viewer-single.html");
|
|
637
667
|
}
|
|
668
|
+
function readTestcasesViewerSingleTemplate() {
|
|
669
|
+
return readSingleTemplate("testcases-viewer-single.html");
|
|
670
|
+
}
|
|
638
671
|
function readAuditViewerSingleTemplate() {
|
|
639
672
|
return readSingleTemplate("audit-viewer-single.html");
|
|
640
673
|
}
|
|
@@ -658,6 +691,9 @@ function renderDiagramsSingleFile(template, data) {
|
|
|
658
691
|
function renderMindmapSingleFile(template, data) {
|
|
659
692
|
return renderSingleFileWith(template, data, "__EDSGER_MINDMAP__");
|
|
660
693
|
}
|
|
694
|
+
function renderTestcasesSingleFile(template, data) {
|
|
695
|
+
return renderSingleFileWith(template, data, "__EDSGER_TESTCASES__");
|
|
696
|
+
}
|
|
661
697
|
function renderAuditSingleFile(template, data) {
|
|
662
698
|
return renderSingleFileWith(template, data, "__EDSGER_AUDIT__");
|
|
663
699
|
}
|
|
@@ -1024,6 +1060,30 @@ var MindmapResultSchema = z.object({
|
|
|
1024
1060
|
),
|
|
1025
1061
|
notes: z.string().nullable().default(null).describe("Optional notes, e.g. areas intentionally left out and why")
|
|
1026
1062
|
});
|
|
1063
|
+
var TestcasePrioritySchema = z.enum(["critical", "high", "medium", "low"]);
|
|
1064
|
+
var TestcaseNodeSchema = z.object({
|
|
1065
|
+
id: z.string().describe("Unique kebab-case node id, e.g. 'core-flows' or 'login-empty-password'"),
|
|
1066
|
+
parentId: z.string().nullable().default(null).describe("id of the parent node; null ONLY for the single root node"),
|
|
1067
|
+
title: z.string().describe("Short outline-bullet title, 2-8 words"),
|
|
1068
|
+
summary: z.string().describe("One or two sentences: what this area covers, or what this case verifies"),
|
|
1069
|
+
kind: z.string().default("topic").describe("Tag: area, feature, flow, scenario, case, or topic ('case' = executable leaf)"),
|
|
1070
|
+
priority: TestcasePrioritySchema.nullable().default(null).describe("Case priority (critical/high/medium/low); null for grouping nodes"),
|
|
1071
|
+
preconditions: z.array(z.string()).default([]).describe("State a tester must prepare before the steps (cases only)"),
|
|
1072
|
+
steps: z.array(z.string()).default([]).describe("Exact, observable manual actions, in order (cases only)"),
|
|
1073
|
+
expected: z.string().nullable().default(null).describe("The single expected outcome the tester verifies (cases only)"),
|
|
1074
|
+
files: z.array(z.string()).default([]).describe("Repo-relative files implementing the behaviour under test")
|
|
1075
|
+
});
|
|
1076
|
+
var TestcasesResultSchema = z.object({
|
|
1077
|
+
profile: z.object({
|
|
1078
|
+
summary: z.string().describe("2-4 sentence characterisation of the project"),
|
|
1079
|
+
kinds: z.array(z.string()).default([]).describe("Project kind tags, e.g. cli, rest-api, spa, library, data-pipeline, monorepo"),
|
|
1080
|
+
characteristics: z.array(z.string()).default([]).describe("Notable traits that shaped the plan (owns a Postgres DB, monorepo, \u2026)")
|
|
1081
|
+
}).describe("The project profile behind the plan's shape"),
|
|
1082
|
+
nodes: z.array(TestcaseNodeSchema).describe(
|
|
1083
|
+
"The whole test plan as a flat node list: exactly one root (parentId=null), children listed in reading order after their parent, leaves as concrete cases"
|
|
1084
|
+
),
|
|
1085
|
+
notes: z.string().nullable().default(null).describe("Optional notes, e.g. areas intentionally left untested and why")
|
|
1086
|
+
});
|
|
1027
1087
|
|
|
1028
1088
|
// src/commands/audit.ts
|
|
1029
1089
|
async function runAudit(opts, deps = { agent: runAgent }) {
|
|
@@ -2658,26 +2718,415 @@ async function runMindmapReport(opts) {
|
|
|
2658
2718
|
return 0;
|
|
2659
2719
|
}
|
|
2660
2720
|
|
|
2721
|
+
// src/commands/testcases.ts
|
|
2722
|
+
import { appendFileSync as appendFileSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
2723
|
+
import { basename as basename5, dirname as dirname6, isAbsolute as isAbsolute6, join as join12 } from "path";
|
|
2724
|
+
import pc11 from "picocolors";
|
|
2725
|
+
|
|
2726
|
+
// src/core/testcases.ts
|
|
2727
|
+
var PRIORITY_ORDER = ["critical", "high", "medium", "low"];
|
|
2728
|
+
function isCase(node) {
|
|
2729
|
+
return node.steps.length > 0 || Boolean(node.expected);
|
|
2730
|
+
}
|
|
2731
|
+
function buildTestcasesPrompt(standard, cwd, focus) {
|
|
2732
|
+
const maxDepth = standard.maxDepth ?? 5;
|
|
2733
|
+
const aspectsText = standard.aspects.map((a) => {
|
|
2734
|
+
const detect = a.detect?.length ? `
|
|
2735
|
+
detect: ${a.detect.join(", ")}` : "";
|
|
2736
|
+
return ` - id: ${a.id}
|
|
2737
|
+
name: ${a.name}
|
|
2738
|
+
appliesWhen: ${a.appliesWhen.trim()}` + detect + `
|
|
2739
|
+
guidance: ${a.guidance.trim()}`;
|
|
2740
|
+
}).join("\n");
|
|
2741
|
+
return [
|
|
2742
|
+
`Derive a MANUAL test-case plan for the repository at: ${cwd}`,
|
|
2743
|
+
``,
|
|
2744
|
+
`The plan is an outline tree (like Workflowy): the root is the project, the`,
|
|
2745
|
+
`branches are the areas a human tester should exercise, and every leaf is a`,
|
|
2746
|
+
`single concrete test case a tester who has never read the code can execute.`,
|
|
2747
|
+
`These are NOT automated code tests \u2014 they describe how a person verifies the`,
|
|
2748
|
+
`software behaves correctly by actually using it.`,
|
|
2749
|
+
...focus ? [
|
|
2750
|
+
``,
|
|
2751
|
+
`FOCUS: the user asked the plan to concentrate on: "${focus}". Give that`,
|
|
2752
|
+
`area most of the depth; keep other areas to a shallow orientation level.`
|
|
2753
|
+
] : [],
|
|
2754
|
+
``,
|
|
2755
|
+
`Method:`,
|
|
2756
|
+
`1. Explore the repository (Read/Grep/Glob and read-only Bash like ls, cat,`,
|
|
2757
|
+
` git log). Characterise the project: what kind is it (service, library, CLI,`,
|
|
2758
|
+
` SPA, data pipeline, monorepo, \u2026), what its real entry points and user-facing`,
|
|
2759
|
+
` behaviours are, and what failure modes the code actually handles.`,
|
|
2760
|
+
`2. You have a list of candidate top-level test areas ("aspects") below. Keep`,
|
|
2761
|
+
` only the ones THIS project's code supports (use appliesWhen/detect), rename`,
|
|
2762
|
+
` them to the project's own vocabulary where that is clearer, and grow each`,
|
|
2763
|
+
` into a subtree by following its guidance. Aim for 3-7 first-level areas.`,
|
|
2764
|
+
`3. Tree rules:`,
|
|
2765
|
+
` - Root node: the project \u2014 its name as the title, one line on what a tester`,
|
|
2766
|
+
` is validating. Exactly ONE root.`,
|
|
2767
|
+
` - Interior nodes group features/scenarios: short title (2-8 words) plus a`,
|
|
2768
|
+
` one-sentence summary specific to THIS codebase. Tag them kind: area,`,
|
|
2769
|
+
` feature, flow, scenario, or topic.`,
|
|
2770
|
+
` - EVERY leaf is one executable test case, tagged kind: "case", with:`,
|
|
2771
|
+
` * summary \u2014 what the case verifies and why it matters;`,
|
|
2772
|
+
` * preconditions \u2014 the state to prepare (install done, env var set, \u2026);`,
|
|
2773
|
+
` * steps \u2014 exact, observable manual actions in order ("Run \`x --y\`",`,
|
|
2774
|
+
` "Click Save", "Delete the config file"), each independently checkable;`,
|
|
2775
|
+
` * expected \u2014 the SINGLE outcome the tester verifies (message, exit`,
|
|
2776
|
+
` code, UI state, file contents);`,
|
|
2777
|
+
` * priority \u2014 critical (release blocker if broken), high (core`,
|
|
2778
|
+
` behaviour), medium (important but recoverable), or low (polish);`,
|
|
2779
|
+
` * files \u2014 the repo files implementing the behaviour under test.`,
|
|
2780
|
+
` - Ground every case in behaviour that is actually in the code: real`,
|
|
2781
|
+
` commands, flags, routes, screens, and error branches. Never invent`,
|
|
2782
|
+
` behaviour, and never write "check that it works" \u2014 say exactly what to do`,
|
|
2783
|
+
` and exactly what should happen.`,
|
|
2784
|
+
` - Maximum depth ${maxDepth} (root inclusive). Prefer depth where the risk`,
|
|
2785
|
+
` is, brevity where the behaviour is trivial \u2014 a plan a tester can work`,
|
|
2786
|
+
` through top-to-bottom, not a combinatorial matrix.`,
|
|
2787
|
+
`4. Encode the tree as a FLAT node list: each node has a unique kebab-case "id"`,
|
|
2788
|
+
` and a "parentId" referencing its parent's id (null only for the root). List`,
|
|
2789
|
+
` children in reading order right after their parent.`,
|
|
2790
|
+
`5. When finished, call submit_result exactly once with the project profile,`,
|
|
2791
|
+
` the flat node list, and optional notes.`,
|
|
2792
|
+
``,
|
|
2793
|
+
...standard.guidance ? [`Overall guidance: ${standard.guidance.trim()}`, ``] : [],
|
|
2794
|
+
`Candidate top-level test areas:`,
|
|
2795
|
+
aspectsText
|
|
2796
|
+
].join("\n");
|
|
2797
|
+
}
|
|
2798
|
+
function slugify2(text) {
|
|
2799
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "node";
|
|
2800
|
+
}
|
|
2801
|
+
function prepareTestcases(result, fallback) {
|
|
2802
|
+
const warnings = [];
|
|
2803
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2804
|
+
for (const raw of result.nodes) {
|
|
2805
|
+
const node = { ...raw, id: raw.id?.trim() || slugify2(raw.title) };
|
|
2806
|
+
if (!node.title?.trim()) {
|
|
2807
|
+
warnings.push(`dropped a node with no title (id "${node.id}")`);
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
let id = node.id;
|
|
2811
|
+
for (let n = 2; byId.has(id); n++) id = `${node.id}-${n}`;
|
|
2812
|
+
if (id !== node.id) warnings.push(`renamed duplicate node id "${node.id}" to "${id}"`);
|
|
2813
|
+
byId.set(id, { ...node, id });
|
|
2814
|
+
}
|
|
2815
|
+
const parentOf = /* @__PURE__ */ new Map();
|
|
2816
|
+
for (const node of byId.values()) {
|
|
2817
|
+
const p = node.parentId && node.parentId !== node.id ? node.parentId : null;
|
|
2818
|
+
parentOf.set(node.id, p && byId.has(p) ? p : null);
|
|
2819
|
+
if (node.parentId && !byId.has(node.parentId)) {
|
|
2820
|
+
warnings.push(`node "${node.id}" referenced missing parent "${node.parentId}"`);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
for (const id of parentOf.keys()) {
|
|
2824
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
2825
|
+
let cur = parentOf.get(id) ?? null;
|
|
2826
|
+
while (cur) {
|
|
2827
|
+
if (seen.has(cur)) {
|
|
2828
|
+
warnings.push(`broke a parent cycle at node "${cur}"`);
|
|
2829
|
+
parentOf.set(cur, null);
|
|
2830
|
+
break;
|
|
2831
|
+
}
|
|
2832
|
+
seen.add(cur);
|
|
2833
|
+
cur = parentOf.get(cur) ?? null;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
const treeOf = /* @__PURE__ */ new Map();
|
|
2837
|
+
for (const node of byId.values()) {
|
|
2838
|
+
treeOf.set(node.id, {
|
|
2839
|
+
id: node.id,
|
|
2840
|
+
title: node.title.trim(),
|
|
2841
|
+
summary: node.summary?.trim() ?? "",
|
|
2842
|
+
kind: node.kind?.trim() || "topic",
|
|
2843
|
+
priority: node.priority ?? null,
|
|
2844
|
+
preconditions: node.preconditions ?? [],
|
|
2845
|
+
steps: node.steps ?? [],
|
|
2846
|
+
expected: node.expected?.trim() || null,
|
|
2847
|
+
files: node.files ?? [],
|
|
2848
|
+
children: []
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
const roots = [];
|
|
2852
|
+
for (const node of byId.values()) {
|
|
2853
|
+
const tree = treeOf.get(node.id);
|
|
2854
|
+
const parent = parentOf.get(node.id);
|
|
2855
|
+
if (parent) treeOf.get(parent).children.push(tree);
|
|
2856
|
+
else roots.push(tree);
|
|
2857
|
+
}
|
|
2858
|
+
let root;
|
|
2859
|
+
if (roots.length === 1 && roots[0]) {
|
|
2860
|
+
root = roots[0];
|
|
2861
|
+
} else {
|
|
2862
|
+
if (roots.length > 1) warnings.push(`${roots.length} root nodes \u2014 gathered under the project`);
|
|
2863
|
+
let rootId = "root";
|
|
2864
|
+
for (let n = 2; treeOf.has(rootId); n++) rootId = `root-${n}`;
|
|
2865
|
+
root = {
|
|
2866
|
+
id: rootId,
|
|
2867
|
+
title: fallback.repo,
|
|
2868
|
+
summary: result.profile.summary,
|
|
2869
|
+
kind: "area",
|
|
2870
|
+
priority: null,
|
|
2871
|
+
preconditions: [],
|
|
2872
|
+
steps: [],
|
|
2873
|
+
expected: null,
|
|
2874
|
+
files: [],
|
|
2875
|
+
children: roots
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
const nodeCount = countNodes3(root);
|
|
2879
|
+
const depth = treeDepth2(root);
|
|
2880
|
+
const priorities = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
2881
|
+
let caseCount = 0;
|
|
2882
|
+
const tally = (node) => {
|
|
2883
|
+
if (isCase(node)) {
|
|
2884
|
+
caseCount++;
|
|
2885
|
+
priorities[node.priority ?? "medium"]++;
|
|
2886
|
+
}
|
|
2887
|
+
node.children.forEach(tally);
|
|
2888
|
+
};
|
|
2889
|
+
tally(root);
|
|
2890
|
+
if (nodeCount <= 1) log.warn("The agent returned an (almost) empty test plan.");
|
|
2891
|
+
if (caseCount === 0 && nodeCount > 1) {
|
|
2892
|
+
warnings.push("no leaf node carries steps/expected \u2014 the plan has no executable cases");
|
|
2893
|
+
}
|
|
2894
|
+
for (const w of warnings) log.warn(`testcases: ${w}`);
|
|
2895
|
+
return {
|
|
2896
|
+
profile: result.profile,
|
|
2897
|
+
root,
|
|
2898
|
+
nodeCount,
|
|
2899
|
+
caseCount,
|
|
2900
|
+
priorities,
|
|
2901
|
+
depth,
|
|
2902
|
+
warnings,
|
|
2903
|
+
notes: result.notes ?? null
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
function countNodes3(node) {
|
|
2907
|
+
return 1 + node.children.reduce((acc, c) => acc + countNodes3(c), 0);
|
|
2908
|
+
}
|
|
2909
|
+
function treeDepth2(node) {
|
|
2910
|
+
return 1 + node.children.reduce((acc, c) => Math.max(acc, treeDepth2(c)), 0);
|
|
2911
|
+
}
|
|
2912
|
+
function renderTestcasesMarkdown(prepared, meta) {
|
|
2913
|
+
const lines = [];
|
|
2914
|
+
lines.push(`# Edsger Manual Test Cases`);
|
|
2915
|
+
lines.push("");
|
|
2916
|
+
lines.push(`> ${prepared.profile.summary}`);
|
|
2917
|
+
lines.push("");
|
|
2918
|
+
lines.push(`| Field | Value |`);
|
|
2919
|
+
lines.push(`| --- | --- |`);
|
|
2920
|
+
lines.push(`| Repository | \`${meta.repo}\` |`);
|
|
2921
|
+
lines.push(`| Languages | ${meta.languages.join(", ") || "n/a"} |`);
|
|
2922
|
+
lines.push(`| Project kinds | ${prepared.profile.kinds.join(", ") || "n/a"} |`);
|
|
2923
|
+
lines.push(
|
|
2924
|
+
`| Test cases | ${prepared.caseCount} (${PRIORITY_ORDER.map((p) => `${prepared.priorities[p]} ${p}`).join(", ")}) |`
|
|
2925
|
+
);
|
|
2926
|
+
lines.push(`| Nodes | ${prepared.nodeCount} (depth ${prepared.depth}) |`);
|
|
2927
|
+
lines.push(`| Standard sources | ${meta.layers.join(" \u2192 ")} |`);
|
|
2928
|
+
lines.push(`| Model | ${meta.model} |`);
|
|
2929
|
+
lines.push(`| Generated | ${meta.generatedAt} |`);
|
|
2930
|
+
lines.push("");
|
|
2931
|
+
lines.push(`## ${prepared.root.title}`);
|
|
2932
|
+
if (prepared.root.summary && prepared.root.summary !== prepared.profile.summary) {
|
|
2933
|
+
lines.push("");
|
|
2934
|
+
lines.push(prepared.root.summary);
|
|
2935
|
+
}
|
|
2936
|
+
lines.push("");
|
|
2937
|
+
for (const child of prepared.root.children) renderNode2(lines, child, 0);
|
|
2938
|
+
if (prepared.notes) {
|
|
2939
|
+
lines.push("");
|
|
2940
|
+
lines.push(`## Notes`);
|
|
2941
|
+
lines.push("");
|
|
2942
|
+
lines.push(prepared.notes);
|
|
2943
|
+
}
|
|
2944
|
+
lines.push("");
|
|
2945
|
+
lines.push(`---`);
|
|
2946
|
+
lines.push(`<sub>Generated by [edsger](https://www.npmjs.com/package/edsger).</sub>`);
|
|
2947
|
+
return lines.join("\n");
|
|
2948
|
+
}
|
|
2949
|
+
function renderNode2(lines, node, indent2) {
|
|
2950
|
+
const pad = " ".repeat(indent2);
|
|
2951
|
+
const files = node.files.length ? ` ${node.files.map((f) => `\`${f}\``).join(" ")}` : "";
|
|
2952
|
+
const summary = node.summary ? ` \u2014 ${node.summary}` : "";
|
|
2953
|
+
if (isCase(node)) {
|
|
2954
|
+
const prio = node.priority ? ` \`${node.priority}\`` : "";
|
|
2955
|
+
lines.push(`${pad}- [ ] **${node.title}**${prio}${summary}${files}`);
|
|
2956
|
+
const sub = " ".repeat(indent2 + 1);
|
|
2957
|
+
if (node.preconditions.length) {
|
|
2958
|
+
lines.push(`${sub}- Preconditions: ${node.preconditions.join("; ")}`);
|
|
2959
|
+
}
|
|
2960
|
+
for (const [i, step] of node.steps.entries()) {
|
|
2961
|
+
lines.push(`${sub}- Step ${i + 1}: ${step}`);
|
|
2962
|
+
}
|
|
2963
|
+
if (node.expected) lines.push(`${sub}- Expected: ${node.expected}`);
|
|
2964
|
+
} else {
|
|
2965
|
+
lines.push(`${pad}- **${node.title}**${summary}${files}`);
|
|
2966
|
+
}
|
|
2967
|
+
for (const child of node.children) renderNode2(lines, child, indent2 + 1);
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// src/commands/testcases.ts
|
|
2971
|
+
async function runTestcasesGenerate(opts) {
|
|
2972
|
+
const { config, path: configPath } = loadConfig(opts.cwd);
|
|
2973
|
+
const configDir = configPath ? join12(configPath, "..") : opts.cwd;
|
|
2974
|
+
const resolved = resolveStandard("testcases", opts, config, configDir);
|
|
2975
|
+
const standard = resolved.standard;
|
|
2976
|
+
log.info(
|
|
2977
|
+
`Deriving manual test cases for ${pc11.bold(basename5(opts.cwd))} with ${pc11.bold(standard.name)} (languages: ${resolved.languages.join(", ") || "generic"})`
|
|
2978
|
+
);
|
|
2979
|
+
log.debug(`standard layers: ${resolved.layers.join(" \u2192 ")}`);
|
|
2980
|
+
const prompt = buildTestcasesPrompt(standard, opts.cwd, opts.focus);
|
|
2981
|
+
const { result, costUsd, numTurns } = await runAgent({
|
|
2982
|
+
prompt,
|
|
2983
|
+
cwd: opts.cwd,
|
|
2984
|
+
model: opts.model,
|
|
2985
|
+
maxTurns: opts.maxTurns,
|
|
2986
|
+
timeoutMs: opts.timeoutMs,
|
|
2987
|
+
allowWrite: false,
|
|
2988
|
+
verbose: opts.verbose,
|
|
2989
|
+
resultShape: TestcasesResultSchema.shape,
|
|
2990
|
+
systemPromptAppend: "You are Edsger, a staff-level QA engineer writing a manual test plan for a human tester who has never read the code. You derive every test case strictly from behaviour that exists in the repository you inspect: real commands, flags, routes, screens, and error branches, with exact steps and one verifiable expected result each. Never invent behaviour that is not in the code. Do not modify any files."
|
|
2991
|
+
});
|
|
2992
|
+
if (!result) {
|
|
2993
|
+
log.error(
|
|
2994
|
+
"The agent did not produce a result. Try increasing --max-turns or check credentials."
|
|
2995
|
+
);
|
|
2996
|
+
return 1;
|
|
2997
|
+
}
|
|
2998
|
+
const prepared = prepareTestcases(result, { repo: basename5(opts.cwd) });
|
|
2999
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3000
|
+
const runId = generatedAt.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
3001
|
+
const git = gitInfo(opts.cwd);
|
|
3002
|
+
const md = renderTestcasesMarkdown(prepared, {
|
|
3003
|
+
repo: basename5(opts.cwd),
|
|
3004
|
+
languages: resolved.languages,
|
|
3005
|
+
layers: resolved.layers,
|
|
3006
|
+
generatedAt,
|
|
3007
|
+
model: opts.model
|
|
3008
|
+
});
|
|
3009
|
+
const jsonReport = {
|
|
3010
|
+
schema: "edsger.testcases/1",
|
|
3011
|
+
runId,
|
|
3012
|
+
generatedAt,
|
|
3013
|
+
model: opts.model,
|
|
3014
|
+
repo: basename5(opts.cwd),
|
|
3015
|
+
git,
|
|
3016
|
+
languages: resolved.languages,
|
|
3017
|
+
standard: { name: standard.name, version: standard.version, layers: resolved.layers },
|
|
3018
|
+
profile: prepared.profile,
|
|
3019
|
+
root: prepared.root,
|
|
3020
|
+
nodeCount: prepared.nodeCount,
|
|
3021
|
+
caseCount: prepared.caseCount,
|
|
3022
|
+
priorities: prepared.priorities,
|
|
3023
|
+
depth: prepared.depth,
|
|
3024
|
+
focus: opts.focus ?? null,
|
|
3025
|
+
notes: prepared.notes,
|
|
3026
|
+
costUsd
|
|
3027
|
+
};
|
|
3028
|
+
const historyRecord = {
|
|
3029
|
+
runId,
|
|
3030
|
+
generatedAt,
|
|
3031
|
+
commit: git.commit,
|
|
3032
|
+
branch: git.branch,
|
|
3033
|
+
dirty: git.dirty,
|
|
3034
|
+
model: opts.model,
|
|
3035
|
+
standardVersion: standard.version,
|
|
3036
|
+
nodeCount: prepared.nodeCount,
|
|
3037
|
+
caseCount: prepared.caseCount,
|
|
3038
|
+
priorities: prepared.priorities,
|
|
3039
|
+
depth: prepared.depth,
|
|
3040
|
+
costUsd
|
|
3041
|
+
};
|
|
3042
|
+
if (!opts.noWrite && !opts.dryRun) {
|
|
3043
|
+
const baseDir = join12(opts.cwd, opts.out);
|
|
3044
|
+
const mdOut = md + "\n";
|
|
3045
|
+
const jsonOut = JSON.stringify(jsonReport, null, 2) + "\n";
|
|
3046
|
+
const runDir = join12(baseDir, "runs", runId);
|
|
3047
|
+
mkdirSync6(runDir, { recursive: true });
|
|
3048
|
+
writeFileSync7(join12(runDir, "testcases.json"), jsonOut, "utf8");
|
|
3049
|
+
writeFileSync7(join12(runDir, "testcases.md"), mdOut, "utf8");
|
|
3050
|
+
writeFileSync7(join12(baseDir, "testcases.json"), jsonOut, "utf8");
|
|
3051
|
+
writeFileSync7(join12(baseDir, "testcases.md"), mdOut, "utf8");
|
|
3052
|
+
appendFileSync5(join12(baseDir, "history.jsonl"), JSON.stringify(historyRecord) + "\n", "utf8");
|
|
3053
|
+
log.success(
|
|
3054
|
+
`Test plan written to ${pc11.bold(join12(opts.out, "testcases.md"))} ${pc11.dim(`(run archived at ${join12(opts.out, "runs", runId)})`)}`
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
printSummary6(prepared, numTurns, costUsd);
|
|
3058
|
+
if (opts.json) process.stdout.write(JSON.stringify(jsonReport, null, 2) + "\n");
|
|
3059
|
+
return 0;
|
|
3060
|
+
}
|
|
3061
|
+
function printSummary6(prepared, numTurns, costUsd) {
|
|
3062
|
+
const tally = PRIORITY_ORDER.filter((p) => prepared.priorities[p] > 0).map((p) => `${prepared.priorities[p]} ${p}`).join(", ");
|
|
3063
|
+
log.heading(
|
|
3064
|
+
`${prepared.root.title} \u2014 ${prepared.caseCount} cases${tally ? ` (${tally})` : ""}, ${prepared.nodeCount} nodes, depth ${prepared.depth}`
|
|
3065
|
+
);
|
|
3066
|
+
const line = (node, indent2) => {
|
|
3067
|
+
const cases = countCases(node);
|
|
3068
|
+
const suffix = cases ? pc11.dim(` (${cases} case${cases === 1 ? "" : "s"})`) : "";
|
|
3069
|
+
process.stderr.write(`${indent2}${pc11.cyan("\u2022")} ${node.title}${suffix}
|
|
3070
|
+
`);
|
|
3071
|
+
};
|
|
3072
|
+
for (const branch of prepared.root.children) line(branch, " ");
|
|
3073
|
+
log.step(`${numTurns} turns \xB7 ~$${costUsd.toFixed(3)}`);
|
|
3074
|
+
}
|
|
3075
|
+
function countCases(node) {
|
|
3076
|
+
return (isCase(node) ? 1 : 0) + node.children.reduce((acc, c) => acc + countCases(c), 0);
|
|
3077
|
+
}
|
|
3078
|
+
async function runTestcasesServe(opts) {
|
|
3079
|
+
const testcasesDir = join12(opts.cwd, opts.dir);
|
|
3080
|
+
const viewerDir = findTestcasesViewerDir();
|
|
3081
|
+
return startViewerServer({
|
|
3082
|
+
viewerDir,
|
|
3083
|
+
indexFile: "testcases.html",
|
|
3084
|
+
dataRoute: "/edsger-testcases.json",
|
|
3085
|
+
getData: () => assembleTestcasesData(testcasesDir),
|
|
3086
|
+
port: opts.port,
|
|
3087
|
+
host: opts.host,
|
|
3088
|
+
open: opts.open,
|
|
3089
|
+
emptyHint: hasData(assembleTestcasesData(testcasesDir)) ? void 0 : `No test-case data in ${opts.dir} yet \u2014 run \`edsger testcases\` first.`
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
async function runTestcasesReport(opts) {
|
|
3093
|
+
const testcasesDir = join12(opts.cwd, opts.dir);
|
|
3094
|
+
const data = assembleTestcasesData(testcasesDir);
|
|
3095
|
+
if (!hasData(data)) {
|
|
3096
|
+
log.error(`No test-case data in ${opts.dir}. Run \`edsger testcases\` first.`);
|
|
3097
|
+
return 1;
|
|
3098
|
+
}
|
|
3099
|
+
const html = renderTestcasesSingleFile(readTestcasesViewerSingleTemplate(), data);
|
|
3100
|
+
const out = isAbsolute6(opts.html) ? opts.html : join12(opts.cwd, opts.html);
|
|
3101
|
+
mkdirSync6(dirname6(out), { recursive: true });
|
|
3102
|
+
writeFileSync7(out, html, "utf8");
|
|
3103
|
+
log.success(
|
|
3104
|
+
`Wrote self-contained test-case viewer to ${pc11.bold(opts.html)} ${pc11.dim(`(${Math.round(html.length / 1024)} kB, open in any browser)`)}`
|
|
3105
|
+
);
|
|
3106
|
+
if (opts.open) openInBrowser(out);
|
|
3107
|
+
return 0;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
2661
3110
|
// src/commands/skills.ts
|
|
2662
3111
|
import {
|
|
2663
3112
|
cpSync,
|
|
2664
3113
|
existsSync as existsSync5,
|
|
2665
|
-
mkdirSync as
|
|
3114
|
+
mkdirSync as mkdirSync7,
|
|
2666
3115
|
readFileSync as readFileSync3,
|
|
2667
3116
|
readdirSync as readdirSync3,
|
|
2668
3117
|
rmSync as rmSync2,
|
|
2669
3118
|
statSync as statSync3,
|
|
2670
|
-
writeFileSync as
|
|
3119
|
+
writeFileSync as writeFileSync8
|
|
2671
3120
|
} from "fs";
|
|
2672
3121
|
import { homedir as homedir2 } from "os";
|
|
2673
|
-
import { join as
|
|
2674
|
-
import
|
|
3122
|
+
import { join as join13 } from "path";
|
|
3123
|
+
import pc12 from "picocolors";
|
|
2675
3124
|
var MANAGED = ["benchmark", "audit", "pr-review", "pr-resolve"];
|
|
2676
3125
|
function targetSkillsDir(scope) {
|
|
2677
|
-
return scope.global ?
|
|
3126
|
+
return scope.global ? join13(homedir2(), ".claude", "skills") : join13(scope.cwd, ".claude", "skills");
|
|
2678
3127
|
}
|
|
2679
3128
|
function sourceSkillsDir() {
|
|
2680
|
-
return
|
|
3129
|
+
return join13(findBundledAssetsDir(), "skills");
|
|
2681
3130
|
}
|
|
2682
3131
|
async function runSkillsInstall(opts) {
|
|
2683
3132
|
const assetsDir = findBundledAssetsDir();
|
|
@@ -2687,20 +3136,20 @@ async function runSkillsInstall(opts) {
|
|
|
2687
3136
|
return 1;
|
|
2688
3137
|
}
|
|
2689
3138
|
const dest = targetSkillsDir(opts);
|
|
2690
|
-
|
|
2691
|
-
const available = readdirSync3(src).filter((n) => existsSync5(
|
|
3139
|
+
mkdirSync7(dest, { recursive: true });
|
|
3140
|
+
const available = readdirSync3(src).filter((n) => existsSync5(join13(src, n, "SKILL.md")));
|
|
2692
3141
|
let installed = 0;
|
|
2693
3142
|
for (const name of available) {
|
|
2694
|
-
const outDir =
|
|
3143
|
+
const outDir = join13(dest, name);
|
|
2695
3144
|
if (existsSync5(outDir) && !opts.force) {
|
|
2696
3145
|
log.warn(`skill "${name}" already exists \u2014 use --force to overwrite`);
|
|
2697
3146
|
continue;
|
|
2698
3147
|
}
|
|
2699
3148
|
rmSync2(outDir, { recursive: true, force: true });
|
|
2700
|
-
cpSync(
|
|
2701
|
-
const skillFile =
|
|
3149
|
+
cpSync(join13(src, name), outDir, { recursive: true });
|
|
3150
|
+
const skillFile = join13(outDir, "SKILL.md");
|
|
2702
3151
|
const rewritten = readFileSync3(skillFile, "utf8").split("${CLAUDE_PLUGIN_ROOT}").join(assetsDir);
|
|
2703
|
-
|
|
3152
|
+
writeFileSync8(skillFile, rewritten, "utf8");
|
|
2704
3153
|
installed++;
|
|
2705
3154
|
log.success(`installed /${name}`);
|
|
2706
3155
|
}
|
|
@@ -2708,7 +3157,7 @@ async function runSkillsInstall(opts) {
|
|
|
2708
3157
|
log.info("Nothing installed.");
|
|
2709
3158
|
return 0;
|
|
2710
3159
|
}
|
|
2711
|
-
log.heading(`Installed ${installed} skill(s) into ${
|
|
3160
|
+
log.heading(`Installed ${installed} skill(s) into ${pc12.bold(dest)}`);
|
|
2712
3161
|
log.step(`scope: ${opts.global ? "user (all projects)" : "this project"}`);
|
|
2713
3162
|
log.step(`standards: ${assetsDir}`);
|
|
2714
3163
|
log.step("Open Claude Code and run /benchmark, /audit, /pr-review, or /pr-resolve.");
|
|
@@ -2718,7 +3167,7 @@ async function runSkillsUninstall(opts) {
|
|
|
2718
3167
|
const dest = targetSkillsDir(opts);
|
|
2719
3168
|
let removed = 0;
|
|
2720
3169
|
for (const name of MANAGED) {
|
|
2721
|
-
const dir =
|
|
3170
|
+
const dir = join13(dest, name);
|
|
2722
3171
|
if (existsSync5(dir)) {
|
|
2723
3172
|
rmSync2(dir, { recursive: true, force: true });
|
|
2724
3173
|
removed++;
|
|
@@ -2735,13 +3184,13 @@ async function runSkillsList(opts) {
|
|
|
2735
3184
|
]) {
|
|
2736
3185
|
const dir = targetSkillsDir(scope);
|
|
2737
3186
|
const present = MANAGED.filter((n) => {
|
|
2738
|
-
const p =
|
|
3187
|
+
const p = join13(dir, n, "SKILL.md");
|
|
2739
3188
|
return existsSync5(p) && statSync3(p).isFile();
|
|
2740
3189
|
});
|
|
2741
3190
|
log.heading(`${label}: ${dir}`);
|
|
2742
|
-
if (present.length) for (const n of present) process.stderr.write(` ${
|
|
3191
|
+
if (present.length) for (const n of present) process.stderr.write(` ${pc12.green("\u2713")} /${n}
|
|
2743
3192
|
`);
|
|
2744
|
-
else process.stderr.write(` ${
|
|
3193
|
+
else process.stderr.write(` ${pc12.dim("(none installed)")}
|
|
2745
3194
|
`);
|
|
2746
3195
|
}
|
|
2747
3196
|
return 0;
|
|
@@ -2979,6 +3428,57 @@ mindmap.command("report").description("Export a self-contained HTML mind-map vie
|
|
|
2979
3428
|
})
|
|
2980
3429
|
);
|
|
2981
3430
|
});
|
|
3431
|
+
var testcases = program.command("testcases").alias("test-cases").description(
|
|
3432
|
+
"Derive & browse a manual test-case plan (Workflowy-style outline) for a repo \u2014 how a human should test it, not automated code tests"
|
|
3433
|
+
);
|
|
3434
|
+
addGlobalOptions(
|
|
3435
|
+
testcases.command("generate", { isDefault: true }).description("Analyze the repository and derive the manual test-case plan").option("--out <dir>", "test-plan output directory (relative to repo)", ".edsger/testcases").option("--no-write", "do not write test-plan files into the repo").option("--focus <text>", "area or feature the plan should concentrate its depth on").option("--serve", "open the test-case viewer after generating")
|
|
3436
|
+
).action(
|
|
3437
|
+
async (opts) => {
|
|
3438
|
+
await guard(async () => {
|
|
3439
|
+
const g = toGlobal(opts);
|
|
3440
|
+
const code = await runTestcasesGenerate({
|
|
3441
|
+
...g,
|
|
3442
|
+
out: opts.out,
|
|
3443
|
+
noWrite: opts.write === false,
|
|
3444
|
+
focus: opts.focus
|
|
3445
|
+
});
|
|
3446
|
+
if (code === 0 && opts.serve) {
|
|
3447
|
+
return runTestcasesServe({
|
|
3448
|
+
cwd: g.cwd,
|
|
3449
|
+
dir: opts.out,
|
|
3450
|
+
port: 4581,
|
|
3451
|
+
host: "127.0.0.1",
|
|
3452
|
+
open: true
|
|
3453
|
+
});
|
|
3454
|
+
}
|
|
3455
|
+
return code;
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
);
|
|
3459
|
+
testcases.command("serve").description("Serve the test-case viewer locally").option("-C, --cwd <dir>", "repository directory").option("--dir <dir>", "test-plan output directory (relative to repo)", ".edsger/testcases").option("--port <n>", "port to listen on", "4581").option("--host <host>", "host to bind", "127.0.0.1").option("--no-open", "do not open the browser automatically").action(
|
|
3460
|
+
async (opts) => {
|
|
3461
|
+
await guard(
|
|
3462
|
+
() => runTestcasesServe({
|
|
3463
|
+
cwd: resolve2(opts.cwd ?? process.cwd()),
|
|
3464
|
+
dir: opts.dir,
|
|
3465
|
+
port: Number.parseInt(opts.port, 10) || 4581,
|
|
3466
|
+
host: opts.host,
|
|
3467
|
+
open: opts.open !== false
|
|
3468
|
+
})
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
);
|
|
3472
|
+
testcases.command("report").description("Export a self-contained HTML test-case viewer (data inlined) for sharing").option("-C, --cwd <dir>", "repository directory").option("--dir <dir>", "test-plan output directory (relative to repo)", ".edsger/testcases").option("--html <path>", "output HTML file path", "edsger-testcases.html").option("--open", "open the exported file after writing").action(async (opts) => {
|
|
3473
|
+
await guard(
|
|
3474
|
+
() => runTestcasesReport({
|
|
3475
|
+
cwd: resolve2(opts.cwd ?? process.cwd()),
|
|
3476
|
+
dir: opts.dir,
|
|
3477
|
+
html: opts.html,
|
|
3478
|
+
open: Boolean(opts.open)
|
|
3479
|
+
})
|
|
3480
|
+
);
|
|
3481
|
+
});
|
|
2982
3482
|
addGlobalOptions(
|
|
2983
3483
|
program.command("pr-review").description("Review a pull request against the review standard (requires gh)").argument("<pr>", "PR number, URL, or branch").option("--repo <owner/repo>", "target repository (defaults to current)").option("--post", "submit the review to GitHub (otherwise preview only)")
|
|
2984
3484
|
).action(async (pr, opts) => {
|
|
@@ -3029,18 +3529,20 @@ program.addHelpText(
|
|
|
3029
3529
|
"after",
|
|
3030
3530
|
`
|
|
3031
3531
|
Examples:
|
|
3032
|
-
${
|
|
3033
|
-
${
|
|
3034
|
-
${
|
|
3035
|
-
${
|
|
3036
|
-
${
|
|
3037
|
-
${
|
|
3038
|
-
${
|
|
3039
|
-
${
|
|
3040
|
-
${
|
|
3041
|
-
${
|
|
3042
|
-
${
|
|
3043
|
-
${
|
|
3532
|
+
${pc13.dim("$")} edsger benchmark
|
|
3533
|
+
${pc13.dim("$")} edsger benchmark -C ./my-repo --json
|
|
3534
|
+
${pc13.dim("$")} edsger audit ${pc13.dim("# nitpick the whole repo, report issues")}
|
|
3535
|
+
${pc13.dim("$")} edsger audit --fail-on high ${pc13.dim("# CI gate: fail on high/critical findings")}
|
|
3536
|
+
${pc13.dim("$")} edsger diagram ${pc13.dim("# generate diagrams for the current repo")}
|
|
3537
|
+
${pc13.dim("$")} edsger diagram --serve ${pc13.dim("# \u2026and open them in the browser")}
|
|
3538
|
+
${pc13.dim("$")} edsger diagram report --html diagrams.html
|
|
3539
|
+
${pc13.dim("$")} edsger mindmap ${pc13.dim("# implementation mind map of the repo")}
|
|
3540
|
+
${pc13.dim("$")} edsger mindmap --serve ${pc13.dim("# \u2026browse it Workflowy-style")}
|
|
3541
|
+
${pc13.dim("$")} edsger testcases ${pc13.dim("# manual test-case plan for the repo")}
|
|
3542
|
+
${pc13.dim("$")} edsger testcases --serve ${pc13.dim("# \u2026browse the plan Workflowy-style")}
|
|
3543
|
+
${pc13.dim("$")} edsger pr-review 123 --post
|
|
3544
|
+
${pc13.dim("$")} edsger pr-resolve 123 --instruction "fix the failing test" --push
|
|
3545
|
+
${pc13.dim("$")} edsger skills install ${pc13.dim("# enable /benchmark etc. in Claude Code")}
|
|
3044
3546
|
|
|
3045
3547
|
Auth: the agent uses your Claude Code login (run \`claude\` once to sign in); run \`gh auth login\` for PR commands.
|
|
3046
3548
|
`
|