@pcamarajr/scout 0.3.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.
- package/README.md +61 -12
- package/dist/cli.js +57 -16
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/engine.js +74 -7
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +13 -9
- package/dist/mcp/server.js.map +1 -1
- package/dist/report.d.ts +4 -2
- package/dist/report.js +29 -16
- package/dist/report.js.map +1 -1
- package/dist/runner/ai-runner.js +3 -3
- package/dist/runner/ai-runner.js.map +1 -1
- package/dist/runner/browser.d.ts +10 -1
- package/dist/runner/browser.js +31 -10
- package/dist/runner/browser.js.map +1 -1
- package/dist/runner/script-runner.d.ts +14 -0
- package/dist/runner/script-runner.js +32 -0
- package/dist/runner/script-runner.js.map +1 -1
- package/dist/runner/video.d.ts +72 -0
- package/dist/runner/video.js +197 -0
- package/dist/runner/video.js.map +1 -0
- package/dist/specs.d.ts +33 -0
- package/dist/specs.js +198 -0
- package/dist/specs.js.map +1 -0
- package/dist/store.d.ts +6 -15
- package/dist/store.js +50 -54
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +16 -5
- package/package.json +2 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
const BASE_SLOWMO = 200;
|
|
4
|
+
function clamp(n, lo, hi) {
|
|
5
|
+
return Math.min(hi, Math.max(lo, n));
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Maps the single `videoSpeed` knob (0,1] to concrete pacing. <1 = slower.
|
|
9
|
+
* At 1.0 the replay runs at natural speed; the default 0.4 yields a clip a
|
|
10
|
+
* human can actually follow.
|
|
11
|
+
*/
|
|
12
|
+
export function pacingFor(videoSpeed = 0.4) {
|
|
13
|
+
const speed = clamp(videoSpeed, 0.1, 1);
|
|
14
|
+
return {
|
|
15
|
+
slowMoMs: Math.round(BASE_SLOWMO * (1 / speed - 1)),
|
|
16
|
+
assertDwellMs: Math.round(500 / speed),
|
|
17
|
+
titleCardMs: 1500,
|
|
18
|
+
verdictCardMs: 1800,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const FONT_CANDIDATES = [
|
|
22
|
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
23
|
+
"/Library/Fonts/Arial.ttf",
|
|
24
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
25
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
26
|
+
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
27
|
+
"C:\\Windows\\Fonts\\arial.ttf",
|
|
28
|
+
];
|
|
29
|
+
/** First usable font: SCOUT_VIDEO_FONT override, then common system paths. */
|
|
30
|
+
export function resolveFont() {
|
|
31
|
+
const override = process.env.SCOUT_VIDEO_FONT;
|
|
32
|
+
if (override && fs.existsSync(override))
|
|
33
|
+
return override;
|
|
34
|
+
return FONT_CANDIDATES.find((f) => fs.existsSync(f));
|
|
35
|
+
}
|
|
36
|
+
/** Resolves the ffmpeg binary (FFMPEG_PATH or PATH), or undefined if absent. */
|
|
37
|
+
export function findFfmpeg() {
|
|
38
|
+
const bin = process.env.FFMPEG_PATH || "ffmpeg";
|
|
39
|
+
const probe = spawnSync(bin, ["-version"], { stdio: "ignore" });
|
|
40
|
+
return probe.status === 0 ? bin : undefined;
|
|
41
|
+
}
|
|
42
|
+
function probeDurationMs(ffmpegBin, file) {
|
|
43
|
+
const ffprobe = process.env.FFPROBE_PATH ||
|
|
44
|
+
(ffmpegBin.endsWith("ffmpeg") ? ffmpegBin.replace(/ffmpeg$/, "ffprobe") : "ffprobe");
|
|
45
|
+
const r = spawnSync(ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file], { encoding: "utf8" });
|
|
46
|
+
if (r.status === 0) {
|
|
47
|
+
const seconds = parseFloat(r.stdout.trim());
|
|
48
|
+
if (!Number.isNaN(seconds))
|
|
49
|
+
return Math.round(seconds * 1000);
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const VERDICT_COLOR = {
|
|
54
|
+
verified: "0x2ECC71",
|
|
55
|
+
failed: "0xE74C3C",
|
|
56
|
+
partial: "0xF39C12",
|
|
57
|
+
blocked: "0x95A5A6",
|
|
58
|
+
};
|
|
59
|
+
const VERDICT_LABEL = {
|
|
60
|
+
verified: "VERIFICADO",
|
|
61
|
+
failed: "FALHOU",
|
|
62
|
+
partial: "PARCIAL",
|
|
63
|
+
blocked: "BLOQUEADO",
|
|
64
|
+
};
|
|
65
|
+
/** drawtext text=' ' wraps the value, so only quotes/backslashes must go. */
|
|
66
|
+
function safeText(s) {
|
|
67
|
+
return s.replace(/['\\]/g, "").trim();
|
|
68
|
+
}
|
|
69
|
+
/** Approx Arial glyph advance as a fraction of font size — for width fitting. */
|
|
70
|
+
const GLYPH_RATIO = 0.56;
|
|
71
|
+
/**
|
|
72
|
+
* drawtext can't wrap, so we shrink the font to fit `maxWidth`, and if even the
|
|
73
|
+
* minimum size overflows we truncate with an ellipsis. Keeps long scenario
|
|
74
|
+
* names and step labels inside the portrait frame instead of bleeding off-edge.
|
|
75
|
+
*/
|
|
76
|
+
function fitText(value, maxWidth, maxSize, minSize) {
|
|
77
|
+
const len = Math.max(value.length, 1);
|
|
78
|
+
const size = Math.floor(maxWidth / (len * GLYPH_RATIO));
|
|
79
|
+
if (size >= maxSize)
|
|
80
|
+
return { text: value, size: maxSize };
|
|
81
|
+
if (size >= minSize)
|
|
82
|
+
return { text: value, size };
|
|
83
|
+
const maxChars = Math.max(4, Math.floor(maxWidth / (minSize * GLYPH_RATIO)) - 1);
|
|
84
|
+
return { text: value.length > maxChars ? value.slice(0, maxChars - 1) + "…" : value, size: minSize };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Builds the ffmpeg `-vf` filtergraph that bakes the overlays. Drawn in array
|
|
88
|
+
* order (later filters sit on top): step captions first, then the opening
|
|
89
|
+
* card, then the verdict card last so it covers the held final frame.
|
|
90
|
+
* Returns "" when no font is available — caller still transcodes, just plain.
|
|
91
|
+
*/
|
|
92
|
+
export function buildOverlayFilter(spec) {
|
|
93
|
+
const { font, width, durationMs, pacing } = spec;
|
|
94
|
+
if (!font)
|
|
95
|
+
return "";
|
|
96
|
+
const dur = durationMs / 1000;
|
|
97
|
+
const titleSec = Math.min(pacing.titleCardMs / 1000, dur * 0.4);
|
|
98
|
+
const verdictStart = Math.max(titleSec, dur - pacing.verdictCardMs / 1000);
|
|
99
|
+
const ff = safeText(font);
|
|
100
|
+
const between = (a, b) => `enable='between(t,${a.toFixed(2)},${b.toFixed(2)})'`;
|
|
101
|
+
const text = (value, opts) => [
|
|
102
|
+
`drawtext=fontfile='${ff}'`,
|
|
103
|
+
`text='${safeText(value)}'`,
|
|
104
|
+
`fontcolor=${opts.color}`,
|
|
105
|
+
`fontsize=${opts.size}`,
|
|
106
|
+
opts.box ? "box=1:boxcolor=black@0.55:boxborderw=14" : "",
|
|
107
|
+
"x=(w-text_w)/2",
|
|
108
|
+
`y=${opts.y}`,
|
|
109
|
+
"expansion=none",
|
|
110
|
+
between(opts.a, opts.b),
|
|
111
|
+
]
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join(":");
|
|
114
|
+
const fullBox = (a, b, alpha = "0.6") => `drawbox=x=0:y=0:w=iw:h=ih:color=black@${alpha}:t=fill:${between(a, b)}`;
|
|
115
|
+
const filters = [];
|
|
116
|
+
// Step captions — bounded so a long script can't explode the graph.
|
|
117
|
+
spec.timeline.slice(0, 80).forEach((entry, i) => {
|
|
118
|
+
const a = Math.max(entry.tMs / 1000, titleSec);
|
|
119
|
+
const next = spec.timeline[i + 1];
|
|
120
|
+
const b = next ? next.tMs / 1000 : verdictStart;
|
|
121
|
+
if (a >= b)
|
|
122
|
+
return;
|
|
123
|
+
const fit = fitText(entry.label, width - 68, 26, 14); // box border eats horizontal room
|
|
124
|
+
filters.push(text(fit.text, { size: fit.size, color: "white", y: "h-text_h-60", box: true, a, b }));
|
|
125
|
+
});
|
|
126
|
+
// Opening card.
|
|
127
|
+
const title = fitText(spec.scenarioName, width - 40, 40, 18);
|
|
128
|
+
filters.push(fullBox(0, titleSec));
|
|
129
|
+
filters.push(text(title.text, { size: title.size, color: "white", y: "(h/2)-70", a: 0, b: titleSec }));
|
|
130
|
+
filters.push(text("scout preview", { size: 22, color: "0xBDC3C7", y: "(h/2)+10", a: 0, b: titleSec }));
|
|
131
|
+
// Closing verdict card.
|
|
132
|
+
const verdict = fitText(VERDICT_LABEL[spec.verdict], width - 40, 52, 24);
|
|
133
|
+
filters.push(fullBox(verdictStart, dur));
|
|
134
|
+
filters.push(text(verdict.text, {
|
|
135
|
+
size: verdict.size,
|
|
136
|
+
color: VERDICT_COLOR[spec.verdict],
|
|
137
|
+
y: "(h-text_h)/2",
|
|
138
|
+
a: verdictStart,
|
|
139
|
+
b: dur,
|
|
140
|
+
}));
|
|
141
|
+
return filters.join(",");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Transcodes the paced WebM to an H.264 MP4 (GitHub-playable) with baked
|
|
145
|
+
* overlays. On any ffmpeg problem the raw WebM is returned as a fallback so
|
|
146
|
+
* `--record-video` always yields a playable file.
|
|
147
|
+
*/
|
|
148
|
+
export function generateVideo(input) {
|
|
149
|
+
const ffmpeg = findFfmpeg();
|
|
150
|
+
if (!ffmpeg) {
|
|
151
|
+
return {
|
|
152
|
+
output: input.webmPath,
|
|
153
|
+
warning: "ffmpeg não encontrado — MP4 com overlays não gerado, mantido o WebM cru. Instale ffmpeg (brew install ffmpeg / apt-get install ffmpeg) ou defina FFMPEG_PATH.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const font = resolveFont();
|
|
157
|
+
const durationMs = probeDurationMs(ffmpeg, input.webmPath) ??
|
|
158
|
+
(input.timeline.at(-1)?.tMs ?? 0) + input.pacing.verdictCardMs;
|
|
159
|
+
const filter = buildOverlayFilter({
|
|
160
|
+
font: font ?? "",
|
|
161
|
+
width: input.width,
|
|
162
|
+
height: input.height,
|
|
163
|
+
durationMs,
|
|
164
|
+
scenarioName: input.scenarioName,
|
|
165
|
+
verdict: input.verdict,
|
|
166
|
+
timeline: input.timeline,
|
|
167
|
+
pacing: input.pacing,
|
|
168
|
+
});
|
|
169
|
+
const args = [
|
|
170
|
+
"-y",
|
|
171
|
+
"-i",
|
|
172
|
+
input.webmPath,
|
|
173
|
+
...(filter ? ["-vf", filter] : []),
|
|
174
|
+
"-c:v",
|
|
175
|
+
"libx264",
|
|
176
|
+
"-pix_fmt",
|
|
177
|
+
"yuv420p",
|
|
178
|
+
"-movflags",
|
|
179
|
+
"+faststart",
|
|
180
|
+
"-an",
|
|
181
|
+
input.outPath,
|
|
182
|
+
];
|
|
183
|
+
const run = spawnSync(ffmpeg, args, { stdio: "ignore" });
|
|
184
|
+
if (run.status !== 0) {
|
|
185
|
+
return {
|
|
186
|
+
output: input.webmPath,
|
|
187
|
+
warning: "ffmpeg falhou ao gerar o MP4 — mantido o WebM cru.",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
output: input.outPath,
|
|
192
|
+
warning: font
|
|
193
|
+
? undefined
|
|
194
|
+
: "Nenhuma fonte encontrada para os overlays — MP4 gerado sem legendas. Defina SCOUT_VIDEO_FONT apontando para um .ttf.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=video.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"video.js","sourceRoot":"","sources":["../../src/runner/video.ts"],"names":[],"mappings":"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"}
|
package/dist/specs.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Scenario } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scenario specs live as markdown files under `.scout/specs/**\/*.scout.md`.
|
|
4
|
+
* One file per feature/component; each `## heading` is one scenario. The
|
|
5
|
+
* markdown is the human-authored source of truth — a *pure input* that is
|
|
6
|
+
* never written back to by a run (status/last-run derive from `.scout/runs/`).
|
|
7
|
+
*
|
|
8
|
+
* The format mirrors the Playwright Agents test-plan layout (feature file with
|
|
9
|
+
* scenario sections) and adds YAML frontmatter as a superset for scout-specific
|
|
10
|
+
* config (profile, tags). Per-scenario overrides may follow a heading as
|
|
11
|
+
* `key: value` lines before the prose.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SPECS_DIR = "specs";
|
|
14
|
+
export declare function slugify(name: string): string;
|
|
15
|
+
/** A logical slug `<file>/<scenario>` → a single filesystem-safe token. */
|
|
16
|
+
export declare function slugToToken(slug: string): string;
|
|
17
|
+
/** Parse a single `.scout.md` file into its scenarios. */
|
|
18
|
+
export declare function parseSpec(specFile: string, root: string, cwd: string): Scenario[];
|
|
19
|
+
/** Load every scenario across all spec files, validating slug uniqueness. */
|
|
20
|
+
export declare function loadScenarios(cwd?: string): Scenario[];
|
|
21
|
+
export interface NewScenarioInput {
|
|
22
|
+
feature: string;
|
|
23
|
+
name: string;
|
|
24
|
+
scenario: string;
|
|
25
|
+
profile?: string;
|
|
26
|
+
notes?: string;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Append a scenario to `.scout/specs/<feature-slug>.scout.md`, creating the
|
|
31
|
+
* file with frontmatter when it does not exist. Returns the logical slug.
|
|
32
|
+
*/
|
|
33
|
+
export declare function addScenario(input: NewScenarioInput, cwd?: string): Scenario;
|
package/dist/specs.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { SCOUT_DIR } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Scenario specs live as markdown files under `.scout/specs/**\/*.scout.md`.
|
|
7
|
+
* One file per feature/component; each `## heading` is one scenario. The
|
|
8
|
+
* markdown is the human-authored source of truth — a *pure input* that is
|
|
9
|
+
* never written back to by a run (status/last-run derive from `.scout/runs/`).
|
|
10
|
+
*
|
|
11
|
+
* The format mirrors the Playwright Agents test-plan layout (feature file with
|
|
12
|
+
* scenario sections) and adds YAML frontmatter as a superset for scout-specific
|
|
13
|
+
* config (profile, tags). Per-scenario overrides may follow a heading as
|
|
14
|
+
* `key: value` lines before the prose.
|
|
15
|
+
*/
|
|
16
|
+
export const SPECS_DIR = "specs";
|
|
17
|
+
const SPEC_EXT = ".scout.md";
|
|
18
|
+
/** Per-scenario override keys allowed under a heading. */
|
|
19
|
+
const OVERRIDE_KEYS = new Set(["profile", "notes", "tags"]);
|
|
20
|
+
export function slugify(name) {
|
|
21
|
+
return name
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.normalize("NFD")
|
|
24
|
+
.replace(/[̀-ͯ]/g, "")
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.replace(/^-|-$/g, "")
|
|
27
|
+
.slice(0, 60);
|
|
28
|
+
}
|
|
29
|
+
/** A logical slug `<file>/<scenario>` → a single filesystem-safe token. */
|
|
30
|
+
export function slugToToken(slug) {
|
|
31
|
+
return slug.replace(/\//g, "__");
|
|
32
|
+
}
|
|
33
|
+
function specsRoot(cwd) {
|
|
34
|
+
return path.join(cwd, SCOUT_DIR, SPECS_DIR);
|
|
35
|
+
}
|
|
36
|
+
/** All `*.scout.md` under `.scout/specs`, recursively, sorted for stable order. */
|
|
37
|
+
function findSpecFiles(dir) {
|
|
38
|
+
if (!fs.existsSync(dir))
|
|
39
|
+
return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
const full = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory())
|
|
44
|
+
out.push(...findSpecFiles(full));
|
|
45
|
+
else if (entry.isFile() && entry.name.endsWith(SPEC_EXT))
|
|
46
|
+
out.push(full);
|
|
47
|
+
}
|
|
48
|
+
return out.sort();
|
|
49
|
+
}
|
|
50
|
+
/** `.scout/specs/auth/login.scout.md` → `auth/login` (each segment slugified). */
|
|
51
|
+
function fileSlugFromPath(specFile, root) {
|
|
52
|
+
const rel = path.relative(root, specFile).replace(new RegExp(`${SPEC_EXT.replace(".", "\\.")}$`), "");
|
|
53
|
+
return rel
|
|
54
|
+
.split(path.sep)
|
|
55
|
+
.map((seg) => slugify(seg))
|
|
56
|
+
.join("/");
|
|
57
|
+
}
|
|
58
|
+
function parseTags(raw) {
|
|
59
|
+
if (Array.isArray(raw))
|
|
60
|
+
return raw.map(String);
|
|
61
|
+
if (typeof raw === "string") {
|
|
62
|
+
const inner = raw.trim().replace(/^\[|\]$/g, "");
|
|
63
|
+
const parts = inner.split(",").map((t) => t.trim()).filter(Boolean);
|
|
64
|
+
return parts.length ? parts : undefined;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Split markdown body into `## ` sections (h1/h3+ and preamble are ignored).
|
|
70
|
+
* Fenced code blocks (``` ... ```) are skipped so a documented `## ` inside a
|
|
71
|
+
* fence is not mistaken for a real scenario heading.
|
|
72
|
+
*/
|
|
73
|
+
function splitSections(content) {
|
|
74
|
+
const sections = [];
|
|
75
|
+
let cur = null;
|
|
76
|
+
let inFence = false;
|
|
77
|
+
for (const line of content.split("\n")) {
|
|
78
|
+
if (/^\s*```/.test(line))
|
|
79
|
+
inFence = !inFence;
|
|
80
|
+
const m = inFence ? null : /^##\s+(.+?)\s*$/.exec(line);
|
|
81
|
+
if (m) {
|
|
82
|
+
cur = { name: m[1].trim(), bodyLines: [] };
|
|
83
|
+
sections.push(cur);
|
|
84
|
+
}
|
|
85
|
+
else if (cur) {
|
|
86
|
+
cur.bodyLines.push(line);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sections;
|
|
90
|
+
}
|
|
91
|
+
/** Parse a single `.scout.md` file into its scenarios. */
|
|
92
|
+
export function parseSpec(specFile, root, cwd) {
|
|
93
|
+
const fileSlug = fileSlugFromPath(specFile, root);
|
|
94
|
+
const raw = fs.readFileSync(specFile, "utf8");
|
|
95
|
+
const { data, content } = matter(raw);
|
|
96
|
+
const fileFeature = typeof data.feature === "string" ? data.feature : path.basename(specFile).replace(SPEC_EXT, "");
|
|
97
|
+
const fileProfile = typeof data.profile === "string" ? data.profile : undefined;
|
|
98
|
+
const fileTags = parseTags(data.tags);
|
|
99
|
+
const relFile = path.relative(cwd, specFile);
|
|
100
|
+
const sections = splitSections(content);
|
|
101
|
+
const scenarios = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
for (const section of sections) {
|
|
104
|
+
// Consume optional leading `key: value` overrides, then the prose.
|
|
105
|
+
const lines = section.bodyLines;
|
|
106
|
+
let i = 0;
|
|
107
|
+
while (i < lines.length && lines[i].trim() === "")
|
|
108
|
+
i++;
|
|
109
|
+
const overrides = {};
|
|
110
|
+
while (i < lines.length && lines[i].trim() !== "") {
|
|
111
|
+
const m = /^([a-zA-Z]+):\s*(.+)$/.exec(lines[i].trim());
|
|
112
|
+
if (!m || !OVERRIDE_KEYS.has(m[1]))
|
|
113
|
+
break;
|
|
114
|
+
overrides[m[1]] = m[2].trim();
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
while (i < lines.length && lines[i].trim() === "")
|
|
118
|
+
i++;
|
|
119
|
+
const text = lines.slice(i).join("\n").trim();
|
|
120
|
+
if (!text) {
|
|
121
|
+
throw new Error(`Scenario "${section.name}" in ${relFile} has no description text.`);
|
|
122
|
+
}
|
|
123
|
+
const scenarioSlug = slugify(section.name);
|
|
124
|
+
if (seen.has(scenarioSlug)) {
|
|
125
|
+
throw new Error(`Duplicate scenario "${section.name}" (slug "${scenarioSlug}") in ${relFile}.`);
|
|
126
|
+
}
|
|
127
|
+
seen.add(scenarioSlug);
|
|
128
|
+
const tags = parseTags(overrides.tags) ?? fileTags;
|
|
129
|
+
scenarios.push({
|
|
130
|
+
slug: `${fileSlug}/${scenarioSlug}`,
|
|
131
|
+
name: section.name,
|
|
132
|
+
scenario: text,
|
|
133
|
+
feature: fileFeature,
|
|
134
|
+
profile: overrides.profile ?? fileProfile,
|
|
135
|
+
notes: overrides.notes,
|
|
136
|
+
tags,
|
|
137
|
+
file: relFile,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return scenarios;
|
|
141
|
+
}
|
|
142
|
+
/** Load every scenario across all spec files, validating slug uniqueness. */
|
|
143
|
+
export function loadScenarios(cwd = process.cwd()) {
|
|
144
|
+
const root = specsRoot(cwd);
|
|
145
|
+
const all = [];
|
|
146
|
+
const bySlug = new Map();
|
|
147
|
+
for (const file of findSpecFiles(root)) {
|
|
148
|
+
for (const scenario of parseSpec(file, root, cwd)) {
|
|
149
|
+
const prev = bySlug.get(scenario.slug);
|
|
150
|
+
if (prev) {
|
|
151
|
+
throw new Error(`Duplicate scenario slug "${scenario.slug}" in ${scenario.file} (also in ${prev}).`);
|
|
152
|
+
}
|
|
153
|
+
bySlug.set(scenario.slug, scenario.file);
|
|
154
|
+
all.push(scenario);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return all;
|
|
158
|
+
}
|
|
159
|
+
/** Render a `## ` section (with optional overrides) for appending to a spec. */
|
|
160
|
+
function renderSection(input) {
|
|
161
|
+
const overrides = [];
|
|
162
|
+
if (input.profile)
|
|
163
|
+
overrides.push(`profile: ${input.profile}`);
|
|
164
|
+
if (input.notes)
|
|
165
|
+
overrides.push(`notes: ${input.notes}`);
|
|
166
|
+
if (input.tags?.length)
|
|
167
|
+
overrides.push(`tags: [${input.tags.join(", ")}]`);
|
|
168
|
+
const head = `## ${input.name}\n`;
|
|
169
|
+
const meta = overrides.length ? overrides.join("\n") + "\n\n" : "";
|
|
170
|
+
return `${head}${meta}${input.scenario.trim()}\n`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Append a scenario to `.scout/specs/<feature-slug>.scout.md`, creating the
|
|
174
|
+
* file with frontmatter when it does not exist. Returns the logical slug.
|
|
175
|
+
*/
|
|
176
|
+
export function addScenario(input, cwd = process.cwd()) {
|
|
177
|
+
const root = specsRoot(cwd);
|
|
178
|
+
const fileSlug = slugify(input.feature);
|
|
179
|
+
const specFile = path.join(root, `${fileSlug}${SPEC_EXT}`);
|
|
180
|
+
fs.mkdirSync(path.dirname(specFile), { recursive: true });
|
|
181
|
+
const section = renderSection(input);
|
|
182
|
+
if (!fs.existsSync(specFile)) {
|
|
183
|
+
const frontmatter = `---\nfeature: ${input.feature}\n---\n\n`;
|
|
184
|
+
fs.writeFileSync(specFile, frontmatter + section);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const existing = fs.readFileSync(specFile, "utf8").replace(/\s*$/, "");
|
|
188
|
+
fs.writeFileSync(specFile, existing + "\n\n" + section);
|
|
189
|
+
}
|
|
190
|
+
const scenarioSlug = slugify(input.name);
|
|
191
|
+
const slug = `${fileSlug}/${scenarioSlug}`;
|
|
192
|
+
const found = parseSpec(specFile, root, cwd).find((s) => s.slug === slug);
|
|
193
|
+
if (!found) {
|
|
194
|
+
throw new Error(`Failed to round-trip scenario "${input.name}" into ${path.relative(cwd, specFile)}.`);
|
|
195
|
+
}
|
|
196
|
+
return found;
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=specs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specs.js","sourceRoot":"","sources":["../src/specs.ts"],"names":[],"mappings":"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"}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
import type { RunResult, Scenario, Step } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Filesystem store inside the target project:
|
|
4
|
-
* .scout/
|
|
5
|
-
* .scout/scripts/<slug>.json
|
|
6
|
-
* .scout/runs/<runId>/
|
|
7
|
-
* .scout/state/<profile>.json
|
|
4
|
+
* .scout/specs/<feature>.scout.md — committed (the suite; markdown source of truth)
|
|
5
|
+
* .scout/scripts/<slug>.json — committed (cached deterministic steps; `<slug>` is `<file>/<scenario>`)
|
|
6
|
+
* .scout/runs/<runId>/ — gitignored (artifacts per run)
|
|
7
|
+
* .scout/state/<profile>.json — gitignored (auth storageState)
|
|
8
8
|
*/
|
|
9
9
|
export declare class Store {
|
|
10
10
|
readonly root: string;
|
|
11
|
+
readonly cwd: string;
|
|
11
12
|
constructor(cwd?: string);
|
|
12
13
|
init(): void;
|
|
13
|
-
get scenariosFile(): string;
|
|
14
14
|
exists(): boolean;
|
|
15
15
|
listScenarios(): Scenario[];
|
|
16
|
-
|
|
17
|
-
addScenario(input: {
|
|
18
|
-
name: string;
|
|
19
|
-
scenario: string;
|
|
20
|
-
profile?: string;
|
|
21
|
-
notes?: string;
|
|
22
|
-
}): Scenario;
|
|
23
|
-
getScenario(idOrSlug: number | string): Scenario | undefined;
|
|
24
|
-
updateScenario(id: number, patch: Partial<Scenario>): void;
|
|
16
|
+
getScenario(slug: string): Scenario | undefined;
|
|
25
17
|
scriptPath(slug: string): string;
|
|
26
18
|
loadSteps(slug: string): Step[] | undefined;
|
|
27
19
|
saveSteps(slug: string, steps: Step[]): void;
|
|
@@ -30,4 +22,3 @@ export declare class Store {
|
|
|
30
22
|
/** Latest run result per scenario slug (for `scout report`) */
|
|
31
23
|
latestRuns(): Map<string, RunResult>;
|
|
32
24
|
}
|
|
33
|
-
export declare function slugify(name: string): string;
|
package/dist/store.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { SCOUT_DIR } from "./config.js";
|
|
4
|
+
import { SPECS_DIR, loadScenarios, slugToToken } from "./specs.js";
|
|
4
5
|
/**
|
|
5
6
|
* Filesystem store inside the target project:
|
|
6
|
-
* .scout/
|
|
7
|
-
* .scout/scripts/<slug>.json
|
|
8
|
-
* .scout/runs/<runId>/
|
|
9
|
-
* .scout/state/<profile>.json
|
|
7
|
+
* .scout/specs/<feature>.scout.md — committed (the suite; markdown source of truth)
|
|
8
|
+
* .scout/scripts/<slug>.json — committed (cached deterministic steps; `<slug>` is `<file>/<scenario>`)
|
|
9
|
+
* .scout/runs/<runId>/ — gitignored (artifacts per run)
|
|
10
|
+
* .scout/state/<profile>.json — gitignored (auth storageState)
|
|
10
11
|
*/
|
|
11
12
|
export class Store {
|
|
12
13
|
root;
|
|
14
|
+
cwd;
|
|
13
15
|
constructor(cwd = process.cwd()) {
|
|
16
|
+
this.cwd = cwd;
|
|
14
17
|
this.root = path.join(cwd, SCOUT_DIR);
|
|
15
18
|
}
|
|
16
19
|
init() {
|
|
20
|
+
fs.mkdirSync(path.join(this.root, SPECS_DIR), { recursive: true });
|
|
17
21
|
fs.mkdirSync(path.join(this.root, "scripts"), { recursive: true });
|
|
18
22
|
fs.mkdirSync(path.join(this.root, "runs"), { recursive: true });
|
|
19
23
|
fs.mkdirSync(path.join(this.root, "state"), { recursive: true });
|
|
@@ -21,51 +25,19 @@ export class Store {
|
|
|
21
25
|
if (!fs.existsSync(gitignore)) {
|
|
22
26
|
fs.writeFileSync(gitignore, "runs/\nstate/\n");
|
|
23
27
|
}
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
const example = path.join(this.root, SPECS_DIR, "example.scout.md");
|
|
29
|
+
if (!fs.existsSync(example)) {
|
|
30
|
+
fs.writeFileSync(example, EXAMPLE_SPEC);
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
|
-
get scenariosFile() {
|
|
29
|
-
return path.join(this.root, "scenarios.json");
|
|
30
|
-
}
|
|
31
33
|
exists() {
|
|
32
|
-
return fs.existsSync(this.
|
|
34
|
+
return fs.existsSync(this.root);
|
|
33
35
|
}
|
|
34
36
|
listScenarios() {
|
|
35
|
-
|
|
36
|
-
return [];
|
|
37
|
-
return JSON.parse(fs.readFileSync(this.scenariosFile, "utf8"));
|
|
38
|
-
}
|
|
39
|
-
saveScenarios(scenarios) {
|
|
40
|
-
fs.writeFileSync(this.scenariosFile, JSON.stringify(scenarios, null, 2) + "\n");
|
|
41
|
-
}
|
|
42
|
-
addScenario(input) {
|
|
43
|
-
const scenarios = this.listScenarios();
|
|
44
|
-
const id = scenarios.length ? Math.max(...scenarios.map((s) => s.id)) + 1 : 1;
|
|
45
|
-
const scenario = {
|
|
46
|
-
id,
|
|
47
|
-
slug: slugify(input.name),
|
|
48
|
-
name: input.name,
|
|
49
|
-
scenario: input.scenario,
|
|
50
|
-
profile: input.profile,
|
|
51
|
-
notes: input.notes,
|
|
52
|
-
status: "pending",
|
|
53
|
-
createdAt: new Date().toISOString(),
|
|
54
|
-
};
|
|
55
|
-
scenarios.push(scenario);
|
|
56
|
-
this.saveScenarios(scenarios);
|
|
57
|
-
return scenario;
|
|
37
|
+
return loadScenarios(this.cwd);
|
|
58
38
|
}
|
|
59
|
-
getScenario(
|
|
60
|
-
return this.listScenarios().find((s) => s.
|
|
61
|
-
}
|
|
62
|
-
updateScenario(id, patch) {
|
|
63
|
-
const scenarios = this.listScenarios();
|
|
64
|
-
const idx = scenarios.findIndex((s) => s.id === id);
|
|
65
|
-
if (idx === -1)
|
|
66
|
-
throw new Error(`Scenario ${id} not found`);
|
|
67
|
-
scenarios[idx] = { ...scenarios[idx], ...patch };
|
|
68
|
-
this.saveScenarios(scenarios);
|
|
39
|
+
getScenario(slug) {
|
|
40
|
+
return this.listScenarios().find((s) => s.slug === slug);
|
|
69
41
|
}
|
|
70
42
|
// ---- cached deterministic scripts ----
|
|
71
43
|
scriptPath(slug) {
|
|
@@ -78,12 +50,14 @@ export class Store {
|
|
|
78
50
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
79
51
|
}
|
|
80
52
|
saveSteps(slug, steps) {
|
|
81
|
-
|
|
53
|
+
const file = this.scriptPath(slug);
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
fs.writeFileSync(file, JSON.stringify(steps, null, 2) + "\n");
|
|
82
56
|
}
|
|
83
57
|
// ---- runs ----
|
|
84
58
|
newRunDir(slug) {
|
|
85
59
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
86
|
-
const dir = path.join(this.root, "runs", `${stamp}-${slug}`);
|
|
60
|
+
const dir = path.join(this.root, "runs", `${stamp}-${slugToToken(slug)}`);
|
|
87
61
|
fs.mkdirSync(dir, { recursive: true });
|
|
88
62
|
return dir;
|
|
89
63
|
}
|
|
@@ -107,13 +81,35 @@ export class Store {
|
|
|
107
81
|
return map;
|
|
108
82
|
}
|
|
109
83
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
84
|
+
// The example sits inside a fenced block so it loads as ZERO scenarios — it
|
|
85
|
+
// documents the format without polluting the suite (or the `--check` gate).
|
|
86
|
+
const EXAMPLE_SPEC = `---
|
|
87
|
+
feature: Example
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
# How to write a scout spec
|
|
91
|
+
|
|
92
|
+
One file per feature/component. File-level frontmatter sets defaults
|
|
93
|
+
(\`feature\`, \`profile\`, \`tags\`). Each \`##\` heading is one scenario; optional
|
|
94
|
+
\`profile\`/\`notes\`/\`tags\` override lines may follow a heading before the prose.
|
|
95
|
+
Write the flow + expected behavior in plain language — no selectors, no code.
|
|
96
|
+
|
|
97
|
+
Copy the block below into a new \`*.scout.md\` file (outside the fence) to start:
|
|
98
|
+
|
|
99
|
+
\`\`\`markdown
|
|
100
|
+
---
|
|
101
|
+
feature: Paywall
|
|
102
|
+
profile: anon
|
|
103
|
+
tags: [monetization]
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Free user hits paywall on ep 3
|
|
107
|
+
Open ep 3 of series X without login; paywall appears with a signup CTA.
|
|
108
|
+
|
|
109
|
+
## Subscriber bypasses paywall
|
|
110
|
+
profile: qa
|
|
111
|
+
|
|
112
|
+
Logged-in subscriber opens ep 3; plays with no paywall.
|
|
113
|
+
\`\`\`
|
|
114
|
+
`;
|
|
119
115
|
//# sourceMappingURL=store.js.map
|