@itmetsam/samurai-framework 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/browser-finder.d.ts +1 -0
  5. package/dist/browser/browser-finder.js +1 -0
  6. package/dist/browser/browser.d.ts +8 -0
  7. package/dist/browser/browser.js +33 -12
  8. package/dist/browser/warm-up.d.ts +14 -0
  9. package/dist/browser/warm-up.js +37 -0
  10. package/dist/browsers/cache-dir.d.ts +2 -0
  11. package/dist/browsers/cache-dir.js +15 -0
  12. package/dist/browsers/downloader.d.ts +23 -0
  13. package/dist/browsers/downloader.js +52 -0
  14. package/dist/browsers/index.d.ts +6 -0
  15. package/dist/browsers/index.js +5 -0
  16. package/dist/browsers/overrides.d.ts +17 -0
  17. package/dist/browsers/overrides.js +15 -0
  18. package/dist/browsers/pins.d.ts +16 -0
  19. package/dist/browsers/pins.js +14 -0
  20. package/dist/browsers/resolve.d.ts +26 -0
  21. package/dist/browsers/resolve.js +56 -0
  22. package/dist/browsers/store.d.ts +52 -0
  23. package/dist/browsers/store.js +170 -0
  24. package/dist/cli/args.d.ts +2 -0
  25. package/dist/cli/args.js +2 -0
  26. package/dist/cli/browsers.d.ts +6 -0
  27. package/dist/cli/browsers.js +119 -0
  28. package/dist/cli/flow-reporter.d.ts +5 -2
  29. package/dist/cli/flow-reporter.js +3 -2
  30. package/dist/cli/flow.d.ts +1 -1
  31. package/dist/cli/flow.js +4 -2
  32. package/dist/cli/main.d.ts +1 -1
  33. package/dist/cli/main.js +11 -1
  34. package/dist/config/config.d.ts +5 -0
  35. package/dist/config/config.js +13 -0
  36. package/dist/flows/core/steps.js +14 -3
  37. package/dist/flows/run/index.d.ts +1 -0
  38. package/dist/flows/run/index.js +1 -0
  39. package/dist/flows/run/project.js +18 -21
  40. package/dist/flows/steps/api.js +28 -5
  41. package/dist/flows/steps/catalog.d.ts +10 -4
  42. package/dist/flows/steps/catalog.js +23 -16
  43. package/dist/flows/steps/define.d.ts +2 -0
  44. package/dist/flows/steps/define.js +15 -4
  45. package/dist/flows/steps/handler.js +16 -4
  46. package/dist/flows/steps/index.d.ts +3 -2
  47. package/dist/flows/steps/index.js +2 -1
  48. package/dist/flows/steps/set-variable.js +14 -3
  49. package/dist/flows/steps/set.d.ts +26 -0
  50. package/dist/flows/steps/set.js +43 -0
  51. package/dist/flows/steps/wait.js +8 -1
  52. package/dist/recorder/session.js +4 -0
  53. package/dist/runner/groups.d.ts +5 -0
  54. package/dist/runner/groups.js +13 -0
  55. package/dist/runner/test-runner.js +9 -3
  56. package/dist/transport/bidi-connection.d.ts +2 -0
  57. package/dist/transport/bidi-connection.js +1 -1
  58. package/dist/types/config.d.ts +12 -0
  59. package/package.json +6 -1
@@ -1,5 +1,8 @@
1
1
  import { defineStep } from "./define.js";
