@itmetsam/samurai-framework 0.6.0 → 0.7.1

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 (53) hide show
  1. package/README.md +2 -2
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/browser/evidence.d.ts +19 -0
  4. package/dist/browser/evidence.js +35 -0
  5. package/dist/browser/page.d.ts +6 -0
  6. package/dist/browser/page.js +20 -3
  7. package/dist/browser/screenshot.d.ts +2 -0
  8. package/dist/browser/screenshot.js +5 -2
  9. package/dist/cli/browsers.d.ts +1 -1
  10. package/dist/cli/browsers.js +2 -4
  11. package/dist/cli/main.js +17 -3
  12. package/dist/project/config-edit.d.ts +16 -0
  13. package/dist/project/config-edit.js +218 -0
  14. package/dist/project/index.d.ts +3 -0
  15. package/dist/project/index.js +3 -0
  16. package/dist/project/secrets-file.d.ts +8 -0
  17. package/dist/project/secrets-file.js +69 -0
  18. package/dist/project/variables.d.ts +6 -0
  19. package/dist/project/variables.js +17 -0
  20. package/dist/recorder/candidates.d.ts +7 -0
  21. package/dist/recorder/candidates.js +118 -0
  22. package/dist/recorder/capture.d.ts +8 -1
  23. package/dist/recorder/capture.js +25 -116
  24. package/dist/recorder/index.d.ts +1 -1
  25. package/dist/recorder/normalise.d.ts +28 -1
  26. package/dist/recorder/normalise.js +40 -1
  27. package/dist/recorder/recorder.d.ts +54 -0
  28. package/dist/recorder/recorder.js +195 -10
  29. package/dist/recorder/session.d.ts +17 -0
  30. package/dist/recorder/session.js +62 -8
  31. package/dist/recorder/toolbar/controller.d.ts +136 -0
  32. package/dist/recorder/toolbar/controller.js +471 -0
  33. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  34. package/dist/recorder/toolbar/inspect.js +94 -0
  35. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  36. package/dist/recorder/toolbar/protocol.js +4 -0
  37. package/dist/recorder/toolbar/source.d.ts +32 -0
  38. package/dist/recorder/toolbar/source.js +856 -0
  39. package/dist/recorder/toolbar/styles.d.ts +6 -0
  40. package/dist/recorder/toolbar/styles.js +95 -0
  41. package/dist/runner/reporter.d.ts +5 -1
  42. package/dist/runner/reporter.js +4 -3
  43. package/dist/runner/test-runner.d.ts +9 -0
  44. package/dist/runner/test-runner.js +39 -4
  45. package/dist/steps/edit.d.ts +3 -2
  46. package/dist/steps/edit.js +28 -4
  47. package/dist/steps/emit.js +4 -0
  48. package/dist/steps/model.d.ts +21 -0
  49. package/dist/steps/parse.js +45 -3
  50. package/dist/transport/bidi-connection.js +32 -1
  51. package/dist/types/bidi-modules/script.d.ts +20 -3
  52. package/dist/types/test.d.ts +4 -0
  53. package/package.json +5 -1
package/README.md CHANGED
@@ -20,7 +20,7 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
20
20
  - **Semantic locators.** By role, label, text, test id, CSS or XPath, with ordered fallbacks.
21
21
  - **Environments and secrets.** One test file runs against `dev`, `staging` or `production`; secrets come from the environment or a `.env.<name>` file and are masked in logs and reports.
22
22
  - **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
23
- - **Recorder building blocks.** Specs are parsed into steps and edited back with minimal diffs, and an engine records what a person does in the browser. The UI that ties them together is not built yet (see the [roadmap](docs/roadmap.md)).
23
+ - **Recorder building blocks.** Specs are parsed into steps and edited back with minimal diffs, and an engine records what a person does in the browser. A toolbar in the recorded page adds assertions, variables and secrets, waits, screenshots and pause (see [Steps and the recorder](docs/recorder.md)). The Electron app that ties them together lives in a separate repository (see the [roadmap](docs/roadmap.md)).
24
24
 
25
25
  > **Status:** early. Firefox only. See [what is still open](docs/roadmap.md).
26
26
 
@@ -119,7 +119,7 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
119
119
  | [Command line](docs/cli.md) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
120
120
  | [Flows](docs/flows.md) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
