@sanoma-ai/workflows 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +544 -2
  3. package/dist/apply-steps.d.ts +6 -0
  4. package/dist/apply-steps.js +93 -0
  5. package/dist/apply-steps.js.map +1 -0
  6. package/dist/apply.d.ts +197 -0
  7. package/dist/apply.js +470 -0
  8. package/dist/apply.js.map +1 -0
  9. package/dist/approvals.d.ts +46 -0
  10. package/dist/approvals.js +111 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/ast.d.ts +57 -0
  13. package/dist/ast.js +81 -0
  14. package/dist/ast.js.map +1 -0
  15. package/dist/call.d.ts +37 -0
  16. package/dist/call.js +417 -0
  17. package/dist/call.js.map +1 -0
  18. package/dist/client.d.ts +203 -0
  19. package/dist/client.js +457 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +126 -0
  22. package/dist/config.js +264 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/credentials.d.ts +55 -0
  25. package/dist/credentials.js +261 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/cucumber.d.ts +64 -0
  28. package/dist/cucumber.js +158 -0
  29. package/dist/cucumber.js.map +1 -0
  30. package/dist/datafile.d.ts +71 -0
  31. package/dist/datafile.js +380 -0
  32. package/dist/datafile.js.map +1 -0
  33. package/dist/db.d.ts +58 -0
  34. package/dist/db.js +179 -0
  35. package/dist/db.js.map +1 -0
  36. package/dist/define.d.ts +205 -0
  37. package/dist/define.js +103 -0
  38. package/dist/define.js.map +1 -0
  39. package/dist/describe.d.ts +141 -0
  40. package/dist/describe.js +135 -0
  41. package/dist/describe.js.map +1 -0
  42. package/dist/drift.d.ts +127 -0
  43. package/dist/drift.js +167 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/env.d.ts +49 -0
  46. package/dist/env.js +83 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +88 -0
  49. package/dist/errors.js +148 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/expectations.d.ts +26 -0
  52. package/dist/expectations.js +162 -0
  53. package/dist/expectations.js.map +1 -0
  54. package/dist/fake.d.ts +55 -0
  55. package/dist/fake.js +87 -0
  56. package/dist/fake.js.map +1 -0
  57. package/dist/fill.d.ts +9 -0
  58. package/dist/fill.js +17 -0
  59. package/dist/fill.js.map +1 -0
  60. package/dist/host.d.ts +56 -0
  61. package/dist/host.js +45 -0
  62. package/dist/host.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +29 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/ledger.d.ts +217 -0
  67. package/dist/ledger.js +251 -0
  68. package/dist/ledger.js.map +1 -0
  69. package/dist/lint.d.ts +32 -0
  70. package/dist/lint.js +168 -0
  71. package/dist/lint.js.map +1 -0
  72. package/dist/lockfile.d.ts +36 -0
  73. package/dist/lockfile.js +69 -0
  74. package/dist/lockfile.js.map +1 -0
  75. package/dist/log.d.ts +24 -0
  76. package/dist/log.js +83 -0
  77. package/dist/log.js.map +1 -0
  78. package/dist/op.d.ts +260 -0
  79. package/dist/op.js +188 -0
  80. package/dist/op.js.map +1 -0
  81. package/dist/outline.d.ts +179 -0
  82. package/dist/outline.js +506 -0
  83. package/dist/outline.js.map +1 -0
  84. package/dist/paths.d.ts +11 -0
  85. package/dist/paths.js +30 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/plugin.d.ts +28 -0
  88. package/dist/plugin.js +25 -0
  89. package/dist/plugin.js.map +1 -0
  90. package/dist/policy.d.ts +114 -0
  91. package/dist/policy.js +60 -0
  92. package/dist/policy.js.map +1 -0
  93. package/dist/resource.d.ts +378 -0
  94. package/dist/resource.js +430 -0
  95. package/dist/resource.js.map +1 -0
  96. package/dist/resources.d.ts +77 -0
  97. package/dist/resources.js +244 -0
  98. package/dist/resources.js.map +1 -0
  99. package/dist/run.d.ts +101 -0
  100. package/dist/run.js +7 -0
  101. package/dist/run.js.map +1 -0
  102. package/dist/sandbox.d.ts +33 -0
  103. package/dist/sandbox.js +127 -0
  104. package/dist/sandbox.js.map +1 -0
  105. package/dist/scenario.d.ts +221 -0
  106. package/dist/scenario.js +1104 -0
  107. package/dist/scenario.js.map +1 -0
  108. package/dist/scenarios.d.ts +55 -0
  109. package/dist/scenarios.js +71 -0
  110. package/dist/scenarios.js.map +1 -0
  111. package/dist/secret.d.ts +42 -0
  112. package/dist/secret.js +58 -0
  113. package/dist/secret.js.map +1 -0
  114. package/dist/shared.d.ts +69 -0
  115. package/dist/shared.js +84 -0
  116. package/dist/shared.js.map +1 -0
  117. package/dist/source.d.ts +58 -0
  118. package/dist/source.js +54 -0
  119. package/dist/source.js.map +1 -0
  120. package/dist/steps.d.ts +76 -0
  121. package/dist/steps.js +23 -0
  122. package/dist/steps.js.map +1 -0
  123. package/dist/version.d.ts +29 -0
  124. package/dist/version.js +76 -0
  125. package/dist/version.js.map +1 -0
  126. package/dist/worker.d.ts +37 -0
  127. package/dist/worker.js +228 -0
  128. package/dist/worker.js.map +1 -0
  129. package/dist/writer.d.ts +35 -0
  130. package/dist/writer.js +201 -0
  131. package/dist/writer.js.map +1 -0
  132. package/oxlint.json +128 -0
  133. package/package.json +96 -3
  134. package/src/apply-steps.ts +110 -0
  135. package/src/apply.ts +626 -0
  136. package/src/approvals.ts +157 -0
  137. package/src/ast.ts +115 -0
  138. package/src/call.ts +483 -0
  139. package/src/client.ts +604 -0
  140. package/src/config.ts +404 -0
  141. package/src/credentials.ts +296 -0
  142. package/src/cucumber.ts +230 -0
  143. package/src/datafile.ts +481 -0
  144. package/src/db.ts +197 -0
  145. package/src/define.ts +285 -0
  146. package/src/describe.ts +282 -0
  147. package/src/drift.ts +259 -0
  148. package/src/env.ts +122 -0
  149. package/src/errors.ts +202 -0
  150. package/src/expectations.ts +191 -0
  151. package/src/fake.ts +156 -0
  152. package/src/fill.ts +24 -0
  153. package/src/host.ts +96 -0
  154. package/src/index.ts +143 -0
  155. package/src/ledger.ts +419 -0
  156. package/src/lint.ts +208 -0
  157. package/src/lockfile.ts +95 -0
  158. package/src/log.ts +82 -0
  159. package/src/op.ts +402 -0
  160. package/src/outline.ts +620 -0
  161. package/src/paths.ts +32 -0
  162. package/src/plugin.ts +37 -0
  163. package/src/policy.ts +139 -0
  164. package/src/resource.ts +670 -0
  165. package/src/resources.ts +311 -0
  166. package/src/run.ts +106 -0
  167. package/src/sandbox.ts +161 -0
  168. package/src/scenario.ts +1365 -0
  169. package/src/scenarios.ts +111 -0
  170. package/src/secret.ts +92 -0
  171. package/src/shared.ts +126 -0
  172. package/src/source.ts +82 -0
  173. package/src/steps.ts +90 -0
  174. package/src/version.ts +86 -0
  175. package/src/worker.ts +264 -0
  176. package/src/writer.ts +225 -0
