@itmetsam/samurai-framework 0.22.5 → 0.22.7

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.
Files changed (49) hide show
  1. package/dist/agent/generate-kind.js +8 -3
  2. package/dist/agent/index.d.ts +3 -0
  3. package/dist/agent/index.js +3 -0
  4. package/dist/agent/plan/front-matter.d.ts +9 -0
  5. package/dist/agent/plan/front-matter.js +18 -0
  6. package/dist/agent/plan/plan-file.d.ts +34 -2
  7. package/dist/agent/plan/plan-file.js +90 -32
  8. package/dist/agent/plan/plan-path.d.ts +13 -0
  9. package/dist/agent/plan/plan-path.js +62 -0
  10. package/dist/agent/plan/prompt.js +1 -1
  11. package/dist/agent/plan/questions.d.ts +5 -0
  12. package/dist/agent/plan/questions.js +22 -0
  13. package/dist/agent/plan/read-plan.d.ts +1 -1
  14. package/dist/agent/plan/read-plan.js +3 -2
  15. package/dist/agent/plan/recorder.d.ts +3 -3
  16. package/dist/agent/plan/recorder.js +6 -7
  17. package/dist/agent/plan/tools.js +1 -1
  18. package/dist/agent/plan-link.d.ts +8 -0
  19. package/dist/agent/plan-link.js +25 -0
  20. package/dist/agent/recording.d.ts +2 -0
  21. package/dist/agent/recording.js +4 -0
  22. package/dist/agent/types.d.ts +5 -0
  23. package/dist/cli/generate.js +1 -1
  24. package/dist/cli/plan.d.ts +1 -1
  25. package/dist/cli/plan.js +8 -5
  26. package/dist/cli/usage.d.ts +1 -1
  27. package/dist/cli/usage.js +1 -1
  28. package/dist/config/config.js +2 -0
  29. package/dist/config/defaults.d.ts +2 -0
  30. package/dist/config/defaults.js +2 -0
  31. package/dist/config/plans-dir.d.ts +3 -0
  32. package/dist/config/plans-dir.js +11 -0
  33. package/dist/config/resolved-config.d.ts +1 -0
  34. package/dist/config/resolved-config.js +2 -1
  35. package/dist/recorder/spec-source.d.ts +2 -0
  36. package/dist/recorder/spec-source.js +9 -0
  37. package/dist/runner/registry.js +6 -2
  38. package/dist/runner/test-info.js +1 -0
  39. package/dist/runner/test-io.d.ts +5 -1
  40. package/dist/runner/test-io.js +21 -0
  41. package/dist/steps/edit.d.ts +3 -1
  42. package/dist/steps/edit.js +1 -1
  43. package/dist/steps/model.d.ts +3 -1
  44. package/dist/steps/parse.js +2 -1
  45. package/dist/steps/test-options.d.ts +5 -3
  46. package/dist/steps/test-options.js +49 -0
  47. package/dist/types/config.d.ts +6 -0
  48. package/dist/types/test.d.ts +4 -0
  49. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { resultSecrets } from "../recorder/session-secrets.js";
2
2
  import { buildSystemPrompt } from "./prompt.js";
3
+ import { questionsSection } from "./plan/questions.js";
3
4
  import { StepRecorder } from "./recording.js";
4
5
  import { personRecording } from "./take-over.js";
5
6
  import { createTools } from "./tools/index.js";