121
121
  | [Running tests and reports](docs/running-and-reports.md) | What a run does, `report.json`, page logs, timeouts |
122
- | [Steps and the recorder](docs/recorder.md) | The step codec and the recording engine |
122
+ | [Steps and the recorder](docs/recorder.md) | The step codec, the recording engine and the recorder toolbar |
123
123
  | [Embedding the framework](docs/embedding.md) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
124
124
  | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
125
125
  | [Roadmap](docs/roadmap.md) | What is built and what is still open |
package/bin/samurai.mjs CHANGED
@@ -1,4 +1,29 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ // Specs import the framework from their project. A global (or linked) samurai must run that same copy, or the specs
8
+ // register their tests in the project's copy and this one finds none
9
+ const own = realpathSync(
10
+ path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
11
+ );
12
+ let local;
13
+ try {
14
+ local = path.dirname(
15
+ createRequire(path.join(process.cwd(), "package.json")).resolve(
16
+ "@itmetsam/samurai-framework/package.json",
17
+ ),
18
+ );
19
+ } catch {
20
+ local = undefined;
21
+ }
22
+ if (local !== undefined && realpathSync(local) !== own) {
23
+ // That copy's binary runs the command and exits
24
+ await import(pathToFileURL(path.join(local, "bin/samurai.mjs")).href);
25
+ }
26
+
2
27
  // Quiet the framework's console logging unless the caller asked for more; the log files keep everything
3
28
  process.env.SAMURAI_LOG_LEVEL ??= "warn";
4
29
 
@@ -0,0 +1,19 @@
1
+ /** A screenshot name as a file name: letters, digits, - and _ (the rest becomes -) */
2
+ export declare function cleanName(name: string): string;
3
+ /** Where a run (or one test) saves named screenshots, and the ones it saved */
4
+ export declare class Evidence {
5
+ private readonly resultDir;
6
+ private readonly testDir?;
7
+ readonly screenshots: {
8
+ name: string;
9
+ path: string;
10
+ }[];
11
+ private readonly used;
12
+ constructor(resultDir: string, testDir?: string | undefined);
13
+ /** The file for the next screenshot called `name` (`.jpg` for a jpeg, else `.png`); a repeat gets -2, -3, … */
14
+ fileFor(name: string, type?: "png" | "jpeg"): {
15
+ file: string;
16
+ name: string;
17
+ path: string;
18
+ };
19
+ }
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ /** A screenshot name as a file name: letters, digits, - and _ (the rest becomes -) */
3
+ export function cleanName(name) {
4
+ const cleaned = name.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
5
+ return cleaned === "" ? "screenshot" : cleaned;
6
+ }
7
+ /** Where a run (or one test) saves named screenshots, and the ones it saved */
8
+ export class Evidence {
9
+ constructor(resultDir, testDir) {
10
+ this.resultDir = resultDir;
11
+ this.testDir = testDir;
12
+ this.screenshots = [];
13
+ this.used = new Set();
14
+ }
15
+ /** The file for the next screenshot called `name` (`.jpg` for a jpeg, else `.png`); a repeat gets -2, -3, … */
16
+ fileFor(name, type = "png") {
17
+ const base = cleanName(name);
18
+ let unique = base;
19
+ for (let n = 2; this.used.has(unique); n++)
20
+ unique = `${base}-${n}`;
21
+ this.used.add(unique);
22
+ const relative = [
23
+ "screenshots",
24
+ ...(this.testDir ? this.testDir.split("/") : []),
25
+ `${unique}.${type === "jpeg" ? "jpg" : "png"}`,
26
+ ].join("/");
27
+ const entry = {
28
+ file: path.join(this.resultDir, ...relative.split("/")),
29
+ name: unique,
30
+ path: relative,
31
+ };
32
+ this.screenshots.push({ name: entry.name, path: entry.path });
33
+ return entry;
34
+ }
35
+ }
@@ -9,6 +9,7 @@ import { type EmulationOptions } from "./emulation.js";
9
9
  import { Download } from "./download.js";
10
10
  import { FileChooser } from "./file-chooser.js";
11
11
  import { type ScreenshotOptions } from "./screenshot.js";
12
+ import { Evidence } from "./evidence.js";
12
13
  import { type ConsoleMessage, type LogRecord, type PageError } from "./page-logs.js";
13
14
  import { type RouteHandler } from "../network/router.js";
14
15
  import { Response } from "../network/response.js";