@@ -0,0 +1,296 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { Client } from "pg";
4
+ import { credentialsOf } from "./env.ts";
5
+ import { CREDENTIALS_TABLE as TABLE, type Db, prepareDatabase } from "./db.ts";
6
+ import { info, warn } from "./log.ts";
7
+ import type { Driver } from "./op.ts";
8
+ import type { Secrets } from "./secret.ts";
9
+ import { errorMessage } from "./shared.ts";
10
+
11
+ // Where the credentials set from the app live: one table in the config's database, beside DBOS's
12
+ // own (made by `prepareDatabase` in db.ts). A value is read here only, by the worker to load it
13
+ // into its environment or hand it to a driver (`storedSecrets`) and by the client to check it,
14
+ // and leaves none of them. The write (`storeCredential`, `ensureCredential`) and the read
15
+ // (`storedCredentials`) are the only places that touch `value`, which holds it encrypted
16
+ // (`encrypt`) under `SANOMA_SECRETS_KEY`.
17
+
18
+ /** Where a change is announced, with the variable's name as the payload. */
19
+ const CHANNEL = "sanoma_credentials";
20
+
21
+ /** The variable holding the key the stored values are encrypted with: 32 bytes, base64. */
22
+ export const SECRETS_KEY = "SANOMA_SECRETS_KEY";
23
+
24
+ /** What starts an encrypted value; a value without it is a plaintext one stored before encryption, read as it is. */
25
+ const V1 = "v1:";
26
+ const CIPHER = "aes-256-gcm";
27
+ const TAG_BYTES = 16;
28
+
29
+ /** The key from the environment, or an error naming the variable. */
30
+ function secretsKey(): Buffer {
31
+ const text = process.env[SECRETS_KEY];
32
+ if (!text) {
33
+ throw new Error(
34
+ `${SECRETS_KEY} is not set: it encrypts the stored credentials; set it to 32 random bytes, base64 (\`openssl rand -base64 32\`)`,
35
+ );
36
+ }
37
+ const key = Buffer.from(text, "base64");
38
+ if (key.length !== 32) throw new Error(`${SECRETS_KEY} must be 32 bytes, base64; it is ${key.length} bytes`);
39
+ return key;
40
+ }
41
+
42
+ /**
43
+ * `name`'s value encrypted for the table: `v1:<iv, base64>:<ciphertext and tag, base64>`,
44
+ * AES-256-GCM, with the name as associated data, so a value moved to another row does not decrypt.
45
+ */
46
+ export function encrypt(name: string, value: string): string {
47
+ const iv = randomBytes(12);
48
+ const cipher = createCipheriv(CIPHER, secretsKey(), iv).setAAD(Buffer.from(name));
49
+ const sealed = Buffer.concat([cipher.update(value, "utf8"), cipher.final(), cipher.getAuthTag()]);
50
+ return `${V1}${iv.toString("base64")}:${sealed.toString("base64")}`;
51
+ }
52
+
53
+ /** The value `name`'s row holds: decrypted, or a plaintext one as it is (it is encrypted on its next write). */
54
+ export function decrypt(name: string, stored: string): string {
55
+ if (!stored.startsWith(V1)) return stored;
56
+ const key = secretsKey();
57
+ const [iv = "", body = ""] = stored.slice(V1.length).split(":");
58
+ const sealed = Buffer.from(body, "base64");
59
+ try {
60
+ const decipher = createDecipheriv(CIPHER, key, Buffer.from(iv, "base64")).setAAD(Buffer.from(name));
61
+ decipher.setAuthTag(sealed.subarray(-TAG_BYTES));
62
+ return Buffer.concat([decipher.update(sealed.subarray(0, -TAG_BYTES)), decipher.final()]).toString("utf8");
63
+ } catch (err) {
64
+ throw new Error(
65
+ `the stored ${name} cannot be decrypted: ${SECRETS_KEY} is not the key it was stored with, or the row was not stored as ${name}`,
66
+ { cause: err },
67
+ );
68
+ }
69
+ }
70
+
71
+ /** The stored rows of these names, values included: for loading into an environment or checking, never for showing. */
72
+ export async function storedCredentials(
73
+ db: Db,
74
+ names: readonly string[],
75
+ ): Promise<Map<string, { value: string; setBy: string; setAt: string }>> {
76
+ const { rows } = await db.query<{ name: string; value: string; set_by: string; set_at: Date }>(
77
+ `SELECT name, value, set_by, set_at FROM ${TABLE} WHERE name = ANY($1)`,
78
+ [names],
79
+ );
80
+ return new Map(
81
+ rows.map((r) => [r.name, { value: decrypt(r.name, r.value), setBy: r.set_by, setAt: r.set_at.toISOString() }]),
82
+ );
83
+ }
84
+
85
+ /** Upserts the value and announces it, in one statement, so a worker never hears of a change it cannot read. */
86
+ export async function storeCredential(
87
+ db: Db,
88
+ { name, value, setBy }: { name: string; value: string; setBy: string },
89
+ ): Promise<void> {
90
+ await db.query(
91
+ `WITH stored AS (INSERT INTO ${TABLE} (name, value, set_by) VALUES ($1, $2, $3) ` +
92
+ "ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, set_by = EXCLUDED.set_by, set_at = now() " +
93
+ `RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM stored`,
94
+ [name, encrypt(name, value), setBy],
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The stored value of `name`; when there is none, `make()`'s, stored first and announced. Of two
100
+ * at once, the first stored wins, and both return it.
101
+ */
102
+ export async function ensureCredential(
103
+ db: Db,
104
+ { name, make, setBy }: { name: string; make: () => string; setBy: string },
105
+ ): Promise<string> {
106
+ const stored = (await storedCredentials(db, [name])).get(name);
107
+ if (stored) return stored.value;
108
+ await db.query(
109
+ `WITH stored AS (INSERT INTO ${TABLE} (name, value, set_by) VALUES ($1, $2, $3) ON CONFLICT (name) DO NOTHING ` +
110
+ `RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM stored`,
111
+ [name, encrypt(name, make()), setBy],
112
+ );
113
+ return (await storedCredentials(db, [name])).get(name)!.value;
114
+ }
115
+
116
+ /**
117
+ * The secrets store a live driver is given (`CallContext.secrets`): a variable the environment
118
+ * sets wins, as it does for a driver's `env`; else the stored value. `ensure` stores what it
119
+ * makes as `setBy`.
120
+ */
121
+ export function storedSecrets(db: () => Promise<Db>, setBy: string): Secrets {
122
+ return {
123
+ get: async (name) => process.env[name] || (await storedCredentials(await db(), [name])).get(name)?.value,
124
+ ensure: async (name, make) => process.env[name] || ensureCredential(await db(), { name, make, setBy }),
125
+ };
126
+ }
127
+
128
+ /** Deletes the row, announcing it when there was one. */
129
+ export async function deleteCredential(db: Db, name: string): Promise<void> {
130
+ await db.query(
131
+ `WITH gone AS (DELETE FROM ${TABLE} WHERE name = $1 RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM gone`,
132
+ [name],
133
+ );
134
+ }
135
+
136
+ /** Stops a worker's credentials listener. */
137
+ export interface CredentialsWatch {
138
+ close(): Promise<void>;
139
+ }
140
+
141
+ /** The longest wait between two attempts to reconnect the listener, and between two warnings that one failed. */
142
+ const RETRY_MAX_MS = 30_000;
143
+
144
+ /**
145
+ * Loads the stored values of the declared variables the environment does not set into
146
+ * `process.env`, then keeps them current: on each change announced, and on reconnecting, it
147
+ * loads them again, so a value set replaces the one before and a value cleared is deleted. A
148
+ * name the environment set when this started is never touched, and the names it did set are
149
+ * deleted on `close`. Logs, per vendor, how many variables each load changed, never a value, and
150
+ * warns of a reloaded value its schema refuses. A listener that drops reconnects, waiting longer
151
+ * after each failure, up to 30 s, until it is closed. With no such variable, it connects to nothing.
152
+ */
153
+ export async function watchCredentials(
154
+ databaseUrl: string,
155
+ drivers: ReadonlyMap<string, Driver>,
156
+ ): Promise<CredentialsWatch> {
157
+ const vendors = new Map<string, string>();
158
+ for (const [vendor, list] of credentialsOf(drivers, () => undefined)) {
159
+ for (const { name } of list) if (!process.env[name] && !vendors.has(name)) vendors.set(name, vendor);
160
+ }
161
+ const names = [...vendors.keys()];
162
+ if (!names.length) return { close: async () => undefined };
163
+ /** The listener's connection, from when it connects until it drops or closes. */
164
+ let db: Client | undefined;
165
+ let closed = false;
166
+ let reconnecting = false;
167
+ // One load at a time, so close() can wait for it; the changes announced during one make one more.
168
+ let loading: Promise<void> | undefined;
169
+ let stale = false;
170
+
171
+ async function load(first: boolean) {
172
+ const conn = db;
173
+ if (closed || !conn) return;
174
+ const rows = await storedCredentials(conn, names);
175
+ // Nothing touches the environment once closed: a worker or an app started after may own it.
176
+ if (closed) return;
177
+ const changed = names.filter((name) => rows.get(name)?.value !== process.env[name]);
178
+ const counts = new Map<string, number>();
179
+ for (const name of changed) {
180
+ const value = rows.get(name)?.value;
181
+ if (value === undefined) delete process.env[name];
182
+ else process.env[name] = value;
183
+ const vendor = vendors.get(name)!;
184
+ counts.set(vendor, (counts.get(vendor) ?? 0) + 1);
185
+ }
186
+ const verb = first ? "loaded" : "reloaded";
187
+ for (const [vendor, n] of counts) info(`credentials ${verb} for ${vendor} (${n} variable${n === 1 ? "" : "s"})`);
188
+ // At start, startWorker warns of an invalid one, naming it.
189
+ if (first) return;
190
+ for (const list of credentialsOf(drivers, (name) =>
191
+ changed.includes(name) ? process.env[name] : undefined,
192
+ ).values()) {
193
+ for (const c of list) {
194
+ if (c.status === "invalid")
195
+ warn(`the stored ${c.name} is invalid (${c.problem}): calls that read it will fail`);
196
+ }
197
+ }
198
+ }
199
+
200
+ function reload(first = false): Promise<void> {
201
+ if (loading) {
202
+ stale = true;
203
+ return loading;
204
+ }
205
+ loading = (async () => {
206
+ try {
207
+ do {
208
+ stale = false;
209
+ await load(first);
210
+ first = false;
211
+ } while (stale);
212
+ } finally {
213
+ loading = undefined;
214
+ }
215
+ })();
216
+ return loading;
217
+ }
218
+
219
+ async function open(first: boolean) {
220
+ const conn = new Client({ connectionString: databaseUrl, keepAlive: true, connectionTimeoutMillis: 10_000 });
221
+ // Before anything can fail: an error nobody listens for ends the process.
222
+ conn.on("error", (err) => lost(conn, err));
223
+ // Before LISTEN, so no change announced from then on is missed.
224
+ conn.on("notification", () => {
225
+ if (!closed && conn === db) {
226
+ reload().catch((err: unknown) => warn(`could not reload the credentials: ${errorMessage(err)}`));
227
+ }
228
+ });
229
+ try {
230
+ if (first) await prepareDatabase(databaseUrl);
231
+ await conn.connect();
232
+ if (closed) {
233
+ await conn.end();
234
+ return;
235
+ }
236
+ db = conn;
237
+ await conn.query(`LISTEN ${CHANNEL}`);
238
+ await reload(first);
239
+ } catch (err) {
240
+ if (db === conn) db = undefined;
241
+ await conn.end().catch(() => undefined);
242
+ throw err;
243
+ }
244
+ }
245
+
246
+ /** A connection that dropped: the one listening is replaced; one still connecting fails its own open. */
247
+ function lost(conn: Client, err: unknown) {
248
+ if (closed || conn !== db) return;
249
+ db = undefined;
250
+ void conn.end().catch(() => undefined);
251
+ warn(`the credentials listener failed (${errorMessage(err)}); reconnecting`);
252
+ void reconnect();
253
+ }
254
+
255
+ async function reconnect() {
256
+ if (reconnecting) return;
257
+ reconnecting = true;
258
+ let wait = 1_000;
259
+ let warnedAt = 0;
260
+ try {
261
+ for (;;) {
262
+ await sleep(wait, undefined, { ref: false });
263
+ if (closed) return;
264
+ try {
265
+ await open(false);
266
+ return;
267
+ } catch (err) {
268
+ if (Date.now() - warnedAt >= RETRY_MAX_MS) {
269
+ warnedAt = Date.now();
270
+ warn(`could not reconnect the credentials listener (${errorMessage(err)}); trying again`);
271
+ }
272
+ wait = Math.min(wait * 2, RETRY_MAX_MS);
273
+ }
274
+ }
275
+ } finally {
276
+ reconnecting = false;
277
+ }
278
+ }
279
+
280
+ try {
281
+ await open(true);
282
+ } catch (err) {
283
+ closed = true;
284
+ throw err;
285
+ }
286
+ return {
287
+ async close() {
288
+ closed = true;
289
+ await loading?.catch(() => undefined);
290
+ for (const name of names) delete process.env[name];
291
+ const conn = db;
292
+ db = undefined;
293
+ await conn?.end();
294
+ },
295
+ };
296
+ }
@@ -0,0 +1,230 @@
1
+ import { Writable } from "node:stream";
2
+ import { supportCodeLibraryBuilder } from "@cucumber/cucumber";
3
+ import { type IRunOptions, type ISupportCodeLibrary, runCucumber } from "@cucumber/cucumber/api";
4
+ import { type CucumberExpression, type Node, NodeType } from "@cucumber/cucumber-expressions";
5
+ import { IdGenerator, type Pickle, type PickleStep, TestStepResultStatus } from "@cucumber/messages";
6
+ import { Query } from "@cucumber/query";
7
+
8
+ /*
9
+ * Cucumber, as Sanoma runs it: a support code library built from data (never from files it
10
+ * imports), and runs of feature files in this process, one after another, read back through
11
+ * Cucumber's own `Query` of its messages. Internal: `scenario.ts` says what the steps mean.
12
+ * Server-side only.
13
+ *
14
+ * Each step definition's code hands the step to the run's World (`SanomaWorld.step`), so one
15
+ * library serves every kind of run (compiling a scenario, checking a run, an operation's own
16
+ * test): only the World differs, and each run makes its own.
17
+ */
18
+
19
+ /** A parameter type the steps' expressions use, as Cucumber's `defineParameterType` takes it. */
20
+ export interface ParameterTypeSpec {
21
+ name: string;
22
+ regexp: RegExp | string | readonly (RegExp | string)[];
23
+ transformer: (...match: string[]) => unknown;
24
+ }
25
+
26
+ /** What one run does with its scenarios' steps. */
27
+ export interface SanomaWorld {
28
+ /** Called before the scenario's first step. */
29
+ begin(pickle: Pickle): void;
30
+ /** Runs one step: `expression` is the expression it matched, `values` its parameters. */
31
+ step(expression: string, values: unknown[], pickleStep: PickleStep): unknown;
32
+ }
33
+
34
+ /**
35
+ * Builds a support code library: the parameter types, then one step definition per expression,
36
+ * each handing its step to the World. Cucumber keeps one builder per process, so the library is
37
+ * built here, by hand, in one synchronous stretch (`reset`, register, `finalize`): building it
38
+ * again in the same process gives the same definitions, where a second `loadSupport` would
39
+ * import nothing new and find none.
40
+ */
41
+ export function buildLibrary(
42
+ parameterTypes: readonly ParameterTypeSpec[],
43
+ expressions: Iterable<string>,
44
+ ): ISupportCodeLibrary {
45
+ const builder = supportCodeLibraryBuilder;
46
+ builder.reset(process.cwd(), IdGenerator.uuid(), {
47
+ requireModules: [],
48
+ requirePaths: [],
49
+ importPaths: [],
50
+ loaders: [],
51
+ });
52
+ const { defineParameterType, defineStep, Before, BeforeStep } = builder.methods;
53
+ for (const { name, regexp, transformer } of parameterTypes) {
54
+ defineParameterType({ name, regexp: regexp as RegExp, transformer, useForSnippets: false });
55
+ }
56
+ for (const expression of expressions) defineStep(expression, stepCode(expression));
57
+ Before(function (this: Current, { pickle }) {
58
+ if (this.run.skips(pickle)) return "skipped";
59
+ this.begin(pickle);
60
+ return undefined;
61
+ });
62
+ BeforeStep(function (this: Current, { pickleStep }) {
63
+ this.current = pickleStep;
64
+ });
65
+ const library = builder.finalize();
66
+ // Cucumber checks a definition's code's `length` against what it passes: the expression's
67
+ // parameters, then the step's doc string or table, or else a callback. Its own parse of each
68
+ // expression says how many parameters it has.
69
+ for (const { code, expression } of library.stepDefinitions) {
70
+ Object.defineProperty(code, "length", {
71
+ value: countParameters((expression as CucumberExpression).ast) + 1,
72
+ });
73
+ }
74
+ return library;
75
+ }
76
+
77
+ /** The World as the library's code sees it: the run's, with the run's state and the step being run. */
78
+ type Current = SanomaWorld & { current: PickleStep; run: RunState };
79
+
80
+ /** What a run's Worlds share: which scenarios it skips, and where a bug in a step's code goes. */
81
+ interface RunState {
82
+ skips(pickle: Pickle): boolean;
83
+ caught(err: unknown): void;
84
+ }
85
+
86
+ const countParameters = (node: Node): number =>
87
+ node.type === NodeType.parameter ? 1 : (node.nodes ?? []).reduce((n, child) => n + countParameters(child), 0);
88
+
89
+ /**
90
+ * A step definition's code. Cucumber passes the expression's values, then the step's doc string
91
+ * or table when it has one, then a callback: with one more argument than the parameters it took
92
+ * the callback of a step with no argument (the callback interface), and with two more, the
93
+ * argument of a step that has one (the promise interface). The World reads the argument from
94
+ * the pickle step, so the code answers both.
95
+ */
96
+ function stepCode(expression: string) {
97
+ const code = function (this: Current, ...args: unknown[]) {
98
+ const parameters = code.length - 1;
99
+ const result = Promise.resolve()
100
+ .then(() => this.step(expression, args.slice(0, parameters), this.current))
101
+ .catch((err: unknown) => {
102
+ this.run.caught(err);
103
+ throw err;
104
+ });
105
+ if (args.length > parameters + 1) return result;
106
+ const done = args.at(-1) as (error: unknown, result?: unknown) => void;
107
+ result.then(
108
+ (value) => done(null, value),
109
+ (err: unknown) => done(err),
110
+ );
111
+ return undefined;
112
+ };
113
+ return code;
114
+ }
115
+
116
+ /** How one step ended. */
117
+ export interface StepResult {
118
+ status: TestStepResultStatus;
119
+ /** Why it failed: its error's message. */
120
+ message?: string;
121
+ /** The expressions that match it: none for an undefined step, more than one for an ambiguous one. */
122
+ matches: string[];
123
+ }
124
+
125
+ /** What a run read: Cucumber's `Query` of its messages, each file's text, and the files Gherkin could not parse. */
126
+ export interface Report {
127
+ query: Query;
128
+ /** Each file's text, by its path relative to the run's `cwd`. */
129
+ texts: Map<string, string>;
130
+ /** Files Gherkin could not parse; none of the run's scenarios ran when there are any. */
131
+ parseErrors: { uri: string; line?: number; message: string }[];
132
+ }
133
+
134
+ /** How each step of a run ended, by its pickle step's id; a step of a pickle that did not run has none. */
135
+ export function stepResults({ query }: Report): Map<string, StepResult> {
136
+ const results = new Map<string, StepResult>();
137
+ for (const finished of query.findAllTestStepFinished()) {
138
+ const testStep = query.findTestStepBy(finished);
139
+ if (!testStep?.pickleStepId) continue;
140
+ const { status, message, exception } = finished.testStepResult;
141
+ const why = exception?.message ?? message;
142
+ results.set(testStep.pickleStepId, {
143
+ status,
144
+ matches: query.findStepDefinitionsBy(testStep).map((d) => d.pattern.source),
145
+ ...(why !== undefined && { message: why }),
146
+ });
147
+ }
148
+ return results;
149
+ }
150
+
151
+ /** A pickle's line: its examples row's, or its scenario's. */
152
+ export const lineOf = (report: Report, pickle: Pickle) => report.query.findLocationOf(pickle)?.line ?? 1;
153
+
154
+ /** A stream that drops what it is given: Cucumber's formatters and logger write nowhere. */
155
+ const sink = () =>
156
+ new Writable({
157
+ write(_chunk, _encoding, callback) {
158
+ callback();
159
+ },
160
+ });
161
+
162
+ /**
163
+ * A path as a glob that matches it alone: Cucumber globs each path it is given, and reads one
164
+ * whose name starts with `@` as a list of paths. Brackets escape what they can; brace
165
+ * expansion reads braces and commas before brackets, so those (and backslashes) match any
166
+ * character instead.
167
+ */
168
+ const literal = (path: string) => path.replaceAll(/[*?[\]()+@]/g, "[$&]").replaceAll(/[{},\\]/g, "?");
169
+
170
+ /**
171
+ * Runs the feature files `files` (relative to `cwd`) with the library, serially, in this
172
+ * process, and reads back what happened. `line`, with a single file, runs only the scenario or
173
+ * examples row at that line. `makeWorld` makes each scenario's World, given the report so far
174
+ * (every file and pickle, read before the first scenario starts). A dry run matches the steps
175
+ * and runs none. A step's error that is a bug in code (`isBug`) is rethrown once the run is
176
+ * over, rather than reported as the step's failure.
177
+ */
178
+ export async function runFeatures(
179
+ library: ISupportCodeLibrary,
180
+ makeWorld: (report: Report) => SanomaWorld,
181
+ {
182
+ cwd,
183
+ files,
184
+ line,
185
+ dryRun = false,
186
+ isBug,
187
+ }: { cwd: string; files: string[]; line?: number; dryRun?: boolean; isBug: (err: unknown) => boolean },
188
+ ): Promise<Report> {
189
+ const report: Report = { query: new Query(), texts: new Map(), parseErrors: [] };
190
+ let bug: unknown;
191
+ const run: RunState = {
192
+ skips: (pickle) => line !== undefined && lineOf(report, pickle) !== line,
193
+ caught: (err) => {
194
+ if (isBug(err)) bug ??= err;
195
+ },
196
+ };
197
+ // Cucumber makes a scenario's World with `new`: a constructor that returns an object gives that object.
198
+ function World() {
199
+ return Object.assign(makeWorld(report), { run });
200
+ }
201
+ const configuration: IRunOptions = {
202
+ sources: { defaultDialect: "en", paths: files.map(literal), names: [], tagExpression: "", order: "defined" },
203
+ support: { ...library, World } as ISupportCodeLibrary,
204
+ runtime: {
205
+ dryRun,
206
+ failFast: false,
207
+ filterStacktraces: true,
208
+ parallel: 0,
209
+ retry: 0,
210
+ retryTagFilter: "",
211
+ strict: true,
212
+ worldParameters: {},
213
+ },
214
+ // Cucumber always formats to stdout, here a sink: `rerun` is the formatter that does least.
215
+ formats: { stdout: "rerun", files: {}, publish: false, options: {} },
216
+ };
217
+ await runCucumber(configuration, { cwd, stdout: sink(), stderr: sink(), env: {} }, (envelope) => {
218
+ report.query.update(envelope);
219
+ const { source, parseError } = envelope;
220
+ if (source) report.texts.set(source.uri, source.data);
221
+ if (parseError) {
222
+ const { uri = "", location } = parseError.source;
223
+ report.parseErrors.push({ uri, ...(location && { line: location.line }), message: parseError.message });
224
+ }
225
+ });
226
+ if (bug !== undefined) throw bug;
227
+ return report;
228
+ }
229
+
230
+ export { TestStepResultStatus as StepStatus };