2
- const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({ label: m, value: m }));
2
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({
3
+ label: m,
4
+ value: m,
5
+ }));
3
6
  const isJson = (text) => {
4
7
  try {
5
8
  JSON.parse(text);
@@ -16,9 +19,27 @@ export const api = defineStep({
16
19
  icon: "globe",
17
20
  section: "integrations",
18
21
  fields: [
19
- { path: "config.method", type: "select", label: "Method", options: METHODS, default: "GET" },
20
- { path: "config.url", type: "expression", label: "URL", placeholder: 'env.baseUrl + "/health"', default: "" },
21
- { path: "config.body", type: "code", label: "Body", placeholder: '{ "name": "test" }', default: "" },
22
+ {
23
+ path: "config.method",
24
+ type: "select",
25
+ label: "Method",
26
+ options: METHODS,
27
+ default: "GET",
28
+ },
29
+ {
30
+ path: "config.url",
31
+ type: "expression",
32
+ label: "URL",
33
+ placeholder: 'env.baseUrl + "/health"',
34
+ default: "",
35
+ },
36
+ {
37
+ path: "config.body",
38
+ type: "code",
39
+ label: "Body",
40
+ placeholder: '{ "name": "test" }',
41
+ default: "",
42
+ },
22
43
  ],
23
44
  summary: "{config.method} {config.url}",
24
45
  outputs: [
@@ -32,7 +53,9 @@ export const api = defineStep({
32
53
  signal,
33
54
  ...(body !== undefined && {
34
55
  body,
35
- headers: { "content-type": isJson(body) ? "application/json" : "text/plain" },
56
+ headers: {
57
+ "content-type": isJson(body) ? "application/json" : "text/plain",
58
+ },
36
59
  }),
37
60
  });
38
61
  return { outputs: { statusCode: res.status, body: await res.text() } };
@@ -1,16 +1,22 @@
1
1
  import type { AnyNodeDefinition, FlowFile, FlowProblem, StepInfo } from "../core/index.js";
2
2
  import type { Handler } from "../run/handlers/support.js";
3
3
  import { type StepDefinition } from "./define.js";
4
+ import type { SamuraiTestConfig } from "../../types/config.js";
4
5
  /** The built-in steps, in library order */
5
6
  export declare const STEPS: readonly StepDefinition<any>[];
6
7
  export declare const STEP_NODES: readonly AnyNodeDefinition[];
7
8
  export declare const STEP_HANDLERS: Readonly<Record<string, Handler>>;
8
9
  /** The steps as data, for the app */
9
10
  export declare const stepCatalog: (steps?: readonly StepDefinition<any>[]) => StepInfo[];
10
- /** The project's steps; the built-in ones for now (test-project steps come later) */
11
- export declare function listSteps(): Promise<StepInfo[]>;
12
- /** Only the steps' own `validate` problems: the app checks everything else itself */
11
+ /** The project's steps as data: the built-ins, then its config's. `projectDir` defaults to the working directory. */
12
+ export declare function listSteps(options?: {
13
+ projectDir?: string;
14
+ config?: SamuraiTestConfig;
15
+ }): Promise<StepInfo[]>;
16
+ /** Only the steps' own `validate` problems (the project's steps included): the app checks everything else itself */
13
17
  export declare function validateSteps(flow: FlowFile, options: {
14
18
  tests?: string[];
15
19
  groups?: string[];
16
- }): FlowProblem[];
20
+ projectDir?: string;
21
+ config?: SamuraiTestConfig;
22
+ }): Promise<FlowProblem[]>;
@@ -1,29 +1,36 @@
1
- import { api } from "./api.js";
2
- import { infoOf, stepNode } from "./define.js";
3
- import { stepHandler } from "./handler.js";
4
- import { setVariable } from "./set-variable.js";
5
- import { wait } from "./wait.js";
1
+ import { infoOf } from "./define.js";
2
+ import { BUILTIN_SET, BUILTIN_STEPS, projectSteps } from "./set.js";
6
3
  /** The built-in steps, in library order */
7
4
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- export const STEPS = [wait, setVariable, api];
9
- export const STEP_NODES = STEPS.map(stepNode);
10
- export const STEP_HANDLERS = Object.fromEntries(STEPS.map((s) => [s.kind, stepHandler(s)]));
5
+ export const STEPS = BUILTIN_STEPS;
6
+ export const STEP_NODES = BUILTIN_SET.nodes;
7
+ export const STEP_HANDLERS = BUILTIN_SET.handlers;
11
8
  /** The steps as data, for the app */
12
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
10
  export const stepCatalog = (steps = STEPS) => steps.map(infoOf);
14
- /** The project's steps; the built-in ones for now (test-project steps come later) */
15
- export async function listSteps() {
16
- return stepCatalog();
11
+ /** The project's steps as data: the built-ins, then its config's. `projectDir` defaults to the working directory. */
12
+ export async function listSteps(options = {}) {
13
+ return [...(await projectSteps(options)).catalog];
17
14
  }
18
- /** Only the steps' own `validate` problems: the app checks everything else itself */
19
- export function validateSteps(flow, options) {
15
+ /** Only the steps' own `validate` problems (the project's steps included): the app checks everything else itself */
16
+ export async function validateSteps(flow, options) {
17
+ const set = await projectSteps(options);
20
18
  const tests = options.tests ? new Set(options.tests) : undefined;
21
19
  const groups = options.groups ? new Set(options.groups) : undefined;
22
20
  return flow.nodes.flatMap((n) => {
23
- const step = STEPS.find((s) => s.kind === n.kind);
21
+ const step = set.steps.find((s) => s.kind === n.kind);
24
22
  if (!step?.validate)
25
23
  return [];
26
- const def = STEP_NODES.find((d) => d.kind === n.kind);
27
- return step.validate({ ...n, config: { ...Object.fromEntries(step.fields.map((f) => [f.path.slice(7), "default" in f ? f.default : undefined])), ...n.config } }, { label: def.label, tests, groups });
24
+ const def = set.nodes.find((d) => d.kind === n.kind);
25
+ return step.validate({
26
+ ...n,
27
+ config: {
28
+ ...Object.fromEntries(step.fields.map((f) => [
29
+ f.path.slice(7),
30
+ "default" in f ? f.default : undefined,
31
+ ])),
32
+ ...n.config,
33
+ },
34
+ }, { label: def.label, tests, groups });
28
35
  });
29
36
  }
@@ -38,6 +38,8 @@ export interface StepDefinition<C = Record<string, unknown>> extends StepInfo {
38
38
  /** What the step does in a run: return to pass, throw to fail */
39
39
  run: (input: StepRunInput<C>) => Promise<StepResult | void>;
40
40
  }
41
+ /** The checks every step passes: in `defineStep`, and again when a project lists it. Throws for a step that can't work. */
42
+ export declare function checkStep(def: StepDefinition<any>, where?: string): void;
41
43
  /** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
42
44
  export declare function defineStep<const F extends readonly StepField[]>(def: Omit<StepDefinition<RunConfigOf<F>>, "fields"> & {
43
45
  fields: F;
@@ -1,11 +1,14 @@
1
1
  import { CLOSED_KINDS, RESERVED_OUTPUTS, STEP_KIND, stepNodeOf, } from "../core/index.js";
2
- /** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
3
- export function defineStep(def) {
4
- const name = `Step "${def.kind}"`;
2
+ /** The checks every step passes: in `defineStep`, and again when a project lists it. Throws for a step that can't work. */
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ export function checkStep(def, where = "") {
5
+ const name = `Step "${def.kind}"${where}`;
5
6
  if (CLOSED_KINDS.includes(def.kind))
6
7
  throw new Error(`${name}: "${def.kind}" is a closed kind; steps can't replace it.`);
7
8
  if (!STEP_KIND.test(def.kind))
8
9
  throw new Error(`${name}: "${def.kind}" isn't a valid step kind. Use lowercase letters, digits and -.`);
10
+ if (!Array.isArray(def.fields))
11
+ throw new Error(`${name}: fields must be a list.`);
9
12
  for (const f of def.fields) {
10
13
  if (!f.path.startsWith("config."))
11
14
  throw new Error(`${name}: field "${f.path}" must be a config. path.`);
@@ -24,8 +27,16 @@ export function defineStep(def) {
24
27
  if (outputs.indexOf(o) !== outputs.lastIndexOf(o))
25
28
  throw new Error(`${name}: output "${o}" is declared twice.`);
26
29
  }
27
- if (def.writesVar !== undefined && !def.fields.some((f) => f.path === def.writesVar))
30
+ if (def.writesVar !== undefined &&
31
+ !def.fields.some((f) => f.path === def.writesVar))
28
32
  throw new Error(`${name}: writesVar "${def.writesVar}" isn't one of its fields.`);
33
+ if (typeof def.run !== "function")
34
+ throw new Error(`${name}: run must be a function.`);
35
+ }
36
+ /** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
37
+ export function defineStep(def) {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ checkStep(def);
29
40
  return def;
30
41
  }
31
42
  /** The step as data, without its functions: what the catalog holds */
@@ -27,7 +27,9 @@ export function stepHandler(step) {
27
27
  const stopped = new Promise((resolve) => {
28
28
  if (run.signal.aborted)
29
29
  resolve("stopped");
30
- run.signal.addEventListener("abort", () => resolve("stopped"), { once: true });
30
+ run.signal.addEventListener("abort", () => resolve("stopped"), {
31
+ once: true,
32
+ });
31
33
  });
32
34
  let result;
33
35
  try {
@@ -36,7 +38,11 @@ export function stepHandler(step) {
36
38
  config,
37
39
  env: { ...run.context.env },
38
40
  vars: { ...run.context.vars },
39
- node: { id: node.id, ...(node.key && { key: node.key }), ...(node.title && { title: node.title }) },
41
+ node: {
42
+ id: node.id,
43
+ ...(node.key && { key: node.key }),
44
+ ...(node.title && { title: node.title }),
45
+ },
40
46
  signal: run.signal,
41
47
  sleep: (ms) => run.sleep(ms, run.signal),
42
48
  log: (level, text) => run.log(node.id, level, text),
@@ -54,11 +60,17 @@ export function stepHandler(step) {
54
60
  const declared = new Set((step.outputs ?? []).map((o) => o.name));
55
61
  const extra = keysOf(result?.outputs).filter((k) => !declared.has(k));
56
62
  if (extra.length)
57
- return { status: "failed", error: `${step.label} returned outputs it doesn't declare: ${extra.join(", ")}.` };
63
+ return {
64
+ status: "failed",
65
+ error: `${step.label} returned outputs it doesn't declare: ${extra.join(", ")}.`,
66
+ };
58
67
  const allowed = step.writesVar ? getPath(typed, step.writesVar) : undefined;
59
68
  const wrongVars = keysOf(result?.vars).filter((k) => k !== allowed);
60
69
  if (wrongVars.length)
61
- return { status: "failed", error: `${step.label} wrote variables it doesn't declare: ${wrongVars.join(", ")}.` };
70
+ return {
71
+ status: "failed",
72
+ error: `${step.label} wrote variables it doesn't declare: ${wrongVars.join(", ")}.`,
73
+ };
62
74
  Object.assign(run.context.vars, result?.vars);
63
75
  return {
64
76
  status: "passed",
@@ -1,5 +1,6 @@
1
1
  /** Flow steps: define your own, and the built-in ones. Node only. */
2
- export { defineStep, stepNode, type RunConfigOf, type StepDefinition, type StepResult, type StepRunInput, } from "./define.js";
2
+ export { checkStep, defineStep, stepNode, type RunConfigOf, type StepDefinition, type StepResult, type StepRunInput, } from "./define.js";
3
3
  export { stepHandler } from "./handler.js";
4
+ export { BUILTIN_SET, BUILTIN_STEPS, projectSteps, stepSet, type StepSet, } from "./set.js";
4
5
  export { STEPS, STEP_HANDLERS, STEP_NODES, listSteps, stepCatalog, validateSteps, } from "./catalog.js";
5
- export type { StepField, StepInfo, StepOutput, StepSection } from "../core/index.js";
6
+ export type { StepField, StepInfo, StepOutput, StepSection, } from "../core/index.js";
@@ -1,4 +1,5 @@
1
1
  /** Flow steps: define your own, and the built-in ones. Node only. */
2
- export { defineStep, stepNode, } from "./define.js";
2
+ export { checkStep, defineStep, stepNode, } from "./define.js";
3
3
  export { stepHandler } from "./handler.js";
4
+ export { BUILTIN_SET, BUILTIN_STEPS, projectSteps, stepSet, } from "./set.js";
4
5
  export { STEPS, STEP_HANDLERS, STEP_NODES, listSteps, stepCatalog, validateSteps, } from "./catalog.js";
@@ -6,11 +6,22 @@ export const setVariable = defineStep({
6
6
  section: "control",
7
7
  fields: [
8
8
  {
9
- path: "config.name", type: "text", label: "Name", placeholder: "orderId", default: "",
10
- required: true, pattern: "^[a-z][a-z0-9_]*$",
9
+ path: "config.name",
10
+ type: "text",
11
+ label: "Name",
12
+ placeholder: "orderId",
13
+ default: "",
14
+ required: true,
15
+ pattern: "^[a-z][a-z0-9_]*$",
11
16
  patternMessage: "use lowercase letters, digits and _ for the name.",
12
17
  },
13
- { path: "config.value", type: "expression", label: "Value", placeholder: '"order-" + run.environment', default: "" },
18
+ {
19
+ path: "config.value",
20
+ type: "expression",
21
+ label: "Value",
22
+ placeholder: '"order-" + run.environment',
23
+ default: "",
24
+ },
14
25
  ],
15
26
  summary: "vars.{config.name} = {config.value}",
16
27
  writesVar: "config.name",
@@ -0,0 +1,26 @@
1
+ import type { SamuraiTestConfig } from "../../types/config.js";
2
+ import type { AnyNodeDefinition, StepInfo } from "../core/index.js";
3
+ import type { Handler } from "../run/handlers/support.js";
4
+ import { type StepDefinition } from "./define.js";
5
+ type AnyStep = StepDefinition<any>;
6
+ /** The built-in steps, in library order */
7
+ export declare const BUILTIN_STEPS: readonly AnyStep[];
8
+ /** Everything a project's steps give the walker, the check and the app */
9
+ export interface StepSet {
10
+ /** The built-ins, then the project's in config order */
11
+ steps: readonly AnyStep[];
12
+ nodes: readonly AnyNodeDefinition[];
13
+ handlers: Readonly<Record<string, Handler>>;
14
+ /** The steps as data, for the app */
15
+ catalog: StepInfo[];
16
+ }
17
+ /** The built-in steps plus a project's (its config's `steps`). Throws for a list that can't work. */
18
+ export declare function stepSet(projectSteps?: unknown): StepSet;
19
+ /** The built-in steps alone */
20
+ export declare const BUILTIN_SET: StepSet;
21
+ /** The project's steps: its config's `steps` after the built-ins. `projectDir` defaults to the working directory. */
22
+ export declare function projectSteps(options?: {
23
+ projectDir?: string;
24
+ config?: SamuraiTestConfig;
25
+ }): Promise<StepSet>;
26
+ export {};
@@ -0,0 +1,43 @@
1
+ import { projectConfig } from "../../config/config.js";
2
+ import { api } from "./api.js";
3
+ import { checkStep, infoOf, stepNode } from "./define.js";
4
+ import { stepHandler } from "./handler.js";
5
+ import { setVariable } from "./set-variable.js";
6
+ import { wait } from "./wait.js";
7
+ /** The built-in steps, in library order */
8
+ export const BUILTIN_STEPS = [wait, setVariable, api];
9
+ const WHERE = " (samurai.config.ts)";
10
+ /** The built-in steps plus a project's (its config's `steps`). Throws for a list that can't work. */
11
+ export function stepSet(projectSteps = []) {
12
+ if (!Array.isArray(projectSteps))
13
+ throw new Error("samurai.config.ts: steps must be a list of steps.");
14
+ const builtin = new Set(BUILTIN_STEPS.map((s) => s.kind));
15
+ const seen = new Set();
16
+ projectSteps.forEach((entry, i) => {
17
+ const kind = entry && typeof entry === "object"
18
+ ? entry.kind
19
+ : undefined;
20
+ if (typeof kind !== "string" || kind === "")
21
+ throw new Error(`Step #${i + 1}${WHERE}: it has no kind; make it with defineStep.`);
22
+ checkStep(entry, WHERE);
23
+ if (builtin.has(kind))
24
+ throw new Error(`Step "${kind}"${WHERE}: a built-in step already uses this kind.`);
25
+ if (seen.has(kind))
26
+ throw new Error(`Step "${kind}"${WHERE}: declared twice.`);
27
+ seen.add(kind);
28
+ });
29
+ const steps = [...BUILTIN_STEPS, ...projectSteps];
30
+ return {
31
+ steps,
32
+ nodes: steps.map(stepNode),
33
+ handlers: Object.fromEntries(steps.map((s) => [s.kind, stepHandler(s)])),
34
+ catalog: steps.map(infoOf),
35
+ };
36
+ }
37
+ /** The built-in steps alone */
38
+ export const BUILTIN_SET = stepSet();
39
+ /** The project's steps: its config's `steps` after the built-ins. `projectDir` defaults to the working directory. */
40
+ export async function projectSteps(options = {}) {
41
+ const config = await projectConfig(options);
42
+ return config.steps === undefined ? BUILTIN_SET : stepSet(config.steps);
43
+ }
@@ -5,7 +5,14 @@ export const wait = defineStep({
5
5
  icon: "timer",
6
6
  section: "control",
7
7
  fields: [
8
- { path: "config.ms", type: "number", label: "Duration", unit: "ms", default: 1000, min: 0 },
8
+ {
9
+ path: "config.ms",
10
+ type: "number",
11
+ label: "Duration",
12
+ unit: "ms",
13
+ default: 1000,
14
+ min: 0,
15
+ },
9
16
  ],
10
17
  summary: "{config.ms} ms",
11
18
  async run({ config, sleep }) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Browser } from "../browser/browser.js";
4
+ import { ensureWarm } from "../browser/warm-up.js";
4
5
  import { projectDir } from "../config/config.js";
5
6
  import { withProject } from "../runner/project.js";
6
7
  import { parseSpec } from "../steps/parse.js";
@@ -52,6 +53,9 @@ export async function recordSpec(options) {
52
53
  if (source === undefined && options.create === undefined) {
53
54
  throw new Error(`${options.file} does not exist; give a title with --new to create it`);
54
55
  }
56
+ // Before the spec is touched: a recording without its browser changes nothing
57
+ await ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
58
+ options.signal?.throwIfAborted();
55
59
  source ??= "";
56
60
  if (options.create !== undefined &&
57
61
  !parseSpec(source).some((t) => t.name === options.create)) {
@@ -23,6 +23,11 @@ export interface ResolvedGroup {
23
23
  title: string;
24
24
  }[];
25
25
  }
26
+ /**
27
+ * The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
28
+ * from stack traces, which resolve symlinks, so paths compared with them must be real too
29
+ */
30
+ export declare function realPath(p: string): string;
26
31
  /** Separators of any platform become `/` */
27
32
  export declare const toPosix: (p: string) => string;
28
33
  /** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
@@ -1,8 +1,21 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { matchesGlob, relative, resolve } from "node:path";
2
3
  /** The test folder when the config names none */
3
4
  export const DEFAULT_SRC_DIR = "./src";
4
5
  /** The spec pattern when a group names none */
5
6
  export const DEFAULT_TEST_MATCH = "**/*.spec.ts";
7
+ /**
8
+ * The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
9
+ * from stack traces, which resolve symlinks, so paths compared with them must be real too
10
+ */
11
+ export function realPath(p) {
12
+ try {
13
+ return realpathSync(p);
14
+ }
15
+ catch {
16
+ return p;
17
+ }
18
+ }
6
19
  /** Separators of any platform become `/` */
7
20
  export const toPosix = (p) => p.replace(/\\/g, "/");
8
21
  /** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
@@ -3,6 +3,7 @@ import { glob } from "node:fs/promises";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
5
  import { Browser } from "../browser/browser.js";
6
+ import { ensureWarm } from "../browser/warm-up.js";
6
7
  import { loadConfig, projectDir, readConfig } from "../config/config.js";
7
8
  import logger from "../logger/index.js";
8
9
  import { assertModuleProject } from "./module-type.js";
@@ -14,7 +15,7 @@ import Page from "../browser/page.js";
14
15
  import { takePendingAssertions } from "../assert/expect.js";
15
16
  import { toTestError } from "./test-error.js";
16
17
  import { ConnectionClosedError } from "../transport/bidi-connection.js";
17
- import { DEFAULT_SRC_DIR, findGroup, resolveGroups, testId, toPosix, } from "./groups.js";
18
+ import { DEFAULT_SRC_DIR, findGroup, realPath, resolveGroups, testId, toPosix, } from "./groups.js";
18
19
  import { judgePageLogs } from "./page-logs-report.js";
19
20
  /** Whether a test with this full name is selected by the options */
20
21
  export function isSelected(name, { grep, testNames }) {
@@ -81,8 +82,9 @@ export default class TestRunner {
81
82
  /** The config's groups, resolved against the tests the specs register */
82
83
  async resolveAll(tests) {
83
84
  const config = await loadConfig();
84
- const dir = projectDir();
85
- const srcDir = path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR);
85
+ // Real paths: registered test files come from stack traces, which resolve symlinks
86
+ const dir = realPath(projectDir());
87
+ const srcDir = realPath(path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR));
86
88
  const groups = resolveGroups(config, tests.map(({ file, name }) => ({
87
89
  file: toPosix(path.relative(srcDir, file)),
88
90
  name,
@@ -114,6 +116,10 @@ export default class TestRunner {
114
116
  ...(onEvent && { onEvent }),
115
117
  });
116
118
  const selected = await this.register();
119
+ // A missing browser is one clear error before the run, not a failure per test; a fresh build's slow
120
+ // first start happens here, outside any test's timeout
121
+ if (selected.length > 0)
122
+ await ensureWarm((await readConfig("browser")) ?? "firefox", { port: this.options.port ?? 9223 }, signal);
117
123
  reporter.onStart(selected.length);
118
124
  for (const test of selected) {
119
125
  if (signal?.aborted) {
@@ -1,5 +1,7 @@
1
1
  import type { BiDiCommands, BiDiEvents } from "../types/bidi.js";
2
2
  import { type EventName, type Subscription, type SubscribeOptions } from "./subscriptions.js";
3
+ /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
4
+ export declare const DEFAULT_COMMAND_TIMEOUT = 30000;
3
5
  export interface ConnectorOptions {
4
6
  /** @default 30000 */
5
7
  commandTimeout?: number;
@@ -4,7 +4,7 @@ import { WaitTimeoutError } from "../wait/wait-until.js";
4
4
  import { BiDiError } from "./bidi-error.js";
5
5
  import { SubscriptionManager, } from "./subscriptions.js";
6
6
  /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
7
- const DEFAULT_COMMAND_TIMEOUT = 30000;
7
+ export const DEFAULT_COMMAND_TIMEOUT = 30000;
8
8
  /**
9
9
  * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
10
10
  * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
@@ -1,3 +1,4 @@
1
+ import type { StepDefinition } from "../flows/steps/define.js";
1
2
  import type { SupportedBrowser } from "./browser.js";
2
3
 
3
4
  interface SamuraiTest {
@@ -48,6 +49,11 @@ export interface SamuraiTestConfig {
48
49
  * Is overwritten by the 'browser' in a group's config
49
50
  */
50
51
  browser?: SupportedBrowser;
52
+ /**
53
+ * Browser binaries to launch instead of the builds SAMURAI downloads, relative to the project.
54
+ * `SAMURAI_FIREFOX_PATH` / `SAMURAI_CHROME_PATH` win over these.
55
+ */
56
+ browserPaths?: { firefox?: string; chrome?: string };
51
57
  /**
52
58
  * The timeout (in ms) to use for all tests
53
59
  * @default 30000
@@ -57,6 +63,12 @@ export interface SamuraiTestConfig {
57
63
  * Define a group of test's you want to have executed with a certain configuration
58
64
  **/
59
65
  groups?: SamuraiGroup[];
66
+ /**
67
+ * Flow steps of this project, made with `defineStep`. Listed after the built-in steps;
68
+ * a kind a built-in or another step uses is refused.
69
+ */
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ steps?: readonly StepDefinition<any>[];
60
72
  /**
61
73
  * The base URL to use when navigating the browser.
62
74
  * When recording starts, the browser will start at this URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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": {
@@ -24,6 +24,10 @@
24
24
  "types": "./dist/cli/init.d.ts",
25
25
  "default": "./dist/cli/init.js"
26
26
  },
27
+ "./browsers": {
28
+ "types": "./dist/browsers/index.d.ts",
29
+ "default": "./dist/browsers/index.js"
30
+ },
27
31
  "./browser": {
28
32
  "types": "./dist/browser/index.d.ts",
29
33
  "default": "./dist/browser/index.js"
@@ -65,6 +69,7 @@
65
69
  "tsx": "^4.21.0"
66
70
  },
67
71
  "dependencies": {
72
+ "@puppeteer/browsers": "^3.2.4",
68
73
  "typescript": "^6.0.2",
69
74
  "winston": "^3.19.0"
70
75
  },