@itmetsam/samurai-framework 0.2.0 → 0.2.2

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no WebDriver server and no Playwright or Puppeteer underneath.
4
4
 
5
+ [![CI](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml)
6
+
5
7
  ```ts
6
8
  import { expect, test } from "../api.js";
7
9
 
@@ -86,7 +88,7 @@ bun run dev --timeout 60000 # per-test timeout in ms; also --expect-timeout
86
88
 
87
89
  Once the package is installed in a project, `samurai run` does the same with a readable summary and a proper exit code, and `samurai init` scaffolds a new project (see [Command line](docs/cli.md)).
88
90
 
89
- A browser window opens for each test and closes when the test ends. The result is written to `result/report.json` (status, duration and error per test); nothing summarises it on the console yet. The process exits with code 0 when every test passed and 1 otherwise (add `--headless` to hide the window). Framework logs go to the console and `logs/`.
91
+ A browser window opens for each test and closes when the test ends. The result is written to `result/report.json` (status, duration and error per test); nothing summarises it on the console yet. The process exits with code 0 when every test passed and 1 otherwise (`--headless` hides the window; on CI it is the default, see [Configuration](docs/configuration.md#headless)). Framework logs go to the console and `logs/`.
90
92
 
91
93
  > `src/tests/index.spec.ts` is an example that submits a real contact form. Delete it or replace it before running everything.
92
94
 
package/dist/cli/args.js CHANGED
@@ -21,6 +21,7 @@ export function parseCommandLine(argv) {
21
21
  args: argv,
22
22
  options: FLAGS,
23
23
  strict: true,
24
+ allowNegative: true,
24
25
  allowPositionals: true,
25
26
  });
26
27
  }
@@ -31,8 +32,8 @@ function parseFlags(argv) {
31
32
  export function parseRunnerFlags(argv) {
32
33
  const values = parseFlags(argv);
33
34
  const flags = {};
34
- if (values.headless)
35
- flags.headless = true;
35
+ if (values.headless !== undefined)
36
+ flags.headless = values.headless;
36
37
  if (values.port !== undefined) {
37
38
  const port = Number(values.port);
38
39
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
package/dist/cli/init.js CHANGED
@@ -1,6 +1,12 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  const PACKAGE = "@itmetsam/samurai-framework";
4
+ /** The version of this package, so a new project asks for the release that scaffolded it */
5
+ function frameworkVersion() {
6
+ const manifest = new URL("../../package.json", import.meta.url);
7
+ return JSON.parse(readFileSync(manifest, "utf8"))
8
+ .version;
9
+ }
4
10
  /** The files `samurai init` writes, by path relative to the project folder */
5
11
  export function scaffold(name) {
6
12
  return {
@@ -10,7 +16,7 @@ export function scaffold(name) {
10
16
  private: true,
11
17
  type: "module",
12
18
  scripts: { test: "samurai run" },
13
- dependencies: { [PACKAGE]: "^0.1.0" },
19
+ dependencies: { [PACKAGE]: `^${frameworkVersion()}` },
14
20
  devDependencies: { tsx: "^4.21.0" },
15
21
  }, null, 2) + "\n",
16
22
  "samurai.config.ts": `import { defineConfig } from "${PACKAGE}";
@@ -1,4 +1,4 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless Run the browser without a window\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\nRun it in the project folder, the one holding samurai.config.ts.\n";
2
2
  export interface Io {
3
3
  out: (text: string) => void;
4
4
  err: (text: string) => void;
package/dist/cli/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { parseCommandLine, parseRunnerFlags, parseRunOverrides } from "./args.js";
2
+ import { parseCommandLine, parseRunnerFlags, parseRunOverrides, } from "./args.js";
3
3
  import { recordSpec } from "../recorder/index.js";
4
4
  import { listTests, runTests } from "../runner/run.js";
5
5
  import { stepToSource } from "../steps/index.js";
@@ -22,7 +22,8 @@ Options for run and list:
22
22
  Options for run:
23
23
  --timeout <ms> Time one test may take
24
24
  --expect-timeout <ms> Time actions and assertions retry
25
- --headless Run the browser without a window
25
+ --headless, --no-headless Run the browser without a window, or with one.
26
+ Default: the config's use.headless, else headless when CI is set
26
27
  --port <n> Browser debugging port (default 9223)
27
28
 
28
29
  Options for record (also --env, --port, --json):
@@ -5,6 +5,8 @@ export interface RunOverrides {
5
5
  environment?: string;
6
6
  timeout?: number;
7
7
  expectTimeout?: number;
8
+ /** Beats the config's `headless`; the command line's `--headless` and `--no-headless` */
9
+ headless?: boolean;
8
10
  }
9
11
  /** Everything about a run that depends on the chosen environment */
10
12
  export interface RunSettings {
@@ -15,12 +17,18 @@ export interface RunSettings {
15
17
  /** Time (ms) actions and assertions retry */
16
18
  expectTimeout: number;
17
19
  variables: Variables;
20
+ /** Whether the browser runs without a window */
21
+ headless: boolean;
18
22
  }
19
23
  export declare class UnknownEnvironmentError extends Error {
20
24
  constructor(message: string);
21
25
  }
22
26
  /** Picks the environment and applies precedence: run override > environment > project > built-in default */
23
- export declare function resolveRunSettings(config: SamuraiTestConfig, overrides?: RunOverrides): RunSettings;
27
+ /** True when the `CI` environment variable says this is a CI run (set, and not "", "0" or "false") */
28
+ export declare function isCI(env?: Partial<NodeJS.ProcessEnv>): boolean;
29
+ export declare function resolveRunSettings(config: SamuraiTestConfig, overrides?: RunOverrides,
30
+ /** Where `CI` is read from */
31
+ env?: Partial<NodeJS.ProcessEnv>): RunSettings;
24
32
  /** Makes `settings` the run's settings; `undefined` ends the run (tests use this to reset) */
25
33
  export declare function setRunSettings(settings: RunSettings | undefined): void;
26
34
  /** The active run's settings, or undefined outside a run (unit and browser tests) */
@@ -9,7 +9,14 @@ export class UnknownEnvironmentError extends Error {
9
9
  }
10
10
  }
11
11
  /** Picks the environment and applies precedence: run override > environment > project > built-in default */
12
- export function resolveRunSettings(config, overrides = {}) {
12
+ /** True when the `CI` environment variable says this is a CI run (set, and not "", "0" or "false") */
13
+ export function isCI(env = process.env) {
14
+ const value = env.CI?.trim().toLowerCase();
15
+ return (value !== undefined && value !== "" && value !== "0" && value !== "false");
16
+ }
17
+ export function resolveRunSettings(config, overrides = {},
18
+ /** Where `CI` is read from */
19
+ env = process.env) {
13
20
  const environments = config.environments ?? {};
14
21
  const names = Object.keys(environments);
15
22
  const available = `Available: ${names.join(", ")}`;
@@ -43,6 +50,10 @@ export function resolveRunSettings(config, overrides = {}) {
43
50
  config.expect?.timeout ??
44
51
  DEFAULT_EXPECT_TIMEOUT,
45
52
  variables: { ...(environment?.variables ?? {}) },
53
+ headless: overrides.headless ??
54
+ environment?.headless ??
55
+ config.use?.headless ??
56
+ isCI(env),
46
57
  };
47
58
  }
48
59
  let active;
@@ -18,6 +18,8 @@ export interface RecordSpecOptions extends ProjectOptions {
18
18
  url?: string;
19
19
  /** The browser's debugging port. @default 9223 */
20
20
  port?: number;
21
+ /** Run the browser without a window. A person can't use it then; for tests and machines without a display. @default the config's `use.headless`, else headless on CI and a window otherwise */
22
+ headless?: boolean;
21
23
  /** Aborting ends the recording, like closing the browser window */
22
24
  signal?: AbortSignal;
23
25
  /** Called after each step is written to the file, with the file's new content */
@@ -66,7 +66,10 @@ export async function recordSpec(options) {
66
66
  if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
67
67
  throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
68
68
  }
69
- const { browser, page } = await Browser.launch("firefox", { port: options.port ?? 9223, headless: false }, options.signal);
69
+ const { browser, page } = await Browser.launch("firefox", {
70
+ port: options.port ?? 9223,
71
+ headless: options.headless ?? settings.headless,
72
+ }, options.signal);
70
73
  const events = [];
71
74
  try {
72
75
  const start = options.url ?? settings.baseURL;
@@ -11,7 +11,7 @@ export async function withProject(options, work) {
11
11
  if (active)
12
12
  throw new Error("A test run is already active in this process");
13
13
  active = true;
14
- const { projectDir: dir, config, dataDir, environment, timeout, expectTimeout, } = options;
14
+ const { projectDir: dir, config, dataDir, environment, timeout, expectTimeout, headless, } = options;
15
15
  const previousDir = projectDir();
16
16
  try {
17
17
  setActiveProject({ dir: dir ?? previousDir, config, dataDir });
@@ -22,6 +22,7 @@ export async function withProject(options, work) {
22
22
  ...(environment !== undefined && { environment }),
23
23
  ...(timeout !== undefined && { timeout }),
24
24
  ...(expectTimeout !== undefined && { expectTimeout }),
25
+ ...(headless !== undefined && { headless }),
25
26
  });
26
27
  return await work(run);
27
28
  }
@@ -13,6 +13,7 @@ import TestReporter, {} from "./reporter.js";
13
13
  import Page from "../browser/page.js";
14
14
  import { takePendingAssertions } from "../assert/expect.js";
15
15
  import { toTestError } from "./test-error.js";
16
+ import { ConnectionClosedError } from "../transport/bidi-connection.js";
16
17
  import { judgePageLogs } from "./page-logs-report.js";
17
18
  /** Whether a test with this full name is selected by the options */
18
19
  export function isSelected(name, { grep, testNames }) {
@@ -97,6 +98,17 @@ export default class TestRunner {
97
98
  async executeTestCase(test, reporter) {
98
99
  logger.verbose("Starting test: %s", test.name);
99
100
  reporter.onTestStart(test);
101
+ // A test ends once, whichever of finishing, failing, timing out, an abort or a closed browser comes first
102
+ let ended = false;
103
+ const finish = (error, logs) => {
104
+ if (ended)
105
+ return;
106
+ ended = true;
107
+ reporter.onTestEnd(test, error, logs);
108
+ };
109
+ let browserClosed = false;
110
+ /** Set once the runner itself closes the browser */
111
+ let closing = false;
100
112
  const selectedBrowser = (await readConfig("browser")) ?? "firefox";
101
113
  const { settings, secrets } = this.run;
102
114
  const timeout = settings.timeout;
@@ -108,7 +120,7 @@ export default class TestRunner {
108
120
  await Promise.race([
109
121
  new Promise((resolve) => {
110
122
  timeoutTimer = setTimeout(() => {
111
- reporter.onTestEnd(test, {
123
+ finish({
112
124
  message: `Test timed out after ${timeout / 1000} seconds`,
113
125
  type: "timeout",
114
126
  });
@@ -118,7 +130,7 @@ export default class TestRunner {
118
130
  onRunAborted = () => {
119
131
  if (timeoutController.signal.aborted)
120
132
  return;
121
- reporter.onTestEnd(test, { message: "Run aborted", type: "error" });
133
+ finish({ message: "Run aborted", type: "error" });
122
134
  timeoutController.abort(new Error("Run aborted"));
123
135
  resolve();
124
136
  };
@@ -130,9 +142,22 @@ export default class TestRunner {
130
142
  try {
131
143
  const launched = await Browser.launch(selectedBrowser, {
132
144
  port: this.options.port ?? 9223,
133
- headless: this.options.headless ?? false,
145
+ headless: this.options.headless ?? settings.headless,
134
146
  }, timeoutController.signal);
135
147
  browser = launched.browser;
148
+ // A person closing the window, or the browser crashing: end the test now instead of waiting for a
149
+ // command to time out
150
+ void launched.browser.exited.then(() => {
151
+ if (ended || closing)
152
+ return;
153
+ browserClosed = true;
154
+ finish({
155
+ message: "The browser was closed while the test was running",
156
+ type: "error",
157
+ });
158
+ timeoutController.abort(new Error("Browser closed"));
159
+ resolve();
160
+ });
136
161
  page = launched.page;
137
162
  const fixtures = {
138
163
  page: launched.page,
@@ -146,7 +171,7 @@ export default class TestRunner {
146
171
  return resolve();
147
172
  const unawaited = takePendingAssertions();
148
173
  if (unawaited.length > 0) {
149
- reporter.onTestEnd(test, {
174
+ finish({
150
175
  message: unawaited
151
176
  .map((matcher) => `expect(locator).${matcher}() was not awaited`)
152
177
  .join("\n"),
@@ -155,13 +180,18 @@ export default class TestRunner {
155
180
  return resolve();
156
181
  }
157
182
  const verdict = await judgeLogs(page, false);
158
- reporter.onTestEnd(test, verdict.error, verdict);
183
+ finish(verdict.error, verdict);
159
184
  resolve();
160
185
  }
161
186
  catch (err) {
162
187
  if (timeoutController.signal.aborted)
163
188
  return resolve();
164
- reporter.onTestEnd(test, toTestError(err), await judgeLogs(page, true));
189
+ finish(browserClosed || err instanceof ConnectionClosedError
190
+ ? {
191
+ message: "The browser was closed while the test was running",
192
+ type: "error",
193
+ }
194
+ : toTestError(err), await judgeLogs(page, true));
165
195
  resolve();
166
196
  }
167
197
  }),
@@ -171,6 +201,7 @@ export default class TestRunner {
171
201
  this.options.signal?.removeEventListener("abort", onRunAborted);
172
202
  takePendingAssertions();
173
203
  logger.verbose("Closing browser for test: %s", test.name);
204
+ closing = true;
174
205
  await browser?.close();
175
206
  }
176
207
  }
@@ -8,9 +8,21 @@ export interface SendOptions {
8
8
  /** Overrides the connector's commandTimeout for this command */
9
9
  timeout?: number;
10
10
  }
11
+ /**
12
+ * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
13
+ * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
14
+ */
15
+ export declare class ConnectionClosedError extends Error {
16
+ readonly cause: unknown;
17
+ constructor(message: string, options?: {
18
+ cause?: unknown;
19
+ });
20
+ }
11
21
  export declare class BiDiConnector {
12
22
  private resolveMap;
13
23
  private currentId;
24
+ /** Set once the socket closed or failed: nothing sent after that gets an answer */
25
+ private closed;
14
26
  private webSocket;
15
27
  private eventEmitter;
16
28
  private commandTimeout;
@@ -19,6 +31,7 @@ export declare class BiDiConnector {
19
31
  static connect(url: string, options?: ConnectorOptions): Promise<BiDiConnector>;
20
32
  private onWebsocketClose;
21
33
  private onWebsocketError;
34
+ private get isClosed();
22
35
  /** Rejects every pending command; `reason` gets the pending method names for its message */
23
36
  private rejectAll;
24
37
  private getId;
@@ -5,15 +5,30 @@ 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
7
  const DEFAULT_COMMAND_TIMEOUT = 30000;
8
+ /**
9
+ * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
10
+ * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
11
+ */
12
+ export class ConnectionClosedError extends Error {
13
+ constructor(message, options) {
14
+ super(message);
15
+ this.name = "ConnectionClosedError";
16
+ this.cause = options?.cause;
17
+ }
18
+ }
8
19
  export class BiDiConnector {
9
20
  constructor(ws, options = {}) {
10
21
  this.resolveMap = new Map();
11
22
  this.currentId = 0;
23
+ /** Set once the socket closed or failed: nothing sent after that gets an answer */
24
+ this.closed = false;
12
25
  this.onWebsocketClose = () => {
13
- this.rejectAll((pending) => new Error(`Websocket closed${pendingSuffix(pending)}`));
26
+ this.closed = true;
27
+ this.rejectAll((pending) => new ConnectionClosedError(`Websocket closed${pendingSuffix(pending)}`));
14
28
  };
15
29
  this.onWebsocketError = (ev) => {
16
- this.rejectAll((pending) => Object.assign(new Error(`Websocket error${pendingSuffix(pending)}`), {
30
+ this.closed = true;
31
+ this.rejectAll((pending) => new ConnectionClosedError(`Websocket error${pendingSuffix(pending)}`, {
17
32
  cause: ev,
18
33
  }));
19
34
  };
@@ -69,6 +84,11 @@ export class BiDiConnector {
69
84
  });
70
85
  });
71
86
  }
87
+ get isClosed() {
88
+ // A socket that is closing or closed (readyState 2 or 3) has already lost the browser
89
+ const state = this.webSocket.readyState;
90
+ return this.closed || (typeof state === "number" && state > 1);
91
+ }
72
92
  /** Rejects every pending command; `reason` gets the pending method names for its message */
73
93
  rejectAll(reason) {
74
94
  const entries = [...this.resolveMap.values()];
@@ -82,6 +102,10 @@ export class BiDiConnector {
82
102
  send(method, params, options = {}) {
83
103
  return new Promise((resolve, reject) => {
84
104
  try {
105
+ if (this.isClosed) {
106
+ // Sending on a closed socket is silently dropped: without this the command waits out its timeout
107
+ throw new ConnectionClosedError(`Websocket closed: cannot send ${method}`);
108
+ }
85
109
  const id = this.getId();
86
110
  const timeout = options.timeout ?? this.commandTimeout;
87
111
  const timer = setTimeout(() => {
@@ -166,7 +190,8 @@ export class BiDiConnector {
166
190
  this.webSocket.removeEventListener("message", this.messageListener);
167
191
  this.webSocket.removeEventListener("close", this.onWebsocketClose);
168
192
  this.webSocket.removeEventListener("error", this.onWebsocketError);
169
- this.rejectAll((pending) => new Error(`BiDiConnector killed${pendingSuffix(pending)}`));
193
+ this.closed = true;
194
+ this.rejectAll((pending) => new ConnectionClosedError(`BiDiConnector killed${pendingSuffix(pending)}`));
170
195
  this.eventEmitter.removeAllListeners();
171
196
  this.webSocket.close();
172
197
  }
@@ -31,6 +31,8 @@ export interface SamuraiEnvironment {
31
31
  /** Overrides the project timeout (ms) for this environment */
32
32
  timeout?: number;
33
33
  expect?: { timeout?: number };
34
+ /** Overrides `use.headless` for this environment, e.g. `true` for a `ci` environment */
35
+ headless?: boolean;
34
36
  /** Test data, used in steps as ${name}. Never put secrets here. */
35
37
  variables?: Record<string, string | number | boolean>;
36
38
  }
@@ -90,6 +92,11 @@ export interface SamuraiTestConfig {
90
92
  * @default { width: 1280, height: 720 }
91
93
  */
92
94
  viewport?: { width: number; height: number } | null;
95
+ /**
96
+ * Run the browser without a window. Needed on machines without a display, such as CI runners.
97
+ * Left out, a run is headless when the `CI` environment variable is set and shows a window otherwise
98
+ */
99
+ headless?: boolean;
93
100
  };
94
101
  /** What to do with the browser's console output and uncaught page exceptions */
95
102
  logs?: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.2.0",
4
- "description": "",
3
+ "version": "0.2.2",
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": {
7
7
  ".": {
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/recorder/index.d.ts",
21
21
  "default": "./dist/recorder/index.js"
22
22
  },
23
+ "./init": {
24
+ "types": "./dist/cli/init.d.ts",
25
+ "default": "./dist/cli/init.js"
26
+ },
23
27
  "./browser": {
24
28
  "types": "./dist/browser/index.d.ts",
25
29
  "default": "./dist/browser/index.js"
@@ -33,7 +37,14 @@
33
37
  "test:browser": "tsx --test --test-concurrency=1 \"src/**/*.browser-test.ts\"",
34
38
  "test:cli": "tsx --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
35
39
  },
36
- "keywords": [],
40
+ "keywords": [
41
+ "testing",
42
+ "browser",
43
+ "e2e",
44
+ "webdriver-bidi",
45
+ "firefox",
46
+ "recorder"
47
+ ],
37
48
  "author": "",
38
49
  "license": "ISC",
39
50
  "type": "module",
@@ -60,5 +71,19 @@
60
71
  "tsx": {
61
72
  "optional": true
62
73
  }
74
+ },
75
+ "repository": {
76
+ "type": "git",
77
+ "url": "git+https://github.com/degrootsam/samurai-framework.git"
78
+ },
79
+ "homepage": "https://github.com/degrootsam/samurai-framework#readme",
80
+ "bugs": {
81
+ "url": "https://github.com/degrootsam/samurai-framework/issues"
82
+ },
83
+ "publishConfig": {
84
+ "access": "public"
85
+ },
86
+ "engines": {
87
+ "node": ">=24"
63
88
  }
64
89
  }
@@ -1 +0,0 @@
1
- export {};
@@ -1,170 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
- import { describe, it } from "node:test";
6
- import { ConsoleReporter } from "./console-reporter.js";
7
- import { initProject, scaffold } from "./init.js";
8
- import { main, USAGE } from "./main.js";
9
- function io() {
10
- const out = [];
11
- const err = [];
12
- const target = {
13
- out: (t) => out.push(t),
14
- err: (t) => err.push(t),
15
- color: false,
16
- env: {},
17
- };
18
- return { target, out: () => out.join(""), err: () => err.join("") };
19
- }
20
- describe("main", () => {
21
- it("prints the usage for --help, -h and help", async () => {
22
- for (const argv of [["--help"], ["-h"], ["help"], ["run", "--help"]]) {
23
- const { target, out } = io();
24
- assert.equal(await main(argv, target), 0);
25
- assert.equal(out(), USAGE);
26
- }
27
- });
28
- it("prints the version", async () => {
29
- const { target, out } = io();
30
- assert.equal(await main(["--version"], target), 0);
31
- assert.match(out(), /^\d+\.\d+\.\d+/);
32
- });
33
- it("exits 2 for an unknown command, with the usage", async () => {
34
- const { target, err } = io();
35
- assert.equal(await main(["bogus"], target), 2);
36
- assert.match(err(), /Unknown command "bogus"/);
37
- assert.match(err(), /Usage:/);
38
- });
39
- it("exits 2 for an unknown flag or a bad value, with a message", async () => {
40
- const flag = io();
41
- assert.equal(await main(["run", "--nope"], flag.target), 2);
42
- assert.match(flag.err(), /nope/);
43
- const port = io();
44
- assert.equal(await main(["run", "--port", "abc"], port.target), 2);
45
- assert.match(port.err(), /--port/);
46
- });
47
- it("exits 2 when the project has no config", async () => {
48
- const dir = mkdtempSync(path.join(tmpdir(), "samurai-cli-"));
49
- const previous = process.cwd();
50
- process.chdir(dir);
51
- try {
52
- const { target, err } = io();
53
- assert.equal(await main(["list"], target), 2);
54
- assert.ok(err().length > 0);
55
- }
56
- finally {
57
- process.chdir(previous);
58
- }
59
- });
60
- it("record needs a spec and a sensible --at", async () => {
61
- const none = io();
62
- assert.equal(await main(["record"], none.target), 2);
63
- assert.match(none.err(), /Which spec/);
64
- const bad = io();
65
- assert.equal(await main(["record", "a.spec.ts", "--at", "x"], bad.target), 2);
66
- assert.match(bad.err(), /--at/);
67
- });
68
- it("record refuses a spec that does not exist unless --new is given", async () => {
69
- const dir = mkdtempSync(path.join(tmpdir(), "samurai-cli-"));
70
- writeFileSync(path.join(dir, "samurai.config.ts"), "export default {};");
71
- const previous = process.cwd();
72
- process.chdir(dir);
73
- try {
74
- const { target, err } = io();
75
- assert.equal(await main(["record", "tests/none.spec.ts"], target), 2);
76
- assert.match(err(), /does not exist/);
77
- }
78
- finally {
79
- process.chdir(previous);
80
- }
81
- });
82
- it("init scaffolds a project and never overwrites", async () => {
83
- const dir = mkdtempSync(path.join(tmpdir(), "samurai-init-"));
84
- writeFileSync(path.join(dir, "package.json"), '{"name":"mine"}');
85
- const { target, out } = io();
86
- assert.equal(await main(["init", dir], target), 0);
87
- assert.match(out(), /kept\s+package\.json/);
88
- assert.match(out(), /created samurai\.config\.ts/);
89
- assert.equal(readFileSync(path.join(dir, "package.json"), "utf8"), '{"name":"mine"}');
90
- for (const file of [
91
- "samurai.config.ts",
92
- "tests/example.spec.ts",
93
- ".env.example",
94
- ".gitignore",
95
- ]) {
96
- assert.ok(existsSync(path.join(dir, file)), file);
97
- }
98
- });
99
- });
100
- describe("scaffold", () => {
101
- it("is an ES module project with a test script", () => {
102
- const manifest = JSON.parse(scaffold("shop")["package.json"]);
103
- assert.equal(manifest.type, "module");
104
- assert.equal(manifest.scripts.test, "samurai run");
105
- assert.equal(manifest.name, "shop");
106
- });
107
- it("names the package after the folder, safely", () => {
108
- const dir = mkdtempSync(path.join(tmpdir(), "My Shop!-"));
109
- initProject(dir);
110
- const manifest = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
111
- assert.match(manifest.name, /^[a-z0-9-]+$/);
112
- });
113
- });
114
- describe("ConsoleReporter", () => {
115
- const summary = {
116
- status: "failed",
117
- duration: 2300,
118
- startTime: 0,
119
- environment: "staging",
120
- tests: [
121
- {
122
- name: "a",
123
- file: "/x/a.spec.ts",
124
- startTime: 0,
125
- status: "success",
126
- duration: 1000,
127
- },
128
- {
129
- name: "b > c",
130
- file: "/x/a.spec.ts",
131
- startTime: 0,
132
- status: "failed",
133
- duration: 1300,
134
- type: "error",
135
- message: "boom\nsecond line",
136
- },
137
- ],
138
- };
139
- it("prints a line per test, the failures in full and a count", () => {
140
- let text = "";
141
- const reporter = new ConsoleReporter({
142
- write: (t) => (text += t),
143
- color: false,
144
- });
145
- reporter.handle({ type: "run-start", environment: "staging", total: 2 });
146
- reporter.handle({ type: "test-start", name: "a", file: "/x/a.spec.ts" });
147
- for (const result of summary.tests)
148
- reporter.handle({
149
- type: "test-end",
150
- name: "name" in result ? result.name : "",
151
- file: "/x/a.spec.ts",
152
- result,
153
- });
154
- reporter.handle({ type: "run-end", summary });
155
- assert.match(text, /Running 2 tests against staging/);
156
- assert.match(text, /✔ a \(1\.0s\)/);
157
- assert.match(text, /✖ b > c \(1\.3s\)/);
158
- assert.match(text, /\n {4}boom\n {4}second line\n/);
159
- assert.match(text, /1 failed, 1 passed \(2\.3s\)/);
160
- assert.ok(!text.includes("\u001b"), "no colour codes when colour is off");
161
- });
162
- it("colours when asked to", () => {
163
- let text = "";
164
- new ConsoleReporter({ write: (t) => (text += t), color: true }).handle({
165
- type: "run-end",
166
- summary,
167
- });
168
- assert.ok(text.includes("\u001b[31m"));
169
- });
170
- });