@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.
- package/README.md +2 -2
- package/bin/samurai.mjs +25 -0
- package/dist/browser/evidence.d.ts +19 -0
- package/dist/browser/evidence.js +35 -0
- package/dist/browser/page.d.ts +6 -0
- package/dist/browser/page.js +20 -3
- package/dist/browser/screenshot.d.ts +2 -0
- package/dist/browser/screenshot.js +5 -2
- package/dist/cli/browsers.d.ts +1 -1
- package/dist/cli/browsers.js +2 -4
- package/dist/cli/main.js +17 -3
- package/dist/project/config-edit.d.ts +16 -0
- package/dist/project/config-edit.js +218 -0
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/secrets-file.d.ts +8 -0
- package/dist/project/secrets-file.js +69 -0
- package/dist/project/variables.d.ts +6 -0
- package/dist/project/variables.js +17 -0
- package/dist/recorder/candidates.d.ts +7 -0
- package/dist/recorder/candidates.js +118 -0
- package/dist/recorder/capture.d.ts +8 -1
- package/dist/recorder/capture.js +25 -116
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/normalise.d.ts +28 -1
- package/dist/recorder/normalise.js +40 -1
- package/dist/recorder/recorder.d.ts +54 -0
- package/dist/recorder/recorder.js +195 -10
- package/dist/recorder/session.d.ts +17 -0
- package/dist/recorder/session.js +62 -8
- package/dist/recorder/toolbar/controller.d.ts +136 -0
- package/dist/recorder/toolbar/controller.js +471 -0
- package/dist/recorder/toolbar/inspect.d.ts +12 -0
- package/dist/recorder/toolbar/inspect.js +94 -0
- package/dist/recorder/toolbar/protocol.d.ts +135 -0
- package/dist/recorder/toolbar/protocol.js +4 -0
- package/dist/recorder/toolbar/source.d.ts +32 -0
- package/dist/recorder/toolbar/source.js +856 -0
- package/dist/recorder/toolbar/styles.d.ts +6 -0
- package/dist/recorder/toolbar/styles.js +95 -0
- package/dist/runner/reporter.d.ts +5 -1
- package/dist/runner/reporter.js +4 -3
- package/dist/runner/test-runner.d.ts +9 -0
- package/dist/runner/test-runner.js +39 -4
- package/dist/steps/edit.d.ts +3 -2
- package/dist/steps/edit.js +28 -4
- package/dist/steps/emit.js +4 -0
- package/dist/steps/model.d.ts +21 -0
- package/dist/steps/parse.js +45 -3
- package/dist/transport/bidi-connection.js +32 -1
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/test.d.ts +4 -0
- 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
|
|
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
|
|
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
|
+
}
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -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 */
|
package/dist/browser/page.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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.
|
package/dist/cli/browsers.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/cli/browsers.js
CHANGED
|
@@ -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
|
|
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.
|
|
135
|
-
io.out(`Set ${result.
|
|
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,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;
|