@itmetsam/samurai-framework 0.22.5 → 0.22.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/generate-kind.js +8 -3
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.js +2 -0
- package/dist/agent/plan/front-matter.d.ts +9 -0
- package/dist/agent/plan/front-matter.js +18 -0
- package/dist/agent/plan/plan-file.d.ts +10 -2
- package/dist/agent/plan/plan-file.js +33 -5
- package/dist/agent/plan/plan-path.d.ts +8 -0
- package/dist/agent/plan/plan-path.js +46 -0
- package/dist/agent/plan/prompt.js +1 -1
- package/dist/agent/plan/questions.d.ts +5 -0
- package/dist/agent/plan/questions.js +22 -0
- package/dist/agent/plan/read-plan.d.ts +1 -1
- package/dist/agent/plan/read-plan.js +3 -2
- package/dist/agent/plan/recorder.d.ts +3 -3
- package/dist/agent/plan/recorder.js +6 -7
- package/dist/agent/plan/tools.js +1 -1
- package/dist/agent/plan-link.d.ts +8 -0
- package/dist/agent/plan-link.js +25 -0
- package/dist/agent/recording.d.ts +2 -0
- package/dist/agent/recording.js +4 -0
- package/dist/agent/types.d.ts +5 -0
- package/dist/cli/generate.js +1 -1
- package/dist/cli/plan.d.ts +1 -1
- package/dist/cli/plan.js +8 -5
- package/dist/cli/usage.d.ts +1 -1
- package/dist/cli/usage.js +1 -1
- package/dist/config/config.js +2 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +2 -0
- package/dist/config/plans-dir.d.ts +3 -0
- package/dist/config/plans-dir.js +11 -0
- package/dist/config/resolved-config.d.ts +1 -0
- package/dist/config/resolved-config.js +2 -1
- package/dist/recorder/spec-source.d.ts +2 -0
- package/dist/recorder/spec-source.js +9 -0
- package/dist/runner/registry.js +6 -2
- package/dist/runner/test-info.js +1 -0
- package/dist/runner/test-io.d.ts +5 -1
- package/dist/runner/test-io.js +21 -0
- package/dist/steps/edit.d.ts +3 -1
- package/dist/steps/edit.js +1 -1
- package/dist/steps/model.d.ts +3 -1
- package/dist/steps/parse.js +2 -1
- package/dist/steps/test-options.d.ts +5 -3
- package/dist/steps/test-options.js +49 -0
- package/dist/types/config.d.ts +6 -0
- package/dist/types/test.d.ts +4 -0
- 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) =>
|
|
31
|
-
|
|
32
|
-
|
|
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,
|
package/dist/agent/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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 { parsePlan, type ParsedPlan } from "./plan/plan-file.js";
|
|
5
|
+
export { defaultPlanFile } from "./plan/plan-path.js";
|
|
4
6
|
export { readPlan } from "./plan/read-plan.js";
|
|
5
7
|
export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
|
|
6
8
|
export type { AgentEndReason, AgentEvent, AgentOptions, AgentResult, AgentSession, PlanOptions, PlanResult, PlanSession, } from "./types.js";
|
package/dist/agent/index.js
CHANGED
|
@@ -2,6 +2,8 @@ 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 { parsePlan } from "./plan/plan-file.js";
|
|
6
|
+
export { defaultPlanFile } from "./plan/plan-path.js";
|
|
5
7
|
export { readPlan } from "./plan/read-plan.js";
|
|
6
8
|
export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
|
|
7
9
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -12,7 +12,13 @@ interface ParsedPlan {
|
|
|
12
12
|
steps: PlanStep[];
|
|
13
13
|
seen: string[];
|
|
14
14
|
notDone: string[];
|
|
15
|
+
/** Every open question, answered or not */
|
|
15
16
|
questions: string[];
|
|
17
|
+
/** The questions that have an `Answer:` line, with the answer; a question with an empty answer is not here */
|
|
18
|
+
answers: {
|
|
19
|
+
question: string;
|
|
20
|
+
answer: string;
|
|
21
|
+
}[];
|
|
16
22
|
}
|
|
17
23
|
/**
|
|
18
24
|
* A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
|
|
@@ -30,6 +36,8 @@ declare class PlanFile {
|
|
|
30
36
|
/** The title, which is also what the run reports as the name of the test */
|
|
31
37
|
get title(): string;
|
|
32
38
|
get count(): number;
|
|
39
|
+
/** The front matter the file has now (the app may change it while the plan is written); kept as it is */
|
|
40
|
+
private frontMatter;
|
|
33
41
|
private render;
|
|
34
42
|
/** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
|
|
35
43
|
private save;
|
|
@@ -44,7 +52,7 @@ declare class PlanFile {
|
|
|
44
52
|
/** Writes the first version of the file */
|
|
45
53
|
create(): void;
|
|
46
54
|
}
|
|
47
|
-
export type { PlanFile };
|
|
55
|
+
export type { PlanFile, ParsedPlan };
|
|
48
56
|
/** Starts a plan file: the headings are written at once. An existing file is an error unless `force` is set */
|
|
49
57
|
export declare function createPlan(input: {
|
|
50
58
|
goal: string;
|
|
@@ -53,7 +61,7 @@ export declare function createPlan(input: {
|
|
|
53
61
|
force?: boolean;
|
|
54
62
|
}): PlanFile;
|
|
55
63
|
/**
|
|
56
|
-
* Reads a plan, the one `createPlan` wrote or one a person edited
|
|
64
|
+
* 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
65
|
* and `None.` placeholders are fine. Only the `## Steps` heading is needed.
|
|
58
66
|
*/
|
|
59
67
|
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.";
|
|
@@ -37,9 +38,19 @@ class PlanFile {
|
|
|
37
38
|
get count() {
|
|
38
39
|
return this.steps.length;
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
/** The front matter the file has now (the app may change it while the plan is written); kept as it is */
|
|
42
|
+
frontMatter() {
|
|
43
|
+
try {
|
|
44
|
+
return splitFrontMatter(readFileSync(this.file, "utf8")).front;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
render(front) {
|
|
41
51
|
const steps = this.steps.map((step, i) => `${i + 1}. ${step.text}${step.expect !== undefined ? `\n Expect: ${step.expect}` : ""}`);
|
|
42
52
|
return [
|
|
53
|
+
...(front === "" ? [] : [front.replace(/\n$/, "")]),
|
|
43
54
|
`# Plan: ${this.title}`,
|
|
44
55
|
"",
|
|
45
56
|
`Goal: ${this.goal}`,
|
|
@@ -65,7 +76,7 @@ class PlanFile {
|
|
|
65
76
|
}
|
|
66
77
|
/** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
|
|
67
78
|
save() {
|
|
68
|
-
const text = this.render();
|
|
79
|
+
const text = this.render(this.frontMatter());
|
|
69
80
|
if (Buffer.byteLength(text) > MAX_PLAN_BYTES)
|
|
70
81
|
throw new Error(`the plan file is full (${MAX_PLAN_BYTES / 1024} KB); finish with what it holds, or shorten the next entry`);
|
|
71
82
|
mkdirSync(path.dirname(this.file), { recursive: true });
|
|
@@ -129,6 +140,7 @@ const HEADING = /^##\s+(.*?)\s*$/;
|
|
|
129
140
|
const STEP = /^\s*\d+\.\s+(.*)$/;
|
|
130
141
|
const BULLET = /^\s*[-*]\s+(.*)$/;
|
|
131
142
|
const EXPECT = /^\s+Expect:\s*(.*)$/i;
|
|
143
|
+
const ANSWER = /^\s+Answer:\s*(.*)$/i;
|
|
132
144
|
const FIELD = /^(Goal|Start):\s*(.*)$/i;
|
|
133
145
|
function sectionOf(heading) {
|
|
134
146
|
const name = heading.toLowerCase();
|
|
@@ -143,7 +155,7 @@ function sectionOf(heading) {
|
|
|
143
155
|
return "other";
|
|
144
156
|
}
|
|
145
157
|
/**
|
|
146
|
-
* Reads a plan, the one `createPlan` wrote or one a person edited
|
|
158
|
+
* 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
159
|
* and `None.` placeholders are fine. Only the `## Steps` heading is needed.
|
|
148
160
|
*/
|
|
149
161
|
export function parsePlan(text) {
|
|
@@ -155,10 +167,18 @@ export function parsePlan(text) {
|
|
|
155
167
|
seen: [],
|
|
156
168
|
notDone: [],
|
|
157
169
|
questions: [],
|
|
170
|
+
answers: [],
|
|
158
171
|
};
|
|
159
172
|
let section;
|
|
160
173
|
let hasSteps = false;
|
|
161
|
-
|
|
174
|
+
let body;
|
|
175
|
+
try {
|
|
176
|
+
body = splitFrontMatter(text).body;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
throw new Error(`parsePlan(): ${error.message}`);
|
|
180
|
+
}
|
|
181
|
+
for (const line of body.split(/\r?\n/)) {
|
|
162
182
|
const heading = HEADING.exec(line);
|
|
163
183
|
if (heading) {
|
|
164
184
|
section = sectionOf(heading[1]);
|
|
@@ -191,6 +211,14 @@ export function parsePlan(text) {
|
|
|
191
211
|
last.text = `${last.text} ${line.trim()}`;
|
|
192
212
|
continue;
|
|
193
213
|
}
|
|
214
|
+
const answer = section === "questions" ? ANSWER.exec(line) : null;
|
|
215
|
+
const asked = plan.questions[plan.questions.length - 1];
|
|
216
|
+
if (answer) {
|
|
217
|
+
const text = answer[1].trim();
|
|
218
|
+
if (asked !== undefined && text !== "")
|
|
219
|
+
plan.answers.push({ question: asked, answer: text });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
194
222
|
const item = BULLET.exec(line)?.[1]?.trim();
|
|
195
223
|
if (item !== undefined && item !== NONE)
|
|
196
224
|
plan[section].push(item);
|
|
@@ -0,0 +1,8 @@
|
|
|
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>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } 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
|
+
}
|
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
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 (
|
|
32
|
-
throw new Error(
|
|
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
|
}
|
package/dist/agent/plan/tools.js
CHANGED
|
@@ -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
|
|
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 */
|
package/dist/agent/recording.js
CHANGED
|
@@ -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();
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -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 {
|
package/dist/cli/generate.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/plan.d.ts
CHANGED
|
@@ -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>"`:
|
|
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>"`:
|
|
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
|
|
19
|
+
out,
|
|
17
20
|
...(values.force === true && { force: true }),
|
|
18
21
|
});
|
|
19
22
|
return followRun(session, io, values.json === true, (result) => summarise(io, result));
|
package/dist/cli/usage.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/dist/config/config.js
CHANGED
|
@@ -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 */
|
package/dist/config/defaults.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -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);
|
package/dist/runner/registry.js
CHANGED
|
@@ -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 */
|
package/dist/runner/test-info.js
CHANGED
|
@@ -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;
|
package/dist/runner/test-io.d.ts
CHANGED
|
@@ -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.
|
package/dist/runner/test-io.js
CHANGED
|
@@ -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.
|
package/dist/steps/edit.d.ts
CHANGED
|
@@ -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 `
|
|
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
|
package/dist/steps/edit.js
CHANGED
|
@@ -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);
|
package/dist/steps/model.d.ts
CHANGED
|
@@ -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;
|
package/dist/steps/parse.js
CHANGED
|
@@ -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:
|
|
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) {
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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
|
package/dist/types/test.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.22.6",
|
|
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": {
|