@@ -11,6 +12,7 @@ export const generateKind = {
11
12
  create: options.create,
12
13
  test: options.test,
13
14
  baseURL,
15
+ planFile: options.planFile,
14
16
  onStep: (event, code, source) => emit({
15
17
  type: "step",
16
18
  event,
@@ -27,9 +29,12 @@ export const generateKind = {
27
29
  ...input,
28
30
  ...(options.plan !== undefined && { hasPlan: true }),
29
31
  }),
30
- opening: (options, first) => options.plan !== undefined
31
- ? `Goal: ${options.goal}\n\nPlan (written earlier by another agent; follow it, but check each step on the live page):\n${options.plan.trim()}\n\n${first}`
32
- : `Goal: ${options.goal}\n\n${first}`,
32
+ opening: (options, first) => {
33
+ if (options.plan === undefined)
34
+ return `Goal: ${options.goal}\n\n${first}`;
35
+ const questions = questionsSection(options.plan);
36
+ return `Goal: ${options.goal}\n\nPlan (written earlier by another agent; follow it, but check each step on the live page):\n${options.plan.trim()}\n\n${questions === "" ? "" : `${questions}\n\n`}${first}`;
37
+ },
33
38
  secrets: (steps) => resultSecrets(steps.events, []),
34
39
  person: ({ page, sink, snapshots, baseURL }) => personRecording({
35
40
  page,
@@ -1,6 +1,9 @@
1
1
  import type { AgentOptions, AgentSession, PlanOptions, PlanSession } from "./types.js";
2
2
  export { DEFAULT_LIMITS, resolveLimits, type AgentLimits } from "./limits.js";
3
3
  export { assertModelObject, type AgentModel } from "./model.js";
4
+ export { splitFrontMatter } from "./plan/front-matter.js";
5
+ export { formatPlan, MAX_PLAN_BYTES, parsePlan, type ParsedPlan, type PlanDocument, type PlanQuestion, } from "./plan/plan-file.js";
6
+ export { defaultPlanFile, listPlans } from "./plan/plan-path.js";
4
7
  export { readPlan } from "./plan/read-plan.js";
5
8
  export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
6
9
  export type { AgentEndReason, AgentEvent, AgentOptions, AgentResult, AgentSession, PlanOptions, PlanResult, PlanSession, } from "./types.js";
@@ -2,6 +2,9 @@ import { startPlan } from "./plan/run.js";
2
2
  import { startAgent } from "./run.js";
3
3
  export { DEFAULT_LIMITS, resolveLimits } from "./limits.js";
4
4
  export { assertModelObject } from "./model.js";
5
+ export { splitFrontMatter } from "./plan/front-matter.js";
6
+ export { formatPlan, MAX_PLAN_BYTES, parsePlan, } from "./plan/plan-file.js";
7
+ export { defaultPlanFile, listPlans } from "./plan/plan-path.js";
5
8
  export { readPlan } from "./plan/read-plan.js";
6
9
  export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
7
10
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Splits the YAML front matter off a plan: a first line of `---` up to the next line of `---`. Nothing inside is
3
+ * parsed (the app keeps its own metadata there, and any key is fine). A plan without it is returned as it is; an
4
+ * opening fence with no closing one throws.
5
+ */
6
+ export declare function splitFrontMatter(text: string): {
7
+ front: string;
8
+ body: string;
9
+ };
@@ -0,0 +1,18 @@
1
+ const FENCE = "---";
2
+ /**
3
+ * Splits the YAML front matter off a plan: a first line of `---` up to the next line of `---`. Nothing inside is
4
+ * parsed (the app keeps its own metadata there, and any key is fine). A plan without it is returned as it is; an
5
+ * opening fence with no closing one throws.
6
+ */
7
+ export function splitFrontMatter(text) {
8
+ const lines = text.replace(/^/, "").split("\n");
9
+ if (lines[0]?.trimEnd() !== FENCE)
10
+ return { front: "", body: text };
11
+ const end = lines.findIndex((line, i) => i > 0 && line.trimEnd() === FENCE);
12
+ if (end < 0)
13
+ throw new Error(`the front matter block opened with "---" on the first line has no closing "---" line`);
14
+ return {
15
+ front: `${lines.slice(0, end + 1).join("\n")}\n`,
16
+ body: lines.slice(end + 1).join("\n"),
17
+ };
18
+ }
@@ -1,5 +1,6 @@
1
1
  /** A plan file may not be larger than this (bytes), for the planner and for `generate --plan` */
2
2
  export declare const MAX_PLAN_BYTES: number;
3
+ /** One step of a plan, with what to check after it */
3
4
  interface PlanStep {
4
5
  text: string;
5
6
  expect?: string;
@@ -12,8 +13,37 @@ interface ParsedPlan {
12
13
  steps: PlanStep[];
13
14
  seen: string[];
14
15
  notDone: string[];
16
+ /** Every open question, answered or not */
15
17
  questions: string[];
18
+ /** Every open question with its answer, if it has one; what `formatPlan` takes back */
19
+ questionList: PlanQuestion[];
20
+ /** The questions that have an `Answer:` line, with the answer; a question with an empty answer is not here */
21
+ answers: {
22
+ question: string;
23
+ answer: string;
24
+ }[];
16
25
  }
26
+ /** A plan as `formatPlan` writes it and `parsePlan` reads it back */
27
+ interface PlanDocument {
28
+ title: string;
29
+ goal: string;
30
+ start: string;
31
+ steps: PlanStep[];
32
+ seen: string[];
33
+ notDone: string[];
34
+ /** An answered question is written with an indented `Answer:` line below it */
35
+ questions: PlanQuestion[];
36
+ }
37
+ interface PlanQuestion {
38
+ text: string;
39
+ answer?: string;
40
+ }
41
+ /**
42
+ * Writes a plan as Markdown, the format the planner writes and `parsePlan` reads. Every text is masked and made one
43
+ * line. `front` is a raw front matter block (ending with a new line, as `splitFrontMatter` returns it) placed first.
44
+ * Throws when the result is larger than `MAX_PLAN_BYTES`.
45
+ */
46
+ export declare function formatPlan(plan: PlanDocument, front?: string): string;
17
47
  /**
18
48
  * A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
19
49
  * four headings whenever the run stops. Texts are masked and made one line long.
@@ -30,6 +60,8 @@ declare class PlanFile {
30
60
  /** The title, which is also what the run reports as the name of the test */
31
61
  get title(): string;
32
62
  get count(): number;
63
+ /** The front matter the file has now (the app may change it while the plan is written); kept as it is */
64
+ private frontMatter;
33
65
  private render;
34
66
  /** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
35
67
  private save;
@@ -44,7 +76,7 @@ declare class PlanFile {
44
76
  /** Writes the first version of the file */
45
77
  create(): void;
46
78
  }
47
- export type { PlanFile };
79
+ export type { PlanFile, ParsedPlan, PlanDocument, PlanQuestion };
48
80
  /** Starts a plan file: the headings are written at once. An existing file is an error unless `force` is set */
49
81
  export declare function createPlan(input: {
50
82
  goal: string;
@@ -53,7 +85,7 @@ export declare function createPlan(input: {
53
85
  force?: boolean;
54
86
  }): PlanFile;
55
87
  /**
56
- * Reads a plan, the one `createPlan` wrote or one a person edited: blank lines, a missing title, missing sections
88
+ * Reads a plan, the one `createPlan` wrote or one a person edited. A leading front matter block is ignored. Blank lines, a missing title, missing sections
57
89
  * and `None.` placeholders are fine. Only the `## Steps` heading is needed.
58
90
  */
59
91
  export declare function parsePlan(text: string): ParsedPlan;
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { projectDir } from "../../config/config.js";
4
4
  import { maskText } from "../../config/mask.js";
5
+ import { splitFrontMatter } from "./front-matter.js";
5
6
  /** A plan file may not be larger than this (bytes), for the planner and for `generate --plan` */
6
7
  export const MAX_PLAN_BYTES = 32 * 1024;
7
8
  const NONE = "None.";
@@ -15,6 +16,48 @@ function bullets(items) {
15
16
  ? items.map((item) => `- ${item}`).join("\n")
16
17
  : `- ${NONE}`;
17
18
  }
19
+ /**
20
+ * Writes a plan as Markdown, the format the planner writes and `parsePlan` reads. Every text is masked and made one
21
+ * line. `front` is a raw front matter block (ending with a new line, as `splitFrontMatter` returns it) placed first.
22
+ * Throws when the result is larger than `MAX_PLAN_BYTES`.
23
+ */
24
+ export function formatPlan(plan, front = "") {
25
+ const steps = plan.steps.map((step, i) => {
26
+ const expect = step.expect === undefined ? "" : oneLine(step.expect);
27
+ return `${i + 1}. ${oneLine(step.text)}${expect !== "" ? `\n Expect: ${expect}` : ""}`;
28
+ });
29
+ const questions = plan.questions.map((q) => {
30
+ const answer = q.answer === undefined ? "" : oneLine(q.answer);
31
+ return `- ${oneLine(q.text)}${answer !== "" ? `\n Answer: ${answer}` : ""}`;
32
+ });
33
+ const text = [
34
+ ...(front === "" ? [] : [front.replace(/\n$/, "")]),
35
+ `# Plan: ${oneLine(plan.title)}`,
36
+ "",
37
+ `Goal: ${oneLine(plan.goal)}`,
38
+ `Start: ${oneLine(plan.start)}`,
39
+ "",
40
+ "## Steps",
41
+ "",
42
+ steps.length > 0 ? steps.join("\n") : `- ${NONE}`,
43
+ "",
44
+ "## Seen on the page",
45
+ "",
46
+ bullets(plan.seen.map(oneLine)),
47
+ "",
48
+ "## Not done",
49
+ "",
50
+ bullets(plan.notDone.map(oneLine)),
51
+ "",
52
+ "## Open questions",
53
+ "",
54
+ questions.length > 0 ? questions.join("\n") : `- ${NONE}`,
55
+ "",
56
+ ].join("\n");
57
+ if (Buffer.byteLength(text) > MAX_PLAN_BYTES)
58
+ throw new Error(`the plan file is full (${MAX_PLAN_BYTES / 1024} KB); finish with what it holds, or shorten the next entry`);
59
+ return text;
60
+ }
18
61
  /**
19
62
  * A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
20
63
  * four headings whenever the run stops. Texts are masked and made one line long.
@@ -37,37 +80,29 @@ class PlanFile {
37
80
  get count() {
38
81
  return this.steps.length;
39
82
  }
40
- render() {
41
- const steps = this.steps.map((step, i) => `${i + 1}. ${step.text}${step.expect !== undefined ? `\n Expect: ${step.expect}` : ""}`);
42
- return [
43
- `# Plan: ${this.title}`,
44
- "",
45
- `Goal: ${this.goal}`,
46
- `Start: ${this.start}`,
47
- "",
48
- "## Steps",
49
- "",
50
- steps.length > 0 ? steps.join("\n") : `- ${NONE}`,
51
- "",
52
- "## Seen on the page",
53
- "",
54
- bullets(this.seenItems),
55
- "",
56
- "## Not done",
57
- "",
58
- bullets(this.notDoneItems),
59
- "",
60
- "## Open questions",
61
- "",
62
- bullets(this.questionItems),
63
- "",
64
- ].join("\n");
83
+ /** The front matter the file has now (the app may change it while the plan is written); kept as it is */
84
+ frontMatter() {
85
+ try {
86
+ return splitFrontMatter(readFileSync(this.file, "utf8")).front;
87
+ }
88
+ catch {
89
+ return "";
90
+ }
91
+ }
92
+ render(front) {
93
+ return formatPlan({
94
+ title: this.title,
95
+ goal: this.goal,
96
+ start: this.start,
97
+ steps: this.steps,
98
+ seen: this.seenItems,
99
+ notDone: this.notDoneItems,
100
+ questions: this.questionItems.map((text) => ({ text })),
101
+ }, front);
65
102
  }
66
103
  /** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
67
104
  save() {
68
- const text = this.render();
69
- if (Buffer.byteLength(text) > MAX_PLAN_BYTES)
70
- throw new Error(`the plan file is full (${MAX_PLAN_BYTES / 1024} KB); finish with what it holds, or shorten the next entry`);
105
+ const text = this.render(this.frontMatter());
71
106
  mkdirSync(path.dirname(this.file), { recursive: true });
72
107
  writeFileSync(this.file, text);
73
108
  }
@@ -129,6 +164,7 @@ const HEADING = /^##\s+(.*?)\s*$/;
129
164
  const STEP = /^\s*\d+\.\s+(.*)$/;
130
165
  const BULLET = /^\s*[-*]\s+(.*)$/;
131
166
  const EXPECT = /^\s+Expect:\s*(.*)$/i;
167
+ const ANSWER = /^\s+Answer:\s*(.*)$/i;
132
168
  const FIELD = /^(Goal|Start):\s*(.*)$/i;
133
169
  function sectionOf(heading) {
134
170
  const name = heading.toLowerCase();
@@ -143,7 +179,7 @@ function sectionOf(heading) {
143
179
  return "other";
144
180
  }
145
181
  /**
146
- * Reads a plan, the one `createPlan` wrote or one a person edited: blank lines, a missing title, missing sections
182
+ * Reads a plan, the one `createPlan` wrote or one a person edited. A leading front matter block is ignored. Blank lines, a missing title, missing sections
147
183
  * and `None.` placeholders are fine. Only the `## Steps` heading is needed.
148
184
  */
149
185
  export function parsePlan(text) {
@@ -155,10 +191,19 @@ export function parsePlan(text) {
155
191
  seen: [],
156
192
  notDone: [],
157
193
  questions: [],
194
+ questionList: [],
195
+ answers: [],
158
196
  };
159
197
  let section;
160
198
  let hasSteps = false;
161
- for (const line of text.split(/\r?\n/)) {
199
+ let body;
200
+ try {
201
+ body = splitFrontMatter(text).body;
202
+ }
203
+ catch (error) {
204
+ throw new Error(`parsePlan(): ${error.message}`);
205
+ }
206
+ for (const line of body.split(/\r?\n/)) {
162
207
  const heading = HEADING.exec(line);
163
208
  if (heading) {
164
209
  section = sectionOf(heading[1]);
@@ -191,9 +236,22 @@ export function parsePlan(text) {
191
236
  last.text = `${last.text} ${line.trim()}`;
192
237
  continue;
193
238
  }
239
+ const answer = section === "questions" ? ANSWER.exec(line) : null;
240
+ const asked = plan.questions[plan.questions.length - 1];
241
+ if (answer) {
242
+ const text = answer[1].trim();
243
+ if (asked !== undefined && text !== "") {
244
+ plan.answers.push({ question: asked, answer: text });
245
+ plan.questionList[plan.questionList.length - 1].answer = text;
246
+ }
247
+ continue;
248
+ }
194
249
  const item = BULLET.exec(line)?.[1]?.trim();
195
- if (item !== undefined && item !== NONE)
250
+ if (item !== undefined && item !== NONE) {
196
251
  plan[section].push(item);
252
+ if (section === "questions")
253
+ plan.questionList.push({ text: item });
254
+ }
197
255
  }
198
256
  if (!hasSteps)
199
257
  throw new Error('parsePlan(): the plan has no "## Steps" heading');
@@ -0,0 +1,13 @@
1
+ /** Lower-case ascii words of a goal joined by `-`, at most 60 characters; `plan` when nothing is left */
2
+ export declare function slugOf(goal: string): string;
3
+ /**
4
+ * The file `samurai plan` writes when no `--out` is given: `<plansDir>/<slug-of-goal>.plan.md` below the project
5
+ * folder, with `-2`, `-3`, ... added until the name is free, so an earlier plan is never replaced. The folder is
6
+ * created. Returns the path relative to the project folder, with `/` separators.
7
+ */
8
+ export declare function defaultPlanFile(goal: string): Promise<string>;
9
+ /**
10
+ * The plan files (`*.plan.md`) below the config's `plansDir`, searched in all sub-folders: paths relative to the
11
+ * project folder, with `/` separators, sorted. A missing folder gives an empty list.
12
+ */
13
+ export declare function listPlans(): Promise<string[]>;
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { loadRawConfig, projectDir } from "../../config/config.js";
4
+ import { DEFAULT_PLANS_DIR } from "../../config/defaults.js";
5
+ const SLUG_LENGTH = 60;
6
+ /** Lower-case ascii words of a goal joined by `-`, at most 60 characters; `plan` when nothing is left */
7
+ export function slugOf(goal) {
8
+ const slug = goal
9
+ .normalize("NFKD")
10
+ .replace(/[̀-ͯ]/g, "")
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .slice(0, SLUG_LENGTH)
14
+ .replace(/^-+|-+$/g, "");
15
+ return slug === "" ? "plan" : slug;
16
+ }
17
+ /** The config's `plansDir`; a project without a `samurai.config.ts` (and no config given) uses the default */
18
+ async function plansDirOf() {
19
+ try {
20
+ return (await loadRawConfig()).plansDir ?? DEFAULT_PLANS_DIR;
21
+ }
22
+ catch (error) {
23
+ if (existsSync(path.join(projectDir(), "samurai.config.ts")))
24
+ throw error;
25
+ return DEFAULT_PLANS_DIR;
26
+ }
27
+ }
28
+ /**
29
+ * The file `samurai plan` writes when no `--out` is given: `<plansDir>/<slug-of-goal>.plan.md` below the project
30
+ * folder, with `-2`, `-3`, ... added until the name is free, so an earlier plan is never replaced. The folder is
31
+ * created. Returns the path relative to the project folder, with `/` separators.
32
+ */
33
+ export async function defaultPlanFile(goal) {
34
+ const dir = await plansDirOf();
35
+ const folder = path.resolve(projectDir(), dir);
36
+ mkdirSync(folder, { recursive: true });
37
+ const slug = slugOf(goal);
38
+ for (let n = 1;; n++) {
39
+ const name = `${n === 1 ? slug : `${slug}-${n}`}.plan.md`;
40
+ if (!existsSync(path.join(folder, name)))
41
+ return path
42
+ .relative(projectDir(), path.join(folder, name))
43
+ .split(path.sep)
44
+ .join("/");
45
+ }
46
+ }
47
+ /**
48
+ * The plan files (`*.plan.md`) below the config's `plansDir`, searched in all sub-folders: paths relative to the
49
+ * project folder, with `/` separators, sorted. A missing folder gives an empty list.
50
+ */
51
+ export async function listPlans() {
52
+ const folder = path.resolve(projectDir(), await plansDirOf());
53
+ if (!existsSync(folder))
54
+ return [];
55
+ return readdirSync(folder, { recursive: true, encoding: "utf8" })
56
+ .filter((name) => name.endsWith(".plan.md"))
57
+ .map((name) => path
58
+ .relative(projectDir(), path.join(folder, name))
59
+ .split(path.sep)
60
+ .join("/"))
61
+ .sort();
62
+ }
@@ -21,7 +21,7 @@ export function buildPlanPrompt(input) {
21
21
  - snapshot: the page as text.
22
22
  - navigate(url): open a URL or a path. Allowed without asking: ${input.origins.length > 0 ? input.origins.join(", ") : "none"}. Any other site asks the person and may be refused.
23
23
  - act(kind, target, value?): click, fill, select, check, uncheck, hover, dblclick, clear, press. It writes nothing to the plan.
24
- - step(text, expect?): write a step you did. Refused when you did nothing since the last step.
24
+ - step(text, expect?): write a step you did. Refused when every act or navigate so far is already a step.
25
25
  - seen(text), notDone(text): notes for the test writer.
26
26
  - question(question, options?): ask the person; it is kept in the plan.
27
27
  - finish(summary).`,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The plan's open questions as a prompt section: the answered ones with their answers, then the unanswered ones,
3
+ * which the agent may `ask` the person. Empty when the plan has none or cannot be read.
4
+ */
5
+ export declare function questionsSection(planText: string): string;
@@ -0,0 +1,22 @@
1
+ import { parsePlan } from "./plan-file.js";
2
+ /**
3
+ * The plan's open questions as a prompt section: the answered ones with their answers, then the unanswered ones,
4
+ * which the agent may `ask` the person. Empty when the plan has none or cannot be read.
5
+ */
6
+ export function questionsSection(planText) {
7
+ let plan;
8
+ try {
9
+ plan = parsePlan(planText);
10
+ }
11
+ catch {
12
+ return "";
13
+ }
14
+ const answered = new Set(plan.answers.map((entry) => entry.question));
15
+ const open = plan.questions.filter((question) => !answered.has(question));
16
+ const parts = [];
17
+ if (plan.answers.length > 0)
18
+ parts.push(`Questions the person has answered (use the answers, do not ask again):\n${plan.answers.map((entry) => `- ${entry.question}\n Answer: ${entry.answer}`).join("\n")}`);
19
+ if (open.length > 0)
20
+ parts.push(`Questions still open (call ask if you need the answer, do not guess):\n${open.map((question) => `- ${question}`).join("\n")}`);
21
+ return parts.join("\n\n");
22
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Reads a plan for `generate --plan`: the text goes to the agent as it is. A missing file, a file over 32 KB and a
2
+ * Reads a plan for `generate --plan`: the text goes to the agent as it is, without a front matter block. A missing file, a file over 32 KB and a
3
3
  * file without a `## Steps` heading are errors that name the file.
4
4
  */
5
5
  export declare function readPlan(file: string): string;
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { projectDir } from "../../config/config.js";
4
+ import { splitFrontMatter } from "./front-matter.js";
4
5
  import { MAX_PLAN_BYTES, parsePlan } from "./plan-file.js";
5
6
  /**
6
- * Reads a plan for `generate --plan`: the text goes to the agent as it is. A missing file, a file over 32 KB and a
7
+ * Reads a plan for `generate --plan`: the text goes to the agent as it is, without a front matter block. A missing file, a file over 32 KB and a
7
8
  * file without a `## Steps` heading are errors that name the file.
8
9
  */
9
10
  export function readPlan(file) {
@@ -20,9 +21,9 @@ export function readPlan(file) {
20
21
  const text = readFileSync(full, "utf8");
21
22
  try {
22
23
  parsePlan(text);
24
+ return splitFrontMatter(text).body;
23
25
  }
24
26
  catch (error) {
25
27
  throw new Error(`readPlan(): ${full}: ${error.message.replace(/^parsePlan\(\): /, "")}`);
26
28
  }
27
- return text;
28
29
  }
@@ -2,14 +2,14 @@ import type { Observation, RecorderEvent } from "../../recorder/normalise.js";
2
2
  import type { RunSink } from "../run-kind.js";
3
3
  import type { PlanFile } from "./plan-file.js";
4
4
  /**
5
- * What a planning run keeps: the plan file, whether something was done on the page since the last step, and the
5
+ * What a planning run keeps: the plan file, how many things were done on the page, and the
6
6
  * requests the guard stopped. `observe` is how the shared tools (`act`, `navigate`) report what they did; they
7
7
  * write nothing, a plan step is the agent's own words (`step`).
8
8
  */
9
9
  export declare class PlanRecorder implements RunSink {
10
10
  private readonly plan;
11
11
  private readonly onStep;
12
- private acted;
12
+ private done;
13
13
  private blocked;
14
14
  constructor(plan: PlanFile, onStep: (n: number, text: string) => void);
15
15
  get count(): number;
@@ -17,7 +17,7 @@ export declare class PlanRecorder implements RunSink {
17
17
  get testName(): string;
18
18
  observe(_observation: Observation): RecorderEvent[];
19
19
  undo(_count: number): number;
20
- /** Records a step the agent has done on the live page; refused when it did nothing since the last step */
20
+ /** Records a step the agent has done on the live page; refused when every action so far is already a step */
21
21
  addStep(text: string, expect?: string): number;
22
22
  setStart(url: string): void;
23
23
  seen(text: string): void;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * What a planning run keeps: the plan file, whether something was done on the page since the last step, and the
2
+ * What a planning run keeps: the plan file, how many things were done on the page, and the
3
3
  * requests the guard stopped. `observe` is how the shared tools (`act`, `navigate`) report what they did; they
4
4
  * write nothing, a plan step is the agent's own words (`step`).
5
5
  */
@@ -7,7 +7,7 @@ export class PlanRecorder {
7
7
  constructor(plan, onStep) {
8
8
  this.plan = plan;
9
9
  this.onStep = onStep;
10
- this.acted = false;
10
+ this.done = 0;
11
11
  this.blocked = new Set();
12
12
  }
13
13
  get count() {
@@ -20,18 +20,17 @@ export class PlanRecorder {
20
20
  return this.plan.title;
21
21
  }
22
22
  observe(_observation) {
23
- this.acted = true;
23
+ this.done++;
24
24
  return [];
25
25
  }
26
26
  undo(_count) {
27
27
  return 0;
28
28
  }
29
- /** Records a step the agent has done on the live page; refused when it did nothing since the last step */
29
+ /** Records a step the agent has done on the live page; refused when every action so far is already a step */
30
30
  addStep(text, expect) {
31
- if (!this.acted)
32
- throw new Error("nothing was done since the last step; act first");
31
+ if (this.plan.count >= this.done)
32
+ throw new Error(`all ${this.done} actions so far are already recorded as steps; act first`);
33
33
  const n = this.plan.addStep(text, expect);
34
- this.acted = false;
35
34
  this.onStep(n, text);
36
35
  return n;
37
36
  }
@@ -8,7 +8,7 @@ const step = defineTool({
8
8
  name: "step",
9
9
  description: "Write one step into the plan, after you did it on the live page with act or navigate. Say what to do the way " +
10
10
  'snapshot names it (role and name, for example: Click "Create account" (button)), and in expect what showed ' +
11
- "afterwards. It is refused when you did nothing since the last step. One step per thing done.",
11
+ "afterwards. It is refused when every act or navigate so far is already a step. One step per thing done.",
12
12
  inputSchema: z.object({
13
13
  text: z.string().min(1).max(600),
14
14
  expect: z.string().min(1).max(400).optional(),
@@ -0,0 +1,8 @@
1
+ /** The plan's path as the annotation names it: relative to the project folder, with `/` separators */
2
+ export declare function planAnnotationPath(planFile: string): string;
3
+ /**
4
+ * `source` with test `testIndex` marked as made from `planFile`: an annotation `{ type: "plan", description }` in
5
+ * its options. An existing plan annotation gets the new path in place, the test's other annotations stay, and a
6
+ * test that already names this plan leaves the source as it is.
7
+ */
8
+ export declare function linkPlan(source: string, testIndex: number, planFile: string): string;
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ import { projectDir } from "../config/config.js";
3
+ import { applyEdit } from "../steps/edit.js";
4
+ import { parseSpec } from "../steps/parse.js";
5
+ /** The plan's path as the annotation names it: relative to the project folder, with `/` separators */
6
+ export function planAnnotationPath(planFile) {
7
+ const relative = path.isAbsolute(planFile)
8
+ ? path.relative(projectDir(), planFile)
9
+ : path.normalize(planFile);
10
+ return relative.split(path.sep).join("/");
11
+ }
12
+ /**
13
+ * `source` with test `testIndex` marked as made from `planFile`: an annotation `{ type: "plan", description }` in
14
+ * its options. An existing plan annotation gets the new path in place, the test's other annotations stay, and a
15
+ * test that already names this plan leaves the source as it is.
16
+ */
17
+ export function linkPlan(source, testIndex, planFile) {
18
+ const note = { type: "plan", description: planAnnotationPath(planFile) };
19
+ const existing = parseSpec(source)[testIndex]?.annotations ?? [];
20
+ const at = existing.findIndex((entry) => entry.type === "plan");
21
+ const annotations = at < 0
22
+ ? [...existing, note]
23
+ : existing.map((entry, i) => (i === at ? note : entry));
24
+ return applyEdit(source, { op: "declare", test: testIndex, annotations });
25
+ }
@@ -6,6 +6,8 @@ export interface StepRecorderOptions {
6
6
  create?: string | undefined;
7
7
  /** The existing test to write into, as for `recordSpec` */
8
8
  test?: string | number | undefined;
9
+ /** The plan the test is made from, as `AgentOptions.planFile`: written on the test as a `plan` annotation */
10
+ planFile?: string | undefined;
9
11
  /** Relative URLs are written against this */
10
12
  baseURL?: string | undefined;
11
13
  /** Called after each event is written, with the statement it produced and the file's new content */
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { projectDir } from "../config/config.js";
3
3
  import { Normaliser, } from "../recorder/normalise.js";
4
4
  import { prepareSpec, readSpec, SpecWriter } from "../recorder/spec-source.js";
5
+ import { linkPlan } from "./plan-link.js";
5
6
  import { stepToSource } from "../steps/emit.js";
6
7
  /** The statement an event wrote, `undefined` for a removal */
7
8
  function codeOf(event) {
@@ -23,6 +24,9 @@ export class StepRecorder {
23
24
  const spec = prepareSpec(this.file, readSpec(this.file, options), options);
24
25
  this.testName = spec.testName;
25
26
  this.writer = new SpecWriter(spec.source, this.file, spec.testIndex);
27
+ const planFile = options.planFile;
28
+ if (planFile !== undefined)
29
+ this.writer.rewrite((source) => linkPlan(source, spec.testIndex, planFile));
26
30
  this.steps = spec.at;
27
31
  this.first = spec.at;
28
32
  this.normaliser = this.fresh();
@@ -96,6 +96,11 @@ export interface AgentOptions extends BaseAgentOptions {
96
96
  test?: string | number;
97
97
  /** The text of a plan written earlier (see `runPlan`): a draft to follow and check on the live page */
98
98
  plan?: string;
99
+ /**
100
+ * The plan file the `plan` text came from, relative to the project folder (an absolute path is made relative).
101
+ * The test is marked with `{ type: "plan", description: <this path> }` in its `annotations` option
102
+ */
103
+ planFile?: string;
99
104
  }
100
105
  /** A planning run: explores the page and writes a Markdown plan, never a spec, and never submits */
101
106
  export interface PlanOptions extends BaseAgentOptions {
@@ -23,7 +23,7 @@ export async function generateCommand(parsed, io, deps = {}) {
23
23
  file: files[0],
24
24
  ...(test !== undefined && { test }),
25
25
  ...(values.new !== undefined && { create: values.new }),
26
- ...(plan !== undefined && { plan }),
26
+ ...(plan !== undefined && { plan, planFile: values.plan }),
27
27
  });
28
28
  return followRun(session, io, values.json === true, (result) => summarise(io, result));
29
29
  }
@@ -2,5 +2,5 @@ import { type AgentCommandDeps } from "./agent-run.js";
2
2
  import type { ParsedCommandLine } from "./args.js";
3
3
  import type { Io } from "./io.js";
4
4
  export type PlanDeps = AgentCommandDeps;
5
- /** `samurai plan "<goal>"`: lets the AI agent explore the page and write a test plan, without submitting anything */
5
+ /** `samurai plan "<goal>"`: without `--out` the plan goes to `<plansDir>/<slug-of-goal>.plan.md`. Lets the AI agent explore the page and write a test plan, without submitting anything */
6
6
  export declare function planCommand(parsed: ParsedCommandLine, io: Io, deps?: PlanDeps): Promise<number>;
package/dist/cli/plan.js CHANGED
@@ -1,19 +1,22 @@
1
1
  import { endingOf, followRun, prepareAgentRun, } from "./agent-run.js";
2
- const USAGE_LINE = 'Usage: samurai plan "<goal>" --out <plan.md> [--force] [--url <url>] [--model <provider:name>] [--max-steps <n>] [--yes-origin <origin>] [--secret <NAME>] [--json]';
3
- /** `samurai plan "<goal>"`: lets the AI agent explore the page and write a test plan, without submitting anything */
2
+ const USAGE_LINE = 'Usage: samurai plan "<goal>" [--out <plan.md>] [--force] [--url <url>] [--model <provider:name>] [--max-steps <n>] [--yes-origin <origin>] [--secret <NAME>] [--json]';
3
+ /** `samurai plan "<goal>"`: without `--out` the plan goes to `<plansDir>/<slug-of-goal>.plan.md`. Lets the AI agent explore the page and write a test plan, without submitting anything */
4
4
  export async function planCommand(parsed, io, deps = {}) {
5
5
  const { values, positionals } = parsed;
6
6
  const goal = positionals[0];
7
7
  if (!goal)
8
8
  throw new Error(`Which goal? ${USAGE_LINE}`);
9
- if (values.out === undefined || values.out === "")
10
- throw new Error(`Give the plan file with --out <plan.md>. ${USAGE_LINE}`);
11
9
  const { agent, model, options } = await prepareAgentRun(parsed, io, deps, "plan");
10
+ const out = values.out === undefined || values.out === ""
11
+ ? await agent.defaultPlanFile(goal)
12
+ : values.out;
13
+ if (values.out === undefined && values.json !== true)
14
+ io.out(`The plan will be written to ${out}\n`);
12
15
  const session = agent.runPlan({
13
16
  ...options,
14
17
  model,
15
18
  goal,
16
- out: values.out,
19
+ out,
17
20
  ...(values.force === true && { force: true }),
18
21
  });
19
22
  return followRun(session, io, values.json === true, (result) => summarise(io, result));
@@ -1 +1 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai plan \"<goal>\" Let an AI agent explore a page and write a test plan (Markdown); it submits nothing\n samurai generate \"<goal>\" Let an AI agent write a test into a spec file (needs ai, zod and a provider)\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json; always with a window):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\nOptions for plan (also --env, --browser, --port, --headless, --json and the model, limit and\norigin options of generate below):\n --out <plan.md> The plan file to write (required); written after every step\n --force Replace --out when it exists\n The page is read-only while planning: every request that is not a GET is blocked.\n\nOptions for generate (also --env, --browser, --port, --headless, --json):\n --file <spec> The spec file to write into (required, once)\n --plan <plan.md> A plan from samurai plan: the agent follows it and checks each step\n --new <title> Add a test with this title (creates the file if needed)\n --test <name|index> Write into this test; not needed when the file has one\n --url <url> Page to start on (default: the environment's baseURL)\n --model <provider:name> anthropic, openai or openai-compatible, for example\n anthropic:claude-sonnet-4-5. Default: SAMURAI_AI_MODEL, then the\n config's ai.model. Keys: ANTHROPIC_API_KEY, OPENAI_API_KEY,\n SAMURAI_AI_API_KEY (+ SAMURAI_AI_BASE_URL for openai-compatible)\n --max-steps <n> Most model calls in the run (default 40)\n --yes-origin <origin> A site the agent may open without asking; repeatable\n --secret <NAME> A secret the agent may type (it never sees the value); repeatable\n --secret-field <css> A field whose value is never shown to the model; repeatable\n Questions are asked in the terminal; without one (or with --json) an unknown site is refused\n and any other question ends the run.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed (generate, plan: the agent finished), 1 when a test failed (generate, plan: it ended at a limit, was stopped or failed), 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai plan \"<goal>\" Let an AI agent explore a page and write a test plan (Markdown); it submits nothing\n samurai generate \"<goal>\" Let an AI agent write a test into a spec file (needs ai, zod and a provider)\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json; always with a window):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\nOptions for plan (also --env, --browser, --port, --headless, --json and the model, limit and\norigin options of generate below):\n --out <plan.md> The plan file to write; written after every step. Without it: <plansDir>/<goal-slug>.plan.md\n --force Replace --out when it exists\n The page is read-only while planning: every request that is not a GET is blocked.\n\nOptions for generate (also --env, --browser, --port, --headless, --json):\n --file <spec> The spec file to write into (required, once)\n --plan <plan.md> A plan from samurai plan: the agent follows it and checks each step\n --new <title> Add a test with this title (creates the file if needed)\n --test <name|index> Write into this test; not needed when the file has one\n --url <url> Page to start on (default: the environment's baseURL)\n --model <provider:name> anthropic, openai or openai-compatible, for example\n anthropic:claude-sonnet-4-5. Default: SAMURAI_AI_MODEL, then the\n config's ai.model. Keys: ANTHROPIC_API_KEY, OPENAI_API_KEY,\n SAMURAI_AI_API_KEY (+ SAMURAI_AI_BASE_URL for openai-compatible)\n --max-steps <n> Most model calls in the run (default 40)\n --yes-origin <origin> A site the agent may open without asking; repeatable\n --secret <NAME> A secret the agent may type (it never sees the value); repeatable\n --secret-field <css> A field whose value is never shown to the model; repeatable\n Questions are asked in the terminal; without one (or with --json) an unknown site is refused\n and any other question ends the run.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed (generate, plan: the agent finished), 1 when a test failed (generate, plan: it ended at a limit, was stopped or failed), 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
package/dist/cli/usage.js CHANGED
@@ -45,7 +45,7 @@ Options for record (also --env, --port, --json; always with a window):
45
45
 
46
46
  Options for plan (also --env, --browser, --port, --headless, --json and the model, limit and
47
47
  origin options of generate below):
48
- --out <plan.md> The plan file to write (required); written after every step
48
+ --out <plan.md> The plan file to write; written after every step. Without it: <plansDir>/<goal-slug>.plan.md
49
49
  --force Replace --out when it exists
50
50
  The page is read-only while planning: every request that is not a GET is blocked.
51
51
 
@@ -8,6 +8,7 @@ import { validateForbidOnlyConfig } from "./forbid-only.js";
8
8
  import { validateRetriesConfig } from "./retries.js";
9
9
  import { validateFullyParallelConfig } from "./fully-parallel.js";
10
10
  import { validateWorkersConfig } from "./workers.js";
11
+ import { validatePlansDirConfig } from "./plans-dir.js";
11
12
  import { validateWebServerConfig } from "./web-server-options.js";
12
13
  let activeProject;
13
14
  /** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
@@ -39,6 +40,7 @@ export function validateConfig(config) {
39
40
  validateGroupUse(config);
40
41
  validateEnvironmentUse(config);
41
42
  validateWebServerConfig(config);
43
+ validatePlansDirConfig(config);
42
44
  }
43
45
  /** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
44
46
  export async function loadRawConfig() {
@@ -33,6 +33,8 @@ export declare const DEFAULT_VIEWPORT: {
33
33
  export declare const DEFAULT_DOWNLOADS_DIR = "result/downloads";
34
34
  /** The files folder, relative to the project */
35
35
  export declare const DEFAULT_FILES_DIR = "files";
36
+ /** Where test plans are written, relative to the project */
37
+ export declare const DEFAULT_PLANS_DIR = "plans";
36
38
  /** How many times a failing test is run again */
37
39
  export declare const DEFAULT_RETRIES = 0;
38
40
  /** Which page log entries go in the report: those of failed tests */
@@ -30,6 +30,8 @@ export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
30
30
  export const DEFAULT_DOWNLOADS_DIR = "result/downloads";
31
31
  /** The files folder, relative to the project */
32
32
  export const DEFAULT_FILES_DIR = "files";
33
+ /** Where test plans are written, relative to the project */
34
+ export const DEFAULT_PLANS_DIR = "plans";
33
35
  /** How many times a failing test is run again */
34
36
  export const DEFAULT_RETRIES = 0;
35
37
  /** Which page log entries go in the report: those of failed tests */
@@ -0,0 +1,3 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ /** Checks the `plansDir` key of a config: a folder inside the project. Throws an Error that names the key */
3
+ export declare function validatePlansDirConfig(config: SamuraiTestConfig): void;
@@ -0,0 +1,11 @@
1
+ import path from "node:path";
2
+ /** Checks the `plansDir` key of a config: a folder inside the project. Throws an Error that names the key */
3
+ export function validatePlansDirConfig(config) {
4
+ const dir = config.plansDir;
5
+ if (dir === undefined)
6
+ return;
7
+ if (typeof dir !== "string" || dir.trim() === "")
8
+ throw new Error(`plansDir must be a folder name relative to the project, got ${JSON.stringify(dir)}`);
9
+ if (path.isAbsolute(dir) || path.normalize(dir).split(path.sep)[0] === "..")
10
+ throw new Error(`plansDir must be a folder inside the project, got "${dir}"`);
11
+ }
@@ -57,6 +57,7 @@ export interface ResolvedConfig {
57
57
  } | null;
58
58
  downloadsDir: string;
59
59
  filesDir: string;
60
+ plansDir: string;
60
61
  logs: {
61
62
  capture: "off" | "failures" | "all";
62
63
  failOnPageError: boolean;
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { browsersFor } from "./browsers-for.js";
3
3
  import { importConfigFile, projectConfig, validateConfig } from "./config.js";
4
- import { DEFAULT_COLLECT_BODIES, DEFAULT_COMMAND_TIMEOUT, DEFAULT_DOWNLOADS_DIR, DEFAULT_FAIL_ON_PAGE_ERROR, DEFAULT_FILES_DIR, DEFAULT_IDLE_TIME, DEFAULT_LOGS_CAPTURE, DEFAULT_MAX_BODY_SIZE, DEFAULT_NAVIGATION_TIMEOUT, DEFAULT_RETRIES, DEFAULT_ROUTE_TIMEOUT, DEFAULT_SRC_DIR, DEFAULT_TRACK_NETWORK, DEFAULT_VIEWPORT, DEFAULT_WAIT_UNTIL, } from "./defaults.js";
4
+ import { DEFAULT_COLLECT_BODIES, DEFAULT_COMMAND_TIMEOUT, DEFAULT_DOWNLOADS_DIR, DEFAULT_FAIL_ON_PAGE_ERROR, DEFAULT_FILES_DIR, DEFAULT_PLANS_DIR, DEFAULT_IDLE_TIME, DEFAULT_LOGS_CAPTURE, DEFAULT_MAX_BODY_SIZE, DEFAULT_NAVIGATION_TIMEOUT, DEFAULT_RETRIES, DEFAULT_ROUTE_TIMEOUT, DEFAULT_SRC_DIR, DEFAULT_TRACK_NETWORK, DEFAULT_VIEWPORT, DEFAULT_WAIT_UNTIL, } from "./defaults.js";
5
5
  import { forbidsOnly } from "./forbid-only.js";
6
6
  import { IMPLICIT_ENVIRONMENT, resolveRunSettings, UnknownEnvironmentError, } from "./run-settings.js";
7
7
  import { resolveWorkers } from "./workers-option.js";
@@ -72,6 +72,7 @@ export function resolveConfig(config, options) {
72
72
  viewport: viewport === null ? null : { ...(viewport ?? DEFAULT_VIEWPORT) },
73
73
  downloadsDir: config.downloadsDir ?? DEFAULT_DOWNLOADS_DIR,
74
74
  filesDir: config.filesDir ?? DEFAULT_FILES_DIR,
75
+ plansDir: config.plansDir ?? DEFAULT_PLANS_DIR,
75
76
  logs: {
76
77
  capture: config.logs?.capture ?? DEFAULT_LOGS_CAPTURE,
77
78
  failOnPageError: config.logs?.failOnPageError ?? DEFAULT_FAIL_ON_PAGE_ERROR,
@@ -34,6 +34,8 @@ export declare class SpecWriter {
34
34
  private readonly testIndex;
35
35
  readonly events: RecorderEvent[];
36
36
  constructor(source: string, file: string, testIndex: number);
37
+ /** Changes the source with `change` and writes the file when it differs; returns the new content */
38
+ rewrite(change: (source: string) => string): string;
37
39
  /** Applies the event, writes the file and returns the file's new content */
38
40
  write(event: RecorderEvent): string;
39
41
  }
@@ -74,6 +74,15 @@ export class SpecWriter {
74
74
  this.testIndex = testIndex;
75
75
  this.events = [];
76
76
  }
77
+ /** Changes the source with `change` and writes the file when it differs; returns the new content */
78
+ rewrite(change) {
79
+ const next = change(this.source);
80
+ if (next !== this.source) {
81
+ this.source = next;
82
+ writeFileSync(this.file, this.source);
83
+ }
84
+ return this.source;
85
+ }
77
86
  /** Applies the event, writes the file and returns the file's new content */
78
87
  write(event) {
79
88
  this.source = applyRecorderEvent(this.source, this.testIndex, event);
@@ -7,7 +7,7 @@ import { validateUseOptions } from "../config/use-options.js";
7
7
  import { currentStepLog, currentTestInfo, infoFor, skipFromCondition, } from "./test-info.js";
8
8
  import { runStep } from "./steps.js";
9
9
  import { markTests } from "./test-annotations.js";
10
- import { validateTestIO } from "./test-io.js";
10
+ import { validateTestAnnotations, validateTestIO } from "./test-io.js";
11
11
  import { addHook, addUse, currentSuite, enterSuite, leaveSuite, modifierOf, resetSuites, } from "./suite.js";
12
12
  import { configureSuite } from "./serial.js";
13
13
  const registered = [];
@@ -26,7 +26,7 @@ function openDescribe(file, title, fn, modifier, reason, mode) {
26
26
  }
27
27
  }
28
28
  function add({ file, line, column }, title, fn, modifier, extras = {}) {
29
- const { reason, inputs, outputs, extension, expectedFail } = extras;
29
+ const { reason, inputs, outputs, annotations, extension, expectedFail } = extras;
30
30
  const suite = currentSuite(file);
31
31
  const titlePath = [...titlesOf(suite), title];
32
32
  const name = titlePath.join(" > ");
@@ -40,6 +40,7 @@ function add({ file, line, column }, title, fn, modifier, extras = {}) {
40
40
  ...modifierOf({ modifier, ...(reason !== undefined && { reason }) }, suite),
41
41
  ...(inputs && { inputs }),
42
42
  ...(outputs && { outputs }),
43
+ ...(annotations && { annotations }),
43
44
  ...(extension && { extension }),
44
45
  ...(expectedFail && { expectedFail }),
45
46
  });
@@ -65,6 +66,9 @@ function bodyAndOptions(call, title, second, third) {
65
66
  fn: fn,
66
67
  ...(reason !== undefined && { reason }),
67
68
  ...(options && call.startsWith("test") && validateTestIO(call, options)),
69
+ ...(options &&
70
+ call.startsWith("test") &&
71
+ validateTestAnnotations(call, options)),
68
72
  };
69
73
  }
70
74
  /** `test()` and `test.only()`: a reason only makes sense with skip and fixme */
@@ -19,6 +19,7 @@ export class TestInfoImpl {
19
19
  const { test } = from;
20
20
  this.title = test.titlePath[test.titlePath.length - 1] ?? test.name;
21
21
  this.titlePath = [...test.titlePath];
22
+ this.annotations = (test.annotations ?? []).map((a) => ({ ...a }));
22
23
  this.file = test.file;
23
24
  if (test.line !== undefined)
24
25
  this.line = test.line;
@@ -1,4 +1,4 @@
1
- import type { TestError, TestOptions, TestOutputs } from "../types/test.js";
1
+ import type { TestAnnotation, TestError, TestOptions, TestOutputs } from "../types/test.js";
2
2
  import type { OutputSink } from "../wait/output-sink.js";
3
3
  /**
4
4
  * Checks the `inputs` and `outputs` of a `test()` options object and returns them, leaving out an empty or absent
@@ -8,6 +8,10 @@ export declare function validateTestIO(call: string, options: TestOptions): {
8
8
  inputs?: Record<string, string>;
9
9
  outputs?: string[];
10
10
  };
11
+ /** Checks the `annotations` of a `test()` options object and returns them, or nothing when absent or empty */
12
+ export declare function validateTestAnnotations(call: string, options: TestOptions): {
13
+ annotations?: TestAnnotation[];
14
+ };
11
15
  /**
12
16
  * The `inputs` fixture: each declared input with the value the run supplies, else its default. Reading a
13
17
  * name the test did not declare throws.
@@ -45,6 +45,27 @@ export function validateTestIO(call, options) {
45
45
  }
46
46
  return result;
47
47
  }
48
+ /** Checks the `annotations` of a `test()` options object and returns them, or nothing when absent or empty */
49
+ export function validateTestAnnotations(call, options) {
50
+ const { annotations } = options;
51
+ if (annotations === undefined)
52
+ return {};
53
+ if (!Array.isArray(annotations))
54
+ throw new Error(`${call}(): annotations must be an array`);
55
+ for (const note of annotations) {
56
+ const entry = note;
57
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
58
+ throw new Error(`${call}(): each annotation must be an object with a type`);
59
+ if (typeof entry.type !== "string" || entry.type === "")
60
+ throw new Error(`${call}(): an annotation needs a non-empty type`);
61
+ if (entry.description !== undefined &&
62
+ typeof entry.description !== "string")
63
+ throw new Error(`${call}(): the description of annotation "${entry.type}" must be a string`);
64
+ }
65
+ return annotations.length > 0
66
+ ? { annotations: annotations.map((a) => ({ ...a })) }
67
+ : {};
68
+ }
48
69
  /**
49
70
  * The `inputs` fixture: each declared input with the value the run supplies, else its default. Reading a
50
71
  * name the test did not declare throws.
@@ -1,3 +1,4 @@
1
+ import type { TestAnnotation } from "../types/test.js";
1
2
  import type { Step, TestInput } from "./model.js";
2
3
  export type StepEdit = {
3
4
  op: "replace";
@@ -34,7 +35,7 @@ export type StepEdit = {
34
35
  test: number;
35
36
  }
36
37
  /**
37
- * Sets the test's `inputs` and `outputs` in its options object: a given list replaces that list, one left out
38
+ * Sets the test's `inputs`, `outputs` and `annotations` in its options object: a given list replaces that list, one left out
38
39
  * stays. When both end up empty the options object goes, unless it holds a `reason`
39
40
  */
40
41
  | {
@@ -42,6 +43,7 @@ export type StepEdit = {
42
43
  test: number;
43
44
  inputs?: TestInput[];
44
45
  outputs?: string[];
46
+ annotations?: TestAnnotation[];
45
47
  };
46
48
  /**
47
49
  * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
@@ -158,7 +158,7 @@ export function applyEdit(source, edit) {
158
158
  return withStep(source, parsed, edit.step, (text, code) => insertCode(text, parsed, edit.index, code));
159
159
  case "declare": {
160
160
  let next = source;
161
- for (const key of ["inputs", "outputs"]) {
161
+ for (const key of ["inputs", "outputs", "annotations"]) {
162
162
  const list = edit[key];
163
163
  if (list)
164
164
  next = declareList(next, pickTest(next, edit.test), key, list);
@@ -1,4 +1,4 @@
1
- import type { Modifier } from "../types/test.js";
1
+ import type { Modifier, TestAnnotation } from "../types/test.js";
2
2
  /**
3
3
  * One call of a locator expression, as written in a spec: `page.getByRole("button", { name: "Save" })`.
4
4
  * A RegExp text or name is tested as written, so it never comes with `exact`, `match` or `ignoreCase`.
@@ -264,6 +264,8 @@ export interface ParsedTest {
264
264
  inputs?: TestInput[];
265
265
  /** The `outputs` of the test's options object; undefined without the key */
266
266
  outputs?: string[];
267
+ /** The `annotations` of the test's options object; undefined without the key or with an empty list */
268
+ annotations?: TestAnnotation[];
267
269
  /** The range of the test's options object (`test(title, { … }, fn)`), when it has one */
268
270
  options?: {
269
271
  start: number;
@@ -137,7 +137,7 @@ export function parseSpecFile(source) {
137
137
  return;
138
138
  }
139
139
  const { callback, options = {}, range } = args;
140
- const { reason, inputs, outputs } = options;
140
+ const { reason, inputs, outputs, annotations } = options;
141
141
  const body = callbackBody(callback);
142
142
  if (!body) {
143
143
  unparsed.push(unreadable(node, kind, "not-inline"));
@@ -161,6 +161,7 @@ export function parseSpecFile(source) {
161
161
  ...effectiveMark(mark, describes),
162
162
  ...(inputs && { inputs }),
163
163
  ...(outputs && { outputs }),
164
+ ...(annotations && { annotations }),
164
165
  ...(range && { options: range }),
165
166
  titleEnd: node.arguments[0].end,
166
167
  });
@@ -1,13 +1,15 @@
1
1
  import ts from "typescript";
2
- import type { Modifier } from "../types/test.js";
2
+ import type { Modifier, TestAnnotation } from "../types/test.js";
3
3
  import type { ParsedTest, TestInput } from "./model.js";
4
4
  /** What a `test()` or `describe()` options object says, as far as the codec reads it */
5
5
  export interface TestOptionsRead {
6
6
  reason?: string;
7
7
  inputs?: TestInput[];
8
8
  outputs?: string[];
9
+ annotations?: TestAnnotation[];
9
10
  }
10
- type ListKey = "inputs" | "outputs";
11
+ type ListKey = "inputs" | "outputs" | "annotations";
12
+ type ListValue = TestInput[] | string[] | TestAnnotation[];
11
13
  /**
12
14
  * The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
13
15
  * `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
@@ -21,5 +23,5 @@ export declare function readTestOptions(node: ts.Expression, kind: "test" | "des
21
23
  * the object, its layout and a `reason`, stays as it is; an unchanged list leaves the source as it is. Throws for
22
24
  * names the runtime rejects, before writing
23
25
  */
24
- export declare function declareList(source: string, parsed: ParsedTest, key: ListKey, list: TestInput[] | string[]): string;
26
+ export declare function declareList(source: string, parsed: ParsedTest, key: ListKey, list: ListValue): string;
25
27
  export {};
@@ -33,6 +33,35 @@ function readOutputs(node) {
33
33
  ? names
34
34
  : undefined;
35
35
  }
36
+ /** `[{ type: "plan", description: "…" }, …]`; undefined for anything but string literals under those two keys */
37
+ function readAnnotations(node) {
38
+ if (!ts.isArrayLiteralExpression(node))
39
+ return undefined;
40
+ const notes = [];
41
+ for (const element of node.elements) {
42
+ if (!ts.isObjectLiteralExpression(element))
43
+ return undefined;
44
+ const note = {};
45
+ for (const property of element.properties) {
46
+ if (!ts.isPropertyAssignment(property))
47
+ return undefined;
48
+ const key = keyOf(property.name);
49
+ const value = stringOf(property.initializer);
50
+ if (value === undefined)
51
+ return undefined;
52
+ if (key === "type")
53
+ note.type = value;
54
+ else if (key === "description")
55
+ note.description = value;
56
+ else
57
+ return undefined;
58
+ }
59
+ if (note.type === undefined || note.type === "")
60
+ return undefined;
61
+ notes.push(note);
62
+ }
63
+ return notes;
64
+ }
36
65
  /**
37
66
  * The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
38
67
  * `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
@@ -67,13 +96,27 @@ export function readTestOptions(node, kind, modifier) {
67
96
  if (outputs.length > 0)
68
97
  read.outputs = outputs;
69
98
  }
99
+ else if (kind === "test" && key === "annotations") {
100
+ const annotations = readAnnotations(value);
101
+ if (!annotations)
102
+ return undefined;
103
+ if (annotations.length > 0)
104
+ read.annotations = annotations;
105
+ }
70
106
  else
71
107
  return undefined;
72
108
  }
73
109
  return read;
74
110
  }
75
111
  const str = (value) => JSON.stringify(value);
112
+ function annotationToSource(note) {
113
+ return note.description === undefined
114
+ ? `{ type: ${str(note.type)} }`
115
+ : `{ type: ${str(note.type)}, description: ${str(note.description)} }`;
116
+ }
76
117
  function listToSource(key, list) {
118
+ if (key === "annotations")
119
+ return `[${list.map(annotationToSource).join(", ")}]`;
77
120
  if (key === "outputs")
78
121
  return `[${list.map(str).join(", ")}]`;
79
122
  const entries = list.map(({ name, default: value }) => `${IDENTIFIER.test(name) ? name : str(name)}: ${str(value)}`);
@@ -82,6 +125,12 @@ function listToSource(key, list) {
82
125
  const sameList = (a, b) => JSON.stringify(a) === JSON.stringify(b);
83
126
  /** Throws for a list the runtime's `test()` would reject: a name that isn't valid, a name twice, a default that isn't text */
84
127
  function checkList(key, list) {
128
+ if (key === "annotations") {
129
+ for (const note of list)
130
+ if (typeof note.type !== "string" || note.type === "")
131
+ throw new Error(`applyEdit(): an annotation needs a non-empty type`);
132
+ return;
133
+ }
85
134
  const kind = key === "inputs" ? "input" : "output";
86
135
  const seen = new Set();
87
136
  for (const entry of list) {
@@ -204,6 +204,12 @@ export interface SamuraiTestConfig {
204
204
  * @default "files"
205
205
  */
206
206
  filesDir?: string;
207
+ /**
208
+ * Folder, relative to the project folder, where test plans are kept; `samurai plan "<goal>"` without `--out`
209
+ * writes `<plansDir>/<slug-of-goal>.plan.md`
210
+ * @default "plans"
211
+ */
212
+ plansDir?: string;
207
213
  /**
208
214
  * The AI agent (`samurai generate`). `model` is a `provider:name` pair, such as `anthropic:claude-sonnet-4-5`;
209
215
  * never a key (keys come from the environment). The `--model` flag and `SAMURAI_AI_MODEL` win over it
@@ -170,6 +170,8 @@ export interface TestOptions extends TestDetails {
170
170
  inputs?: Record<string, string>;
171
171
  /** Names of the text values the test saves for later tests to use */
172
172
  outputs?: string[];
173
+ /** Notes on the test, such as `{ type: "plan", description: "plans/pay.plan.md" }`; the report lists them first */
174
+ annotations?: TestAnnotation[];
173
175
  }
174
176
 
175
177
  /** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
@@ -225,6 +227,8 @@ export interface RegisteredTestCase extends TestCaseBase {
225
227
  inputs?: Record<string, string>;
226
228
  /** The names of the outputs the test declared */
227
229
  outputs?: string[];
230
+ /** The annotations the test declared; every run of the test starts with them */
231
+ annotations?: TestAnnotation[];
228
232
  /** Registered with `test.fail("title", fn)`: expected to fail; `description` is the `{ reason }` given */
229
233
  expectedFail?: { description?: string };
230
234
  /** The custom fixtures of the `test` it was written with (`test.extend()`); absent for the plain `test` */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.22.5",
3
+ "version": "0.22.7",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {