@c9up/helix-plugin-ream 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C9up
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @c9up/helix-plugin-ream
2
+
3
+ The bridge between [Ream](https://github.com/C9up/ream) and [helix](https://github.com/C9up/helix), its test runner.
4
+
5
+ Ream knows nothing about helix, helix knows nothing about Ream. The plugin that joins them lives here and declares both sides as peers — the same shape as `@japa/plugin-adonisjs`.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add -D @c9up/helix-plugin-ream @c9up/helix
11
+ ```
12
+
13
+ ## Inject a test client
14
+
15
+ ```ts
16
+ // tests/bootstrap.ts
17
+ import { configure } from '@c9up/helix'
18
+ import { apiClient } from '@c9up/helix-plugin-ream'
19
+
20
+ await configure({ plugins: [apiClient({ boot: () => bootApp() })] })
21
+ ```
22
+
23
+ ```ts
24
+ test('health', async ({ client }) => {
25
+ await client.get('/health').assertOk()
26
+ })
27
+ ```
28
+
29
+ The server boots once at `configure()` time, is shared across the run, and closes through `api.cleanup` when the run ends.
30
+
31
+ ## Run the suites declared in the rc file
32
+
33
+ ```ts
34
+ import { runTestsFromRcFile } from '@c9up/helix-plugin-ream/runner'
35
+ ```
36
+
37
+ This is what `ream test` calls.
38
+
39
+ ## What stays in Ream
40
+
41
+ `TestClient`, `createTestClient`, `RequestBuilder` and `ApiResponse` live in `@c9up/ream/testing`. They drive a Ream server over HTTP and owe nothing to the runner, so they need no plugin.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `@c9up/helix-plugin-ream` — the bridge between Ream and its test runner.
3
+ *
4
+ * Ream knows nothing about helix, helix knows nothing about Ream: the plugin
5
+ * that joins them lives here, and declares both sides as peers. The HTTP test
6
+ * client itself (`TestClient`, `createTestClient`) stays in `@c9up/ream/testing`
7
+ * — it drives a Ream server and owes nothing to the runner.
8
+ *
9
+ * // tests/bootstrap.ts
10
+ * import { configure } from '@c9up/helix'
11
+ * import { apiClient } from '@c9up/helix-plugin-ream'
12
+ * await configure({ plugins: [apiClient({ boot: () => bootApp() })] })
13
+ *
14
+ * // a test
15
+ * test('health', async ({ client }) => {
16
+ * await client.get('/health').assertOk()
17
+ * })
18
+ */
19
+ import type { PluginApi } from "@c9up/helix";
20
+ import type { AuthStrategy, RouteManifest } from "@c9up/ream/testing";
21
+ import { TestClient } from "@c9up/ream/testing";
22
+ /** The slice of helix's `PluginApi` this plugin actually uses. */
23
+ export type ClientHost = Pick<PluginApi, "context" | "cleanup">;
24
+ export interface ApiClientConfig {
25
+ /** Boot the app under test on the given port; return the port + a close fn. */
26
+ boot: (port: number) => Promise<{
27
+ port: number;
28
+ close: () => Promise<void> | void;
29
+ }>;
30
+ /** Warden auth strategy for `client.withAuth()`/`asUser()`. */
31
+ auth?: AuthStrategy;
32
+ /** Named-route manifest (`router.namedManifest()`) for `client.visit()`. */
33
+ routes?: RouteManifest;
34
+ }
35
+ /**
36
+ * Injects a booted {@link TestClient} on the test context as `ctx.client`.
37
+ *
38
+ * The server is booted once at `configure()` time, shared across the run, and
39
+ * closed via `api.cleanup` after the run finishes — a proper lifecycle, with no
40
+ * reliance on process exit.
41
+ */
42
+ export declare function apiClient(config: ApiClientConfig): (api: ClientHost) => Promise<void>;
43
+ declare module "@c9up/helix" {
44
+ interface TestContext {
45
+ client: TestClient;
46
+ }
47
+ }
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAU,SAAS,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACtE,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD,kEAAkE;AAClE,MAAM,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;AAEhE,MAAM,WAAW,eAAe;IAC/B,+EAA+E;IAC/E,IAAI,EAAE,CACL,IAAI,EAAE,MAAM,KACR,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;IAClE,+DAA+D;IAC/D,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,eAAe,SACrB,UAAU,KAAG,OAAO,CAAC,IAAI,CAAC,CAarD;AAGD,OAAO,QAAQ,aAAa,CAAC;IAC5B,UAAU,WAAW;QACpB,MAAM,EAAE,UAAU,CAAC;KACnB;CACD"}
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `@c9up/helix-plugin-ream` — the bridge between Ream and its test runner.
3
+ *
4
+ * Ream knows nothing about helix, helix knows nothing about Ream: the plugin
5
+ * that joins them lives here, and declares both sides as peers. The HTTP test
6
+ * client itself (`TestClient`, `createTestClient`) stays in `@c9up/ream/testing`
7
+ * — it drives a Ream server and owes nothing to the runner.
8
+ *
9
+ * // tests/bootstrap.ts
10
+ * import { configure } from '@c9up/helix'
11
+ * import { apiClient } from '@c9up/helix-plugin-ream'
12
+ * await configure({ plugins: [apiClient({ boot: () => bootApp() })] })
13
+ *
14
+ * // a test
15
+ * test('health', async ({ client }) => {
16
+ * await client.get('/health').assertOk()
17
+ * })
18
+ */
19
+ import { TestClient } from "@c9up/ream/testing";
20
+ /**
21
+ * Injects a booted {@link TestClient} on the test context as `ctx.client`.
22
+ *
23
+ * The server is booted once at `configure()` time, shared across the run, and
24
+ * closed via `api.cleanup` after the run finishes — a proper lifecycle, with no
25
+ * reliance on process exit.
26
+ */
27
+ export function apiClient(config) {
28
+ const plugin = async (api) => {
29
+ const client = new TestClient(config.boot, {
30
+ auth: config.auth,
31
+ routes: config.routes,
32
+ });
33
+ await client.boot();
34
+ api.context.macro("client", client);
35
+ api.cleanup(() => client.close());
36
+ };
37
+ // A wider parameter than `PluginApi` stays assignable to `Plugin`, so the
38
+ // plugin declares exactly what it touches and a caller can drive it with
39
+ // nothing more than that.
40
+ return plugin;
41
+ }
42
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAgBhD;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAC,MAAuB;IAChD,MAAM,MAAM,GAAG,KAAK,EAAE,GAAe,EAAiB,EAAE;QACvD,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,IAAI,EAAE;YAC1C,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,MAAM,EAAE,MAAM,CAAC,MAAM;SACrB,CAAC,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QACpC,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;IACnC,CAAC,CAAC;IACF,0EAA0E;IAC1E,yEAAyE;IACzE,0BAA0B;IAC1B,OAAO,MAAuB,CAAC;AAChC,CAAC"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `ream test` — run the suites declared in the rc file.
3
+ *
4
+ * The AdonisJS stratification, kept intact: the FRAMEWORK reads its rc file and
5
+ * hands the suites to the runner, exactly as `@adonisjs/core` reads
6
+ * `adonisrc.ts` and hands them to Japa. The runner itself (helix) knows nothing
7
+ * about ream, and ream owns no test execution — it only translates.
8
+ *
9
+ * // bin/test.ts
10
+ * import { runTestsFromRcFile } from '@c9up/helix-plugin-ream/runner'
11
+ * process.exitCode = await runTestsFromRcFile('./reamrc.ts', {
12
+ * suites: process.argv.slice(2),
13
+ * })
14
+ *
15
+ * Prefer that over `runTests(rc.tests, …)`: a `suites[].configure` callback has
16
+ * to be re-imported in each worker, so the runner needs the module's PATH, not
17
+ * just the object it exported. `runTests` takes it as `configModule`, and
18
+ * REFUSES to run when a suite declares a callback it was given no way to
19
+ * deliver — in Japa a declared `configure` runs, so a run that skipped it would
20
+ * be green in a state its own config does not describe.
21
+ */
22
+ import type { TestsConfig } from "@c9up/ream";
23
+ /** What a caller may override on top of the rc file. */
24
+ export interface RunTestsOptions {
25
+ /** Project root the suites resolve against. Defaults to `process.cwd()`. */
26
+ root?: string;
27
+ /**
28
+ * Suite names to run. Empty (the default) runs every declared suite, in
29
+ * order — the AdonisJS behaviour for `ream test` with no argument.
30
+ */
31
+ suites?: string[];
32
+ /** Concurrent worker processes. */
33
+ threads?: number;
34
+ /** Reporter name, or several (`["spec", "json"]`). */
35
+ reporters?: string[];
36
+ /** Stop at the first failure. */
37
+ bail?: boolean;
38
+ /**
39
+ * The module that declared the suites, so a `suites[].configure` callback can
40
+ * be re-imported in each worker — a function does not cross a process
41
+ * boundary. Set for you by {@link runTestsFromRcFile}.
42
+ */
43
+ configModule?: string;
44
+ /**
45
+ * Flags the worker processes are spawned with. Defaults to this process's own
46
+ * (`process.execArgv`), so the workers load TypeScript through whatever
47
+ * loader the parent was started with — `--import @swc-node/register/esm-register`
48
+ * for `ream test`, tsx for a project that prefers it. Nothing to detect: the
49
+ * workers simply run under the same loader as their parent.
50
+ */
51
+ nodeArgs?: string[];
52
+ }
53
+ /** A suite name that was asked for but is not declared. */
54
+ export declare class UnknownSuiteError extends Error {
55
+ constructor(name: string, declared: string[]);
56
+ }
57
+ /**
58
+ * A suite declares `configure`, but this entry point cannot deliver it.
59
+ *
60
+ * Not a warning: Japa runs a declared `configure`, so carrying on would run the
61
+ * suite in a state its own config does not describe — and a warning is exactly
62
+ * what gets scrolled past in CI.
63
+ */
64
+ export declare class SuiteConfigureUnreachableError extends Error {
65
+ constructor(names: string[]);
66
+ }
67
+ /**
68
+ * The flags the worker processes are spawned with.
69
+ *
70
+ * Split out because it is a decision, not plumbing: the Japa alias loader
71
+ * redirects a package specifier, so it goes in only when a project asks. It
72
+ * rides ALONGSIDE whatever loader is already there — the test files still need
73
+ * theirs to read TypeScript.
74
+ */
75
+ export declare function workerNodeArgs(tests: TestsConfig | undefined, options: RunTestsOptions): string[];
76
+ /**
77
+ * Run the rc file's test suites. Returns the process exit code; the caller
78
+ * decides what to do with it, so this stays usable from a `bin/test.ts`, from a
79
+ * console command, or from a test of its own.
80
+ *
81
+ * `NODE_ENV=test` is set first, then the `.env` files are loaded — so `.env.test`
82
+ * wins over `.env` and `.env.local` is skipped, the AdonisJS test-env rules. It
83
+ * happens HERE, in the process that spawns the workers, so every worker
84
+ * inherits the result: an app gets its test environment without writing a
85
+ * single hook, which is what "loaded automatically" has to mean.
86
+ */
87
+ export declare function runTests(tests: TestsConfig | undefined, options?: RunTestsOptions): Promise<number>;
88
+ /**
89
+ * Load an rc file and run its suites — the one-liner a `bin/test.ts` needs.
90
+ * `rcPath` is resolved against `root`.
91
+ */
92
+ export declare function runTestsFromRcFile(rcPath: string, options?: RunTestsOptions): Promise<number>;
93
+ //# sourceMappingURL=runTests.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runTests.d.ts","sourceRoot":"","sources":["../src/runTests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAmB,WAAW,EAAE,MAAM,YAAY,CAAC;AAG/D,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC/B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,mCAAmC;IACnC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,iCAAiC;IACjC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,2DAA2D;AAC3D,qBAAa,iBAAkB,SAAQ,KAAK;gBAC/B,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;CAQ5C;AAED;;;;;;GAMG;AACH,qBAAa,8BAA+B,SAAQ,KAAK;gBAC5C,KAAK,EAAE,MAAM,EAAE;CAU3B;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC7B,KAAK,EAAE,WAAW,GAAG,SAAS,EAC9B,OAAO,EAAE,eAAe,GACtB,MAAM,EAAE,CAMV;AAkBD;;;;;;;;;;GAUG;AACH,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,WAAW,GAAG,SAAS,EAC9B,OAAO,GAAE,eAAoB,GAC3B,OAAO,CAAC,MAAM,CAAC,CA2GjB;AAgBD;;;GAGG;AACH,wBAAsB,kBAAkB,CACvC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,eAAoB,GAC3B,OAAO,CAAC,MAAM,CAAC,CAmBjB"}
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `ream test` — run the suites declared in the rc file.
3
+ *
4
+ * The AdonisJS stratification, kept intact: the FRAMEWORK reads its rc file and
5
+ * hands the suites to the runner, exactly as `@adonisjs/core` reads
6
+ * `adonisrc.ts` and hands them to Japa. The runner itself (helix) knows nothing
7
+ * about ream, and ream owns no test execution — it only translates.
8
+ *
9
+ * // bin/test.ts
10
+ * import { runTestsFromRcFile } from '@c9up/helix-plugin-ream/runner'
11
+ * process.exitCode = await runTestsFromRcFile('./reamrc.ts', {
12
+ * suites: process.argv.slice(2),
13
+ * })
14
+ *
15
+ * Prefer that over `runTests(rc.tests, …)`: a `suites[].configure` callback has
16
+ * to be re-imported in each worker, so the runner needs the module's PATH, not
17
+ * just the object it exported. `runTests` takes it as `configModule`, and
18
+ * REFUSES to run when a suite declares a callback it was given no way to
19
+ * deliver — in Japa a declared `configure` runs, so a run that skipped it would
20
+ * be green in a state its own config does not describe.
21
+ */
22
+ import path from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+ import { loadEnvFiles } from "@c9up/ream/env";
25
+ /** A suite name that was asked for but is not declared. */
26
+ export class UnknownSuiteError extends Error {
27
+ constructor(name, declared) {
28
+ super(declared.length === 0
29
+ ? `Unknown test suite "${name}": the rc file declares none.`
30
+ : `Unknown test suite "${name}". Declared: ${declared.join(", ")}.`);
31
+ this.name = "UnknownSuiteError";
32
+ }
33
+ }
34
+ /**
35
+ * A suite declares `configure`, but this entry point cannot deliver it.
36
+ *
37
+ * Not a warning: Japa runs a declared `configure`, so carrying on would run the
38
+ * suite in a state its own config does not describe — and a warning is exactly
39
+ * what gets scrolled past in CI.
40
+ */
41
+ export class SuiteConfigureUnreachableError extends Error {
42
+ constructor(names) {
43
+ const plural = names.length > 1;
44
+ super(`Suite${plural ? "s" : ""} ${names.map((name) => `"${name}"`).join(", ")} ` +
45
+ `declare${plural ? "" : "s"} \`configure\`, which has to be re-imported in each ` +
46
+ "worker and therefore needs the rc file PATH, not the object it exported. " +
47
+ "Call runTestsFromRcFile(), or pass `configModule`.");
48
+ this.name = "SuiteConfigureUnreachableError";
49
+ }
50
+ }
51
+ /**
52
+ * The flags the worker processes are spawned with.
53
+ *
54
+ * Split out because it is a decision, not plumbing: the Japa alias loader
55
+ * redirects a package specifier, so it goes in only when a project asks. It
56
+ * rides ALONGSIDE whatever loader is already there — the test files still need
57
+ * theirs to read TypeScript.
58
+ */
59
+ export function workerNodeArgs(tests, options) {
60
+ const args = [...(options.nodeArgs ?? process.execArgv)];
61
+ if (tests?.japaPlugins === true) {
62
+ args.push("--import", import.meta.resolve("@c9up/helix/japa-alias"));
63
+ }
64
+ return args;
65
+ }
66
+ /** The suites to run, in declaration order, for the given selection. */
67
+ function select(declared, asked) {
68
+ if (asked.length === 0)
69
+ return declared;
70
+ const byName = new Map(declared.map((suite) => [suite.name, suite]));
71
+ return asked.map((name) => {
72
+ const suite = byName.get(name);
73
+ if (suite === undefined) {
74
+ throw new UnknownSuiteError(name, [...byName.keys()]);
75
+ }
76
+ return suite;
77
+ });
78
+ }
79
+ /**
80
+ * Run the rc file's test suites. Returns the process exit code; the caller
81
+ * decides what to do with it, so this stays usable from a `bin/test.ts`, from a
82
+ * console command, or from a test of its own.
83
+ *
84
+ * `NODE_ENV=test` is set first, then the `.env` files are loaded — so `.env.test`
85
+ * wins over `.env` and `.env.local` is skipped, the AdonisJS test-env rules. It
86
+ * happens HERE, in the process that spawns the workers, so every worker
87
+ * inherits the result: an app gets its test environment without writing a
88
+ * single hook, which is what "loaded automatically" has to mean.
89
+ */
90
+ export async function runTests(tests, options = {}) {
91
+ process.env.NODE_ENV = "test";
92
+ const root = options.root ?? process.cwd();
93
+ // Skipping `.env.local` is deliberate (and what the Ignitor does for the test
94
+ // environment): a developer's local overrides must not decide what CI runs.
95
+ loadEnvFiles(pathToFileURL(`${root}${path.sep}`), { skipEnvLocal: true });
96
+ const declared = tests?.suites ?? [];
97
+ const selected = select(declared, options.suites ?? []);
98
+ const helix = await import("@c9up/helix/runner");
99
+ // The bootstrap module is the app's, not the runner's — helix imports it in
100
+ // every worker, so a plugin's context extensions exist before the first test
101
+ // declares itself.
102
+ const bootstrap = helix.resolveBootstrap(root, tests?.bootstrap);
103
+ process.env.HELIX_BOOTSTRAP = bootstrap ?? "";
104
+ // Assigned either way: a second call in the same process must not inherit
105
+ // the first one's flag. A plugin reads it back off `api.cliArgs.forceExit`.
106
+ const forceExit = tests?.forceExit === true;
107
+ process.env.HELIX_FORCE_EXIT = forceExit ? "1" : "";
108
+ // Only named when a suite actually declares `configure`: pointing at it makes
109
+ // every worker import the rc file, which a project not using the callback
110
+ // should not pay for.
111
+ const configuring = selected.filter((suite) => typeof suite.configure === "function");
112
+ if (configuring.length > 0 && options.configModule === undefined) {
113
+ // In Japa a declared `configure` RUNS. It cannot here — the callback needs
114
+ // the module's path to be re-imported in each worker, and this entry was
115
+ // handed the exported object. Running anyway would produce a green suite
116
+ // configured differently from what the rc file says, so the run stops.
117
+ throw new SuiteConfigureUnreachableError(configuring.map((suite) => suite.name));
118
+ }
119
+ process.env.HELIX_SUITE_CONFIG =
120
+ configuring.length > 0 && options.configModule !== undefined
121
+ ? options.configModule
122
+ : "";
123
+ process.env.HELIX_SUITE_CONFIG_KEY = "tests.suites";
124
+ const base = {
125
+ root,
126
+ nodeArgs: workerNodeArgs(tests, options),
127
+ threads: options.threads,
128
+ timeoutMs: tests?.timeout,
129
+ reporters: options.reporters,
130
+ bail: options.bail,
131
+ };
132
+ // `runnerHooks` run ONCE around the whole run, here, and the workers skip
133
+ // them — Japa's semantics, and the difference between migrating once and
134
+ // migrating once per test file.
135
+ const dropGlobalHooks = await helix.runGlobalHooks(bootstrap, {
136
+ japaPlugins: tests?.japaPlugins === true,
137
+ });
138
+ // No suites declared: run whatever the project's discovery finds, so an app
139
+ // with a plain `tests/` directory works without declaring anything.
140
+ if (selected.length === 0) {
141
+ try {
142
+ const outcome = await helix.run(base);
143
+ return finish(outcome.exitCode, forceExit);
144
+ }
145
+ finally {
146
+ await dropGlobalHooks();
147
+ }
148
+ }
149
+ const steps = [];
150
+ for (const suite of selected) {
151
+ const files = await helix.resolveSuiteFiles({
152
+ name: suite.name,
153
+ files: suite.files,
154
+ timeout: suite.timeout,
155
+ retries: suite.retries,
156
+ }, root, undefined);
157
+ if (files.length === 0) {
158
+ process.stderr.write(`ream: suite "${suite.name}": no test files\n`);
159
+ continue;
160
+ }
161
+ steps.push({
162
+ env: {
163
+ HELIX_SUITE: suite.name,
164
+ // Empty means unset, so a suite that declares no retries does not
165
+ // inherit the previous suite's.
166
+ HELIX_RETRIES: suite.retries === undefined ? "" : String(suite.retries),
167
+ },
168
+ config: { ...base, files, timeoutMs: suite.timeout ?? tests?.timeout },
169
+ });
170
+ }
171
+ if (steps.length === 0) {
172
+ await dropGlobalHooks();
173
+ return finish(0, forceExit);
174
+ }
175
+ try {
176
+ const outcome = await helix.runSuites(steps, base);
177
+ return finish(outcome.exitCode, forceExit);
178
+ }
179
+ finally {
180
+ await dropGlobalHooks();
181
+ }
182
+ }
183
+ /**
184
+ * Apply `tests.forceExit`. Japa does this inside its own run — sets the exit
185
+ * code, then `process.exit()` — rather than leaving it to the caller, because
186
+ * the whole point is to not wait for the event loop to drain. A run that
187
+ * force-exits never returns here; the value is for every other run.
188
+ */
189
+ function finish(code, forceExit) {
190
+ if (forceExit) {
191
+ process.exitCode = code;
192
+ process.exit();
193
+ }
194
+ return code;
195
+ }
196
+ /**
197
+ * Load an rc file and run its suites — the one-liner a `bin/test.ts` needs.
198
+ * `rcPath` is resolved against `root`.
199
+ */
200
+ export async function runTestsFromRcFile(rcPath, options = {}) {
201
+ const root = options.root ?? process.cwd();
202
+ const absolute = path.isAbsolute(rcPath)
203
+ ? rcPath
204
+ : path.resolve(root, rcPath);
205
+ const imported = await import(pathToFileURL(absolute).href);
206
+ const rc = imported !== null && typeof imported === "object"
207
+ ? (Reflect.get(imported, "default") ?? imported)
208
+ : undefined;
209
+ const tests = rc !== null && typeof rc === "object"
210
+ ? Reflect.get(rc, "tests")
211
+ : undefined;
212
+ return runTests(isTestsConfig(tests) ? tests : undefined, {
213
+ ...options,
214
+ root,
215
+ configModule: absolute,
216
+ });
217
+ }
218
+ /** Narrow an rc file's `tests` value without trusting it. */
219
+ function isTestsConfig(value) {
220
+ return value !== null && typeof value === "object";
221
+ }
222
+ //# sourceMappingURL=runTests.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runTests.js","sourceRoot":"","sources":["../src/runTests.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@c9up/helix-plugin-ream",
3
+ "version": "0.1.0",
4
+ "description": "Helix plugin for Ream — boots a Ream app under test and injects a TestClient on the test context",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./runner": {
15
+ "types": "./dist/runTests.d.ts",
16
+ "import": "./dist/runTests.js"
17
+ }
18
+ },
19
+ "peerDependencies": {
20
+ "@c9up/helix": "^0.1.9",
21
+ "@c9up/ream": "^0.1.27"
22
+ },
23
+ "devDependencies": {
24
+ "@biomejs/biome": "^2.4.10",
25
+ "@c9up/helix": "^0.1.9",
26
+ "@c9up/ream": "^0.1.27",
27
+ "@types/node": "^22.19.15",
28
+ "tsx": "^4",
29
+ "typescript": "^6.0.2",
30
+ "vitest": "^4.1.2"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.0.0"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/C9up/helix-plugin-ream.git"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "test": "vitest run",
51
+ "lint": "biome check src/",
52
+ "test:coverage": "vitest run --coverage",
53
+ "typecheck": "tsc --noEmit"
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `@c9up/helix-plugin-ream` — the bridge between Ream and its test runner.
3
+ *
4
+ * Ream knows nothing about helix, helix knows nothing about Ream: the plugin
5
+ * that joins them lives here, and declares both sides as peers. The HTTP test
6
+ * client itself (`TestClient`, `createTestClient`) stays in `@c9up/ream/testing`
7
+ * — it drives a Ream server and owes nothing to the runner.
8
+ *
9
+ * // tests/bootstrap.ts
10
+ * import { configure } from '@c9up/helix'
11
+ * import { apiClient } from '@c9up/helix-plugin-ream'
12
+ * await configure({ plugins: [apiClient({ boot: () => bootApp() })] })
13
+ *
14
+ * // a test
15
+ * test('health', async ({ client }) => {
16
+ * await client.get('/health').assertOk()
17
+ * })
18
+ */
19
+
20
+ import type { Plugin, PluginApi } from "@c9up/helix";
21
+ import type { AuthStrategy, RouteManifest } from "@c9up/ream/testing";
22
+ import { TestClient } from "@c9up/ream/testing";
23
+
24
+ /** The slice of helix's `PluginApi` this plugin actually uses. */
25
+ export type ClientHost = Pick<PluginApi, "context" | "cleanup">;
26
+
27
+ export interface ApiClientConfig {
28
+ /** Boot the app under test on the given port; return the port + a close fn. */
29
+ boot: (
30
+ port: number,
31
+ ) => Promise<{ port: number; close: () => Promise<void> | void }>;
32
+ /** Warden auth strategy for `client.withAuth()`/`asUser()`. */
33
+ auth?: AuthStrategy;
34
+ /** Named-route manifest (`router.namedManifest()`) for `client.visit()`. */
35
+ routes?: RouteManifest;
36
+ }
37
+
38
+ /**
39
+ * Injects a booted {@link TestClient} on the test context as `ctx.client`.
40
+ *
41
+ * The server is booted once at `configure()` time, shared across the run, and
42
+ * closed via `api.cleanup` after the run finishes — a proper lifecycle, with no
43
+ * reliance on process exit.
44
+ */
45
+ export function apiClient(config: ApiClientConfig) {
46
+ const plugin = async (api: ClientHost): Promise<void> => {
47
+ const client = new TestClient(config.boot, {
48
+ auth: config.auth,
49
+ routes: config.routes,
50
+ });
51
+ await client.boot();
52
+ api.context.macro("client", client);
53
+ api.cleanup(() => client.close());
54
+ };
55
+ // A wider parameter than `PluginApi` stays assignable to `Plugin`, so the
56
+ // plugin declares exactly what it touches and a caller can drive it with
57
+ // nothing more than that.
58
+ return plugin satisfies Plugin;
59
+ }
60
+
61
+ // Typing side of the plugin — importing it augments the helix test context.
62
+ declare module "@c9up/helix" {
63
+ interface TestContext {
64
+ client: TestClient;
65
+ }
66
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * `ream test` — run the suites declared in the rc file.
3
+ *
4
+ * The AdonisJS stratification, kept intact: the FRAMEWORK reads its rc file and
5
+ * hands the suites to the runner, exactly as `@adonisjs/core` reads
6
+ * `adonisrc.ts` and hands them to Japa. The runner itself (helix) knows nothing
7
+ * about ream, and ream owns no test execution — it only translates.
8
+ *
9
+ * // bin/test.ts
10
+ * import { runTestsFromRcFile } from '@c9up/helix-plugin-ream/runner'
11
+ * process.exitCode = await runTestsFromRcFile('./reamrc.ts', {
12
+ * suites: process.argv.slice(2),
13
+ * })
14
+ *
15
+ * Prefer that over `runTests(rc.tests, …)`: a `suites[].configure` callback has
16
+ * to be re-imported in each worker, so the runner needs the module's PATH, not
17
+ * just the object it exported. `runTests` takes it as `configModule`, and
18
+ * REFUSES to run when a suite declares a callback it was given no way to
19
+ * deliver — in Japa a declared `configure` runs, so a run that skipped it would
20
+ * be green in a state its own config does not describe.
21
+ */
22
+
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import type { TestSuiteConfig, TestsConfig } from "@c9up/ream";
26
+ import { loadEnvFiles } from "@c9up/ream/env";
27
+
28
+ /** What a caller may override on top of the rc file. */
29
+ export interface RunTestsOptions {
30
+ /** Project root the suites resolve against. Defaults to `process.cwd()`. */
31
+ root?: string;
32
+ /**
33
+ * Suite names to run. Empty (the default) runs every declared suite, in
34
+ * order — the AdonisJS behaviour for `ream test` with no argument.
35
+ */
36
+ suites?: string[];
37
+ /** Concurrent worker processes. */
38
+ threads?: number;
39
+ /** Reporter name, or several (`["spec", "json"]`). */
40
+ reporters?: string[];
41
+ /** Stop at the first failure. */
42
+ bail?: boolean;
43
+ /**
44
+ * The module that declared the suites, so a `suites[].configure` callback can
45
+ * be re-imported in each worker — a function does not cross a process
46
+ * boundary. Set for you by {@link runTestsFromRcFile}.
47
+ */
48
+ configModule?: string;
49
+ /**
50
+ * Flags the worker processes are spawned with. Defaults to this process's own
51
+ * (`process.execArgv`), so the workers load TypeScript through whatever
52
+ * loader the parent was started with — `--import @swc-node/register/esm-register`
53
+ * for `ream test`, tsx for a project that prefers it. Nothing to detect: the
54
+ * workers simply run under the same loader as their parent.
55
+ */
56
+ nodeArgs?: string[];
57
+ }
58
+
59
+ /** A suite name that was asked for but is not declared. */
60
+ export class UnknownSuiteError extends Error {
61
+ constructor(name: string, declared: string[]) {
62
+ super(
63
+ declared.length === 0
64
+ ? `Unknown test suite "${name}": the rc file declares none.`
65
+ : `Unknown test suite "${name}". Declared: ${declared.join(", ")}.`,
66
+ );
67
+ this.name = "UnknownSuiteError";
68
+ }
69
+ }
70
+
71
+ /**
72
+ * A suite declares `configure`, but this entry point cannot deliver it.
73
+ *
74
+ * Not a warning: Japa runs a declared `configure`, so carrying on would run the
75
+ * suite in a state its own config does not describe — and a warning is exactly
76
+ * what gets scrolled past in CI.
77
+ */
78
+ export class SuiteConfigureUnreachableError extends Error {
79
+ constructor(names: string[]) {
80
+ const plural = names.length > 1;
81
+ super(
82
+ `Suite${plural ? "s" : ""} ${names.map((name) => `"${name}"`).join(", ")} ` +
83
+ `declare${plural ? "" : "s"} \`configure\`, which has to be re-imported in each ` +
84
+ "worker and therefore needs the rc file PATH, not the object it exported. " +
85
+ "Call runTestsFromRcFile(), or pass `configModule`.",
86
+ );
87
+ this.name = "SuiteConfigureUnreachableError";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The flags the worker processes are spawned with.
93
+ *
94
+ * Split out because it is a decision, not plumbing: the Japa alias loader
95
+ * redirects a package specifier, so it goes in only when a project asks. It
96
+ * rides ALONGSIDE whatever loader is already there — the test files still need
97
+ * theirs to read TypeScript.
98
+ */
99
+ export function workerNodeArgs(
100
+ tests: TestsConfig | undefined,
101
+ options: RunTestsOptions,
102
+ ): string[] {
103
+ const args = [...(options.nodeArgs ?? process.execArgv)];
104
+ if (tests?.japaPlugins === true) {
105
+ args.push("--import", import.meta.resolve("@c9up/helix/japa-alias"));
106
+ }
107
+ return args;
108
+ }
109
+
110
+ /** The suites to run, in declaration order, for the given selection. */
111
+ function select(
112
+ declared: TestSuiteConfig[],
113
+ asked: string[],
114
+ ): TestSuiteConfig[] {
115
+ if (asked.length === 0) return declared;
116
+ const byName = new Map(declared.map((suite) => [suite.name, suite]));
117
+ return asked.map((name) => {
118
+ const suite = byName.get(name);
119
+ if (suite === undefined) {
120
+ throw new UnknownSuiteError(name, [...byName.keys()]);
121
+ }
122
+ return suite;
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Run the rc file's test suites. Returns the process exit code; the caller
128
+ * decides what to do with it, so this stays usable from a `bin/test.ts`, from a
129
+ * console command, or from a test of its own.
130
+ *
131
+ * `NODE_ENV=test` is set first, then the `.env` files are loaded — so `.env.test`
132
+ * wins over `.env` and `.env.local` is skipped, the AdonisJS test-env rules. It
133
+ * happens HERE, in the process that spawns the workers, so every worker
134
+ * inherits the result: an app gets its test environment without writing a
135
+ * single hook, which is what "loaded automatically" has to mean.
136
+ */
137
+ export async function runTests(
138
+ tests: TestsConfig | undefined,
139
+ options: RunTestsOptions = {},
140
+ ): Promise<number> {
141
+ process.env.NODE_ENV = "test";
142
+
143
+ const root = options.root ?? process.cwd();
144
+ // Skipping `.env.local` is deliberate (and what the Ignitor does for the test
145
+ // environment): a developer's local overrides must not decide what CI runs.
146
+ loadEnvFiles(pathToFileURL(`${root}${path.sep}`), { skipEnvLocal: true });
147
+ const declared = tests?.suites ?? [];
148
+ const selected = select(declared, options.suites ?? []);
149
+
150
+ const helix = await import("@c9up/helix/runner");
151
+
152
+ // The bootstrap module is the app's, not the runner's — helix imports it in
153
+ // every worker, so a plugin's context extensions exist before the first test
154
+ // declares itself.
155
+ const bootstrap = helix.resolveBootstrap(root, tests?.bootstrap);
156
+ process.env.HELIX_BOOTSTRAP = bootstrap ?? "";
157
+ // Assigned either way: a second call in the same process must not inherit
158
+ // the first one's flag. A plugin reads it back off `api.cliArgs.forceExit`.
159
+ const forceExit = tests?.forceExit === true;
160
+ process.env.HELIX_FORCE_EXIT = forceExit ? "1" : "";
161
+
162
+ // Only named when a suite actually declares `configure`: pointing at it makes
163
+ // every worker import the rc file, which a project not using the callback
164
+ // should not pay for.
165
+ const configuring = selected.filter(
166
+ (suite) => typeof suite.configure === "function",
167
+ );
168
+ if (configuring.length > 0 && options.configModule === undefined) {
169
+ // In Japa a declared `configure` RUNS. It cannot here — the callback needs
170
+ // the module's path to be re-imported in each worker, and this entry was
171
+ // handed the exported object. Running anyway would produce a green suite
172
+ // configured differently from what the rc file says, so the run stops.
173
+ throw new SuiteConfigureUnreachableError(
174
+ configuring.map((suite) => suite.name),
175
+ );
176
+ }
177
+ process.env.HELIX_SUITE_CONFIG =
178
+ configuring.length > 0 && options.configModule !== undefined
179
+ ? options.configModule
180
+ : "";
181
+ process.env.HELIX_SUITE_CONFIG_KEY = "tests.suites";
182
+
183
+ const base = {
184
+ root,
185
+ nodeArgs: workerNodeArgs(tests, options),
186
+ threads: options.threads,
187
+ timeoutMs: tests?.timeout,
188
+ reporters: options.reporters,
189
+ bail: options.bail,
190
+ };
191
+
192
+ // `runnerHooks` run ONCE around the whole run, here, and the workers skip
193
+ // them — Japa's semantics, and the difference between migrating once and
194
+ // migrating once per test file.
195
+ const dropGlobalHooks = await helix.runGlobalHooks(bootstrap, {
196
+ japaPlugins: tests?.japaPlugins === true,
197
+ });
198
+
199
+ // No suites declared: run whatever the project's discovery finds, so an app
200
+ // with a plain `tests/` directory works without declaring anything.
201
+ if (selected.length === 0) {
202
+ try {
203
+ const outcome = await helix.run(base);
204
+ return finish(outcome.exitCode, forceExit);
205
+ } finally {
206
+ await dropGlobalHooks();
207
+ }
208
+ }
209
+
210
+ const steps = [];
211
+ for (const suite of selected) {
212
+ const files = await helix.resolveSuiteFiles(
213
+ {
214
+ name: suite.name,
215
+ files: suite.files,
216
+ timeout: suite.timeout,
217
+ retries: suite.retries,
218
+ },
219
+ root,
220
+ undefined,
221
+ );
222
+ if (files.length === 0) {
223
+ process.stderr.write(`ream: suite "${suite.name}": no test files\n`);
224
+ continue;
225
+ }
226
+ steps.push({
227
+ env: {
228
+ HELIX_SUITE: suite.name,
229
+ // Empty means unset, so a suite that declares no retries does not
230
+ // inherit the previous suite's.
231
+ HELIX_RETRIES: suite.retries === undefined ? "" : String(suite.retries),
232
+ },
233
+ config: { ...base, files, timeoutMs: suite.timeout ?? tests?.timeout },
234
+ });
235
+ }
236
+ if (steps.length === 0) {
237
+ await dropGlobalHooks();
238
+ return finish(0, forceExit);
239
+ }
240
+
241
+ try {
242
+ const outcome = await helix.runSuites(steps, base);
243
+ return finish(outcome.exitCode, forceExit);
244
+ } finally {
245
+ await dropGlobalHooks();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Apply `tests.forceExit`. Japa does this inside its own run — sets the exit
251
+ * code, then `process.exit()` — rather than leaving it to the caller, because
252
+ * the whole point is to not wait for the event loop to drain. A run that
253
+ * force-exits never returns here; the value is for every other run.
254
+ */
255
+ function finish(code: number, forceExit: boolean): number {
256
+ if (forceExit) {
257
+ process.exitCode = code;
258
+ process.exit();
259
+ }
260
+ return code;
261
+ }
262
+
263
+ /**
264
+ * Load an rc file and run its suites — the one-liner a `bin/test.ts` needs.
265
+ * `rcPath` is resolved against `root`.
266
+ */
267
+ export async function runTestsFromRcFile(
268
+ rcPath: string,
269
+ options: RunTestsOptions = {},
270
+ ): Promise<number> {
271
+ const root = options.root ?? process.cwd();
272
+ const absolute = path.isAbsolute(rcPath)
273
+ ? rcPath
274
+ : path.resolve(root, rcPath);
275
+ const imported: unknown = await import(pathToFileURL(absolute).href);
276
+ const rc =
277
+ imported !== null && typeof imported === "object"
278
+ ? (Reflect.get(imported, "default") ?? imported)
279
+ : undefined;
280
+ const tests =
281
+ rc !== null && typeof rc === "object"
282
+ ? Reflect.get(rc, "tests")
283
+ : undefined;
284
+ return runTests(isTestsConfig(tests) ? tests : undefined, {
285
+ ...options,
286
+ root,
287
+ configModule: absolute,
288
+ });
289
+ }
290
+
291
+ /** Narrow an rc file's `tests` value without trusting it. */
292
+ function isTestsConfig(value: unknown): value is TestsConfig {
293
+ return value !== null && typeof value === "object";
294
+ }