@@ -182,6 +183,11 @@ export default class Page {
182
183
  * await page.screenshot({ path: "out/home.png", fullPage: true });
183
184
  */
184
185
  screenshot(options?: ScreenshotOptions): Promise<Buffer>;
186
+ private evidence;
187
+ /** @internal The runner gives each test its own evidence folder */
188
+ setEvidence(evidence: Evidence | undefined): void;
189
+ /** Waits `ms` milliseconds. Prefer waiting for something (an assertion, `waitForNetworkIdle`) when you can */
190
+ waitForTimeout(ms: number): Promise<void>;
185
191
  /** Renders the page as a PDF and returns it (also written to `options.path`) */
186
192
  pdf(options?: PdfOptions): Promise<Buffer>;
187
193
  /** The browser context (cookies, storage) this page belongs to */
@@ -1,7 +1,8 @@
1
1
  import Locator, {} from "../locator/locator.js";
2
2
  import { cssSelector, labelSelector, roleSelector, testIdSelector, textSelector, } from "../locator/selector.js";
3
3
  import EventEmitter from "node:events";
4
- import { readConfig } from "../config/config.js";
4
+ import path from "node:path";
5
+ import { projectDir, readConfig } from "../config/config.js";
5
6
  import { peekRunSettings } from "../config/run-settings.js";
6
7
  import logger from "../logger/index.js";
7
8
  import { NetworkTracker, } from "../network/network-tracker.js";
@@ -13,7 +14,8 @@ import { applyEmulation } from "./emulation.js";
13
14
  import { Download, DownloadTracker } from "./download.js";
14
15
  import { FileChooser, FileChooserTracker } from "./file-chooser.js";
15
16
  import { DownloadWaitTimeoutError, FileChooserTimeoutError, } from "./page-wait.js";
16
- import { takeScreenshot } from "./screenshot.js";
17
+ import { takeScreenshot, writeOutput, } from "./screenshot.js";
18
+ import { Evidence } from "./evidence.js";
17
19
  import { PageLogs, } from "./page-logs.js";
18
20
  import { Router } from "../network/router.js";
19
21
  import { DataCollector, ResponseBodyUnavailableError, } from "../network/data-collector.js";
@@ -405,7 +407,22 @@ export default class Page {
405
407
  */
406
408
  async screenshot(options = {}) {
407
409
  this.assertOpen();
408
- return takeScreenshot(this.biDiConnector, this.id, options);
410
+ const image = await takeScreenshot(this.biDiConnector, this.id, options);
411
+ if (options.name !== undefined) {
412
+ const evidence = this.evidence ?? new Evidence(path.join(projectDir(), "result"));
413
+ await writeOutput(evidence.fileFor(options.name, options.type).file, image);
414
+ }
415
+ return image;
416
+ }
417
+ /** @internal The runner gives each test its own evidence folder */
418
+ setEvidence(evidence) {
419
+ this.evidence = evidence;
420
+ }
421
+ /** Waits `ms` milliseconds. Prefer waiting for something (an assertion, `waitForNetworkIdle`) when you can */
422
+ async waitForTimeout(ms) {
423
+ if (!Number.isFinite(ms) || ms < 0)
424
+ throw new RangeError(`waitForTimeout needs 0 or more milliseconds, got ${ms}`);
425
+ await new Promise((resolve) => setTimeout(resolve, ms));
409
426
  }
410
427
  /** Renders the page as a PDF and returns it (also written to `options.path`) */
411
428
  async pdf(options = {}) {
@@ -11,6 +11,8 @@ export interface ScreenshotOptions {
11
11
  width: number;
12
12
  height: number;
13
13
  };
14
+ /** Also save the image as run evidence under this name (see the Page API docs) */
15
+ name?: string;
14
16
  /** @default "png" */
15
17
  type?: "png" | "jpeg";
16
18
  /** 0 to 100, jpeg only */
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  const MIME = { png: "image/png", jpeg: "image/jpeg" };
4
4
  /** The `format` of a capture command; validates the type/quality combination */
5
- function imageFormat({ type, quality }) {
5
+ function imageFormat({ type, quality, }) {
6
6
  if (quality !== undefined) {
7
7
  if (type !== "jpeg")
8
8
  throw new RangeError("screenshot(): quality only applies to jpeg");
@@ -12,7 +12,10 @@ function imageFormat({ type, quality }) {
12
12
  }
13
13
  if (type === undefined)
14
14
  return undefined;
15
- return { type: MIME[type], ...(quality !== undefined && { quality: quality / 100 }) };
15
+ return {
16
+ type: MIME[type],
17
+ ...(quality !== undefined && { quality: quality / 100 }),
18
+ };
16
19
  }
17
20
  /**
18
21
  * Captures the page (or, with `element`, that element) and returns the image. Writes it to `options.path` too.
@@ -2,5 +2,5 @@ import { type StoreOptions } from "../browsers/index.js";
2
2
  import type { Io } from "./main.js";
3
3
  export declare const BROWSERS_USAGE = "Usage:\n samurai browsers [list] The pinned browsers and every build in the cache\n samurai browsers install [firefox|chrome|all] Download the pinned builds (default: all)\n samurai browsers uninstall <name> [--build <id>]\n Remove a build (default: the pinned one)\n samurai browsers uninstall --unused Remove builds this framework version doesn't pin\n";
4
4
  export declare function formatBytes(n: number): string;
5
- /** `samurai browsers …`; `options` is for tests. The cache folder and download host follow `io.env` */
5
+ /** `samurai browsers …`; `options` is for tests. The cache folder follows `io.env` */
6
6
  export declare function browsersCommand(argv: string[], io: Io, options?: StoreOptions): Promise<number>;
@@ -1,5 +1,4 @@
1
1
  import { browsersCacheDir, browserStatus, installBrowser, isManagedBrowser, listInstalled, MANAGED_BROWSERS, PINNED, uninstallBrowser, } from "../browsers/index.js";
2
- import { puppeteerDownloader } from "../browsers/downloader.js";
3
2
  import { parseCommandLine } from "./args.js";
4
3
  export const BROWSERS_USAGE = `Usage:
5
4
  samurai browsers [list] The pinned browsers and every build in the cache
@@ -70,12 +69,11 @@ async function install(io, targets, store) {
70
69
  io.out(`${label} installed at ${exe}\n`);
71
70
  }
72
71
  }
73
- /** `samurai browsers …`; `options` is for tests. The cache folder and download host follow `io.env` */
72
+ /** `samurai browsers …`; `options` is for tests. The cache folder follows `io.env` */
74
73
  export async function browsersCommand(argv, io, options = {}) {
75
74
  const store = {
75
+ ...options,
76
76
  cacheDir: options.cacheDir ?? browsersCacheDir(io.env),
77
- downloader: options.downloader ??
78
- puppeteerDownloader(io.env.SAMURAI_BROWSERS_BASE_URL),
79
77
  };
80
78
  const { values, positionals } = parseCommandLine(argv);
81
79
  const [sub = "list", target] = positionals;
package/dist/cli/main.js CHANGED
@@ -105,6 +105,8 @@ export async function main(argv, io) {
105
105
  ? Number(values.test)
106
106
  : values.test;
107
107
  let count = 0;
108
+ let state = "recording";
109
+ let toolbarWarned = false;
108
110
  const result = await recordSpec({
109
111
  ...parseRunOverrides(rest, io.env),
110
112
  file,
@@ -116,7 +118,19 @@ export async function main(argv, io) {
116
118
  ...(io.signal && { signal: io.signal }),
117
119
  onStarted: () => {
118
120
  if (!values.json)
119
- io.out("Recording. Alt+click an element to assert on it. Press Ctrl+C or close the window to stop.\n");
121
+ io.out("Recording. Alt+click an element to assert on it. Press Stop in the toolbar, Ctrl+C or close the window to stop.\n");
122
+ },
123
+ onStatus: (status) => {
124
+ if (values.json)
125
+ return;
126
+ if (!status.toolbar && !toolbarWarned) {
127
+ toolbarWarned = true;
128
+ io.out("The toolbar couldn't load on this page. Alt+click still records assertions.\n");
129
+ }
130
+ if (status.state !== state) {
131
+ state = status.state;
132
+ io.out(state === "paused" ? "Paused\n" : "Recording\n");
133
+ }
120
134
  },
121
135
  onEvent: (event) => {
122
136
  if (values.json)
@@ -131,8 +145,8 @@ export async function main(argv, io) {
131
145
  });
132
146
  if (!values.json) {
133
147
  io.out(`\nRecorded ${count} ${count === 1 ? "step" : "steps"} into "${result.test}" in ${result.file}\n`);
134
- if (result.secrets.length > 0) {
135
- io.out(`Set ${result.secrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
148
+ if (result.unsetSecrets.length > 0) {
149
+ io.out(`Set ${result.unsetSecrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
136
150
  }
137
151
  }
138
152
  return 0;
@@ -0,0 +1,16 @@
1
+ export type ConfigValue = string | number | boolean | ConfigValue[] | {
2
+ [key: string]: ConfigValue;
3
+ };
4
+ /** The config can't be edited in place, e.g. it builds its object from variables or spreads */
5
+ export declare class ConfigEditError extends Error {
6
+ }
7
+ /**
8
+ * Sets (or with `undefined`, removes) one value in samurai.config.ts, addressed by its path, e.g.
9
+ * ["environments", "staging", "baseURL"]. Only that property's text changes; missing objects on the way are
10
+ * created. Throws ConfigEditError when the file isn't shaped so that this is safe.
11
+ */
12
+ export declare function editConfig(source: string, path: string[], value: ConfigValue | undefined): string;
13
+ /** The text of one property (`staging: { … }`), indentation removed, for showing a part of the file; undefined when missing */
14
+ export declare function propertyText(source: string, path: string[]): string | undefined;
15
+ /** Whether the property at this path exists in the file's source, walking the same object literals `editConfig` does */
16
+ export declare function hasConfigPath(source: string, path: string[]): boolean;
@@ -0,0 +1,218 @@
1
+ import ts from "typescript";
2
+ /** The config can't be edited in place, e.g. it builds its object from variables or spreads */
3
+ export class ConfigEditError extends Error {
4
+ }
5
+ const KEY = /^[A-Za-z_$][\w$]*$/;
6
+ const keyText = (key) => (KEY.test(key) ? key : JSON.stringify(key));
7
+ function emit(value, indent, unit) {
8
+ if (typeof value !== "object")
9
+ return JSON.stringify(value);
10
+ if (Array.isArray(value)) {
11
+ if (value.length === 0)
12
+ return "[]";
13
+ const inner = indent + unit;
14
+ return `[\n${value.map((v) => `${inner}${emit(v, inner, unit)},`).join("\n")}\n${indent}]`;
15
+ }
16
+ const entries = Object.entries(value);
17
+ if (entries.length === 0)
18
+ return "{}";
19
+ const inner = indent + unit;
20
+ return `{\n${entries
21
+ .map(([k, v]) => `${inner}${keyText(k)}: ${emit(v, inner, unit)},`)
22
+ .join("\n")}\n${indent}}`;
23
+ }
24
+ function nameOf(prop) {
25
+ if (!ts.isPropertyAssignment(prop) &&
26
+ !ts.isShorthandPropertyAssignment(prop)) {
27
+ return undefined;
28
+ }
29
+ const n = prop.name;
30
+ return ts.isIdentifier(n) ||
31
+ ts.isStringLiteral(n) ||
32
+ ts.isNoSubstitutionTemplateLiteral(n)
33
+ ? n.text
34
+ : undefined;
35
+ }
36
+ /** The object `defineConfig({...})` or `export default {...}` is given */
37
+ function rootObject(sf) {
38
+ for (const statement of sf.statements) {
39
+ if (!ts.isExportAssignment(statement))
40
+ continue;
41
+ let node = statement.expression;
42
+ for (;;) {
43
+ if (ts.isParenthesizedExpression(node) ||
44
+ ts.isSatisfiesExpression(node) ||
45
+ ts.isAsExpression(node)) {
46
+ node = node.expression;
47
+ }
48
+ else
49
+ break;
50
+ }
51
+ if (ts.isCallExpression(node) &&
52
+ node.arguments[0] &&
53
+ ts.isObjectLiteralExpression(node.arguments[0])) {
54
+ return node.arguments[0];
55
+ }
56
+ if (ts.isObjectLiteralExpression(node))
57
+ return node;
58
+ }
59
+ throw new ConfigEditError("samurai.config.ts doesn't export a plain object (defineConfig({ … })), so it can't be edited here.");
60
+ }
61
+ const lineIndent = (source, pos) => {
62
+ const start = source.lastIndexOf("\n", pos - 1) + 1;
63
+ return /^[ \t]*/.exec(source.slice(start))[0];
64
+ };
65
+ function unitOf(source, obj) {
66
+ const first = obj.properties[0];
67
+ if (first) {
68
+ const inner = lineIndent(source, first.getStart());
69
+ const outer = lineIndent(source, obj.getStart());
70
+ if (inner.length > outer.length)
71
+ return inner.slice(outer.length);
72
+ }
73
+ return " ";
74
+ }
75
+ function insertProperty(source, obj, key, value) {
76
+ const unit = unitOf(source, obj);
77
+ const outer = lineIndent(source, obj.getStart());
78
+ const inner = outer + unit;
79
+ const multiline = obj.getText().includes("\n");
80
+ const prop = `${keyText(key)}: ${emit(value, inner, unit)}`;
81
+ const last = obj.properties[obj.properties.length - 1];
82
+ if (!last) {
83
+ return {
84
+ start: obj.getStart(),
85
+ end: obj.end,
86
+ text: `{\n${inner}${prop},\n${outer}}`,
87
+ };
88
+ }
89
+ const trailing = obj.properties.hasTrailingComma
90
+ ? /^\s*,/.exec(source.slice(last.end))
91
+ : null;
92
+ if (!multiline) {
93
+ return trailing
94
+ ? {
95
+ start: last.end + trailing[0].length,
96
+ end: last.end + trailing[0].length,
97
+ text: ` ${keyText(key)}: ${emit(value, outer, unit)},`,
98
+ }
99
+ : {
100
+ start: last.end,
101
+ end: last.end,
102
+ text: `, ${keyText(key)}: ${emit(value, outer, unit)}`,
103
+ };
104
+ }
105
+ return trailing
106
+ ? {
107
+ start: last.end + trailing[0].length,
108
+ end: last.end + trailing[0].length,
109
+ text: `\n${inner}${prop},`,
110
+ }
111
+ : { start: last.end, end: last.end, text: `,\n${inner}${prop}` };
112
+ }
113
+ function removeProperty(source, prop) {
114
+ const s = prop.getStart();
115
+ let e = prop.end;
116
+ const comma = /^[ \t]*,/.exec(source.slice(e));
117
+ if (comma)
118
+ e += comma[0].length;
119
+ const lineStart = source.lastIndexOf("\n", s - 1) + 1;
120
+ const aloneBefore = /^[ \t]*$/.test(source.slice(lineStart, s));
121
+ const rest = /^[ \t]*(\r?\n)/.exec(source.slice(e));
122
+ if (aloneBefore && rest)
123
+ return { start: lineStart, end: e + rest[0].length, text: "" };
124
+ const space = /^[ \t]*/.exec(source.slice(e))[0];
125
+ return { start: s, end: e + space.length, text: "" };
126
+ }
127
+ /**
128
+ * Sets (or with `undefined`, removes) one value in samurai.config.ts, addressed by its path, e.g.
129
+ * ["environments", "staging", "baseURL"]. Only that property's text changes; missing objects on the way are
130
+ * created. Throws ConfigEditError when the file isn't shaped so that this is safe.
131
+ */
132
+ export function editConfig(source, path, value) {
133
+ if (path.length === 0)
134
+ throw new ConfigEditError("No setting given.");
135
+ const sf = ts.createSourceFile("samurai.config.ts", source, ts.ScriptTarget.Latest, true);
136
+ let obj = rootObject(sf);
137
+ for (let i = 0; i < path.length; i++) {
138
+ const key = path[i];
139
+ const prop = obj.properties.find((p) => nameOf(p) === key);
140
+ const isLast = i === path.length - 1;
141
+ if (prop && !ts.isPropertyAssignment(prop)) {
142
+ throw new ConfigEditError(`"${key}" in samurai.config.ts isn't a plain value (shorthand or spread), so it can't be edited here.`);
143
+ }
144
+ if (isLast) {
145
+ let splice;
146
+ if (value === undefined) {
147
+ if (!prop)
148
+ return source;
149
+ splice = removeProperty(source, prop);
150
+ }
151
+ else if (prop && ts.isPropertyAssignment(prop)) {
152
+ const init = prop.initializer;
153
+ const unit = unitOf(source, obj);
154
+ splice = {
155
+ start: init.getStart(),
156
+ end: init.end,
157
+ text: emit(value, lineIndent(source, prop.getStart()), unit),
158
+ };
159
+ }
160
+ else {
161
+ splice = insertProperty(source, obj, key, value);
162
+ }
163
+ return (source.slice(0, splice.start) + splice.text + source.slice(splice.end));
164
+ }
165
+ if (!prop) {
166
+ if (value === undefined)
167
+ return source;
168
+ // Build the rest of the path as one nested object and add it here
169
+ let nested = value;
170
+ for (let j = path.length - 1; j > i; j--)
171
+ nested = { [path[j]]: nested };
172
+ const splice = insertProperty(source, obj, key, nested);
173
+ return (source.slice(0, splice.start) + splice.text + source.slice(splice.end));
174
+ }
175
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
176
+ throw new ConfigEditError(`"${key}" in samurai.config.ts isn't a plain object, so it can't be edited here.`);
177
+ }
178
+ obj = prop.initializer;
179
+ }
180
+ return source;
181
+ }
182
+ /** The text of one property (`staging: { … }`), indentation removed, for showing a part of the file; undefined when missing */
183
+ export function propertyText(source, path) {
184
+ const sf = ts.createSourceFile("samurai.config.ts", source, ts.ScriptTarget.Latest, true);
185
+ let obj;
186
+ try {
187
+ obj = rootObject(sf);
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ for (let i = 0; i < path.length; i++) {
193
+ const prop = obj.properties.find((p) => nameOf(p) === path[i]);
194
+ if (!prop)
195
+ return undefined;
196
+ if (i === path.length - 1) {
197
+ const indent = lineIndent(source, prop.getStart());
198
+ return prop
199
+ .getText()
200
+ .split("\n")
201
+ .map((line, n) => n === 0
202
+ ? line
203
+ : line.startsWith(indent)
204
+ ? line.slice(indent.length)
205
+ : line.trimStart())
206
+ .join("\n");
207
+ }
208
+ if (!ts.isPropertyAssignment(prop) ||
209
+ !ts.isObjectLiteralExpression(prop.initializer))
210
+ return undefined;
211
+ obj = prop.initializer;
212
+ }
213
+ return undefined;
214
+ }
215
+ /** Whether the property at this path exists in the file's source, walking the same object literals `editConfig` does */
216
+ export function hasConfigPath(source, path) {
217
+ return propertyText(source, path) !== undefined;
218
+ }
@@ -0,0 +1,3 @@
1
+ export { ConfigEditError, editConfig, hasConfigPath, propertyText, type ConfigValue, } from "./config-edit.js";
2
+ export { addVariable, ExistsError, VARIABLE_NAME } from "./variables.js";
3
+ export { isGitIgnored, SECRET_NAME, setSecret } from "./secrets-file.js";
@@ -0,0 +1,3 @@
1
+ export { ConfigEditError, editConfig, hasConfigPath, propertyText, } from "./config-edit.js";
2
+ export { addVariable, ExistsError, VARIABLE_NAME } from "./variables.js";
3
+ export { isGitIgnored, SECRET_NAME, setSecret } from "./secrets-file.js";
@@ -0,0 +1,8 @@
1
+ export declare const SECRET_NAME: RegExp;
2
+ /** Whether .gitignore covers this file: its name, or `.env*` / `.env.*` */
3
+ export declare function isGitIgnored(projectDir: string, file: string): Promise<boolean>;
4
+ /**
5
+ * Sets SAMURAI_SECRET_<name> in .env.<environment>, replacing its line or adding one. The value is masked
6
+ * in this process's logs from then on, as a secret loaded from the file would be
7
+ */
8
+ export declare function setSecret(projectDir: string, environment: string, name: string, value: string): Promise<void>;
@@ -0,0 +1,69 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { registerSecret } from "../config/mask.js";
5
+ const PREFIX = "SAMURAI_SECRET_";
6
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
7
+ const ENVIRONMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
8
+ function envFile(projectDir, environment) {
9
+ // The name ends up in a file name: keep it to plain characters
10
+ if (!ENVIRONMENT.test(environment))
11
+ throw new Error(`"${environment}" isn't a valid environment name.`);
12
+ return path.join(projectDir, `.env.${environment}`);
13
+ }
14
+ function checkName(name) {
15
+ if (!SECRET_NAME.test(name))
16
+ throw new Error("Use letters, digits and _; a secret is UPPER_SNAKE_CASE.");
17
+ }
18
+ /**
19
+ * dotenv value: bare when that is safe, else in the quotes that need no escaping. Node's parser, which the
20
+ * framework reads these files with, can't escape a quote inside a quote, so the quote kind is picked to not
21
+ * occur in the value.
22
+ */
23
+ function formatValue(value) {
24
+ if (/^[A-Za-z0-9_./:@%+=-]*$/.test(value))
25
+ return value;
26
+ if (!value.includes("'") && !/[\r\n]/.test(value))
27
+ return `'${value}'`;
28
+ if (!value.includes("`"))
29
+ return `\`${value}\``;
30
+ if (!value.includes('"') && !value.includes("\\") && !/[\r\n]/.test(value))
31
+ return `"${value}"`;
32
+ throw new Error("This value has both ' and ` characters (and more), which a .env file can't hold. Set it as a SAMURAI_SECRET_* environment variable instead.");
33
+ }
34
+ const lineFor = (name) => new RegExp(`^[ \\t]*(?:export[ \\t]+)?${PREFIX}${name}[ \\t]*=`);
35
+ /** Whether .gitignore covers this file: its name, or `.env*` / `.env.*` */
36
+ export async function isGitIgnored(projectDir, file) {
37
+ try {
38
+ const text = await readFile(path.join(projectDir, ".gitignore"), "utf8");
39
+ return text.split(/\r?\n/).some((l) => {
40
+ const line = l.trim().replace(/^\//, "");
41
+ return line === file || line === ".env*" || line === ".env.*";
42
+ });
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ /**
49
+ * Sets SAMURAI_SECRET_<name> in .env.<environment>, replacing its line or adding one. The value is masked
50
+ * in this process's logs from then on, as a secret loaded from the file would be
51
+ */
52
+ export async function setSecret(projectDir, environment, name, value) {
53
+ checkName(name);
54
+ if (value === "")
55
+ throw new Error("A secret can't be empty. Remove it instead.");
56
+ if (/\r|\n/.test(value) && !value.trim())
57
+ throw new Error("A secret can't be only line breaks.");
58
+ const file = envFile(projectDir, environment);
59
+ const text = existsSync(file) ? await readFile(file, "utf8") : "";
60
+ const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n");
61
+ const line = `${PREFIX}${name}=${formatValue(value)}`;
62
+ const at = lines.findIndex((l) => lineFor(name).test(l));
63
+ if (at >= 0)
64
+ lines[at] = line;
65
+ else
66
+ lines.push(line);
67
+ await writeFile(file, lines.join("\n") + "\n", { mode: 0o600 });
68
+ registerSecret(name, value);
69
+ }
@@ -0,0 +1,6 @@
1
+ /** The name is already set in that environment */
2
+ export declare class ExistsError extends Error {
3
+ }
4
+ export declare const VARIABLE_NAME: RegExp;
5
+ /** Adds a variable to one environment in samurai.config.ts; only that property's text changes */
6
+ export declare function addVariable(projectDir: string, environment: string, name: string, value: string): Promise<void>;
@@ -0,0 +1,17 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { editConfig, hasConfigPath } from "./config-edit.js";
4
+ /** The name is already set in that environment */
5
+ export class ExistsError extends Error {
6
+ }
7
+ export const VARIABLE_NAME = /^[A-Za-z_$][\w$]*$/;
8
+ /** Adds a variable to one environment in samurai.config.ts; only that property's text changes */
9
+ export async function addVariable(projectDir, environment, name, value) {
10
+ if (!VARIABLE_NAME.test(name))
11
+ throw new Error("Use letters, digits and _, starting with a letter.");
12
+ const file = path.join(projectDir, "samurai.config.ts");
13
+ const source = await readFile(file, "utf8");
14
+ if (hasConfigPath(source, ["environments", environment, "variables", name]))
15
+ throw new ExistsError(`"${name}" already exists`);
16
+ await writeFile(file, editConfig(source, ["environments", environment, "variables", name], value));
17
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Declarations that turn an element into locator candidates (test id, role and name, label, text, css and
3
+ * an absolute xpath), plus `inToolbar(event)`, which is true for events from the recorder toolbar. A string
4
+ * of plain declarations, interpolated into the capture script (page realm) and the toolbar script
5
+ * (sandbox realm), so no transpiler helper can end up in it.
6
+ */
7
+ export declare const CANDIDATES_JS: string;