effect-bdd 0.1.1 → 0.1.3

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 (92) hide show
  1. package/README.md +14 -0
  2. package/dist/Bdd.d.ts +0 -1
  3. package/dist/Bdd.js +1 -2
  4. package/dist/Errors.d.ts +0 -1
  5. package/dist/Errors.js +1 -2
  6. package/dist/bin.d.ts +0 -1
  7. package/dist/bin.js +1 -2
  8. package/dist/index.d.ts +0 -1
  9. package/dist/index.js +1 -2
  10. package/dist/internal/cli/errors.d.ts +0 -1
  11. package/dist/internal/cli/errors.js +1 -2
  12. package/dist/internal/cli/glob.d.ts +0 -1
  13. package/dist/internal/cli/glob.js +1 -2
  14. package/dist/internal/cli/loaders.d.ts +0 -1
  15. package/dist/internal/cli/loaders.js +1 -2
  16. package/dist/internal/cli/models.d.ts +0 -1
  17. package/dist/internal/cli/models.js +1 -2
  18. package/dist/internal/cli/moduleLoader.d.ts +0 -1
  19. package/dist/internal/cli/moduleLoader.js +1 -2
  20. package/dist/internal/cli/reporter.d.ts +0 -1
  21. package/dist/internal/cli/reporter.js +1 -2
  22. package/dist/internal/cli/runner.d.ts +0 -1
  23. package/dist/internal/cli/runner.js +1 -2
  24. package/dist/internal/cli/tagExpression.d.ts +0 -1
  25. package/dist/internal/cli/tagExpression.js +1 -2
  26. package/dist/internal/cucumberCompiler.d.ts +0 -1
  27. package/dist/internal/cucumberCompiler.js +1 -2
  28. package/dist/internal/expression.d.ts +0 -1
  29. package/dist/internal/expression.js +1 -2
  30. package/dist/internal/matching.d.ts +0 -1
  31. package/dist/internal/matching.js +1 -2
  32. package/dist/internal/parser.d.ts +0 -1
  33. package/dist/internal/parser.js +1 -2
  34. package/dist/internal/runner.d.ts +0 -1
  35. package/dist/internal/runner.js +1 -2
  36. package/dist/main.d.ts +0 -1
  37. package/dist/main.js +1 -2
  38. package/package.json +4 -10
  39. package/dist/Bdd.d.ts.map +0 -1
  40. package/dist/Bdd.js.map +0 -1
  41. package/dist/Errors.d.ts.map +0 -1
  42. package/dist/Errors.js.map +0 -1
  43. package/dist/bin.d.ts.map +0 -1
  44. package/dist/bin.js.map +0 -1
  45. package/dist/index.d.ts.map +0 -1
  46. package/dist/index.js.map +0 -1
  47. package/dist/internal/cli/errors.d.ts.map +0 -1
  48. package/dist/internal/cli/errors.js.map +0 -1
  49. package/dist/internal/cli/glob.d.ts.map +0 -1
  50. package/dist/internal/cli/glob.js.map +0 -1
  51. package/dist/internal/cli/loaders.d.ts.map +0 -1
  52. package/dist/internal/cli/loaders.js.map +0 -1
  53. package/dist/internal/cli/models.d.ts.map +0 -1
  54. package/dist/internal/cli/models.js.map +0 -1
  55. package/dist/internal/cli/moduleLoader.d.ts.map +0 -1
  56. package/dist/internal/cli/moduleLoader.js.map +0 -1
  57. package/dist/internal/cli/reporter.d.ts.map +0 -1
  58. package/dist/internal/cli/reporter.js.map +0 -1
  59. package/dist/internal/cli/runner.d.ts.map +0 -1
  60. package/dist/internal/cli/runner.js.map +0 -1
  61. package/dist/internal/cli/tagExpression.d.ts.map +0 -1
  62. package/dist/internal/cli/tagExpression.js.map +0 -1
  63. package/dist/internal/cucumberCompiler.d.ts.map +0 -1
  64. package/dist/internal/cucumberCompiler.js.map +0 -1
  65. package/dist/internal/expression.d.ts.map +0 -1
  66. package/dist/internal/expression.js.map +0 -1
  67. package/dist/internal/matching.d.ts.map +0 -1
  68. package/dist/internal/matching.js.map +0 -1
  69. package/dist/internal/parser.d.ts.map +0 -1
  70. package/dist/internal/parser.js.map +0 -1
  71. package/dist/internal/runner.d.ts.map +0 -1
  72. package/dist/internal/runner.js.map +0 -1
  73. package/dist/main.d.ts.map +0 -1
  74. package/dist/main.js.map +0 -1
  75. package/src/Bdd.ts +0 -575
  76. package/src/Errors.ts +0 -60
  77. package/src/bin.ts +0 -10
  78. package/src/index.ts +0 -155
  79. package/src/internal/cli/errors.ts +0 -20
  80. package/src/internal/cli/glob.ts +0 -37
  81. package/src/internal/cli/loaders.ts +0 -100
  82. package/src/internal/cli/models.ts +0 -118
  83. package/src/internal/cli/moduleLoader.ts +0 -41
  84. package/src/internal/cli/reporter.ts +0 -367
  85. package/src/internal/cli/runner.ts +0 -336
  86. package/src/internal/cli/tagExpression.ts +0 -173
  87. package/src/internal/cucumberCompiler.ts +0 -58
  88. package/src/internal/expression.ts +0 -103
  89. package/src/internal/matching.ts +0 -81
  90. package/src/internal/parser.ts +0 -155
  91. package/src/internal/runner.ts +0 -373
  92. package/src/main.ts +0 -169
@@ -1,367 +0,0 @@
1
- import * as Arr from "effect/Array"
2
- import * as Console from "effect/Console"
3
- import * as Effect from "effect/Effect"
4
- import * as FileSystem from "effect/FileSystem"
5
- import { pipe } from "effect/Function"
6
- import * as Path from "effect/Path"
7
- import * as Str from "effect/String"
8
- import * as Parser from "../parser.ts"
9
- import { ReporterError } from "./errors.ts"
10
- import type { CliDiagnostic, CliRunResult, ReporterName, ScenarioResult } from "./models.ts"
11
-
12
- /** @internal */
13
- export interface Reporter {
14
- readonly name: ReporterName
15
- readonly emit: (
16
- result: CliRunResult
17
- ) => Effect.Effect<void, ReporterError, FileSystem.FileSystem | Path.Path>
18
- }
19
-
20
- /** @internal */
21
- export const makeReporters = (
22
- names: ReadonlyArray<ReporterName>,
23
- outputFiles: {
24
- readonly text?: string
25
- readonly html?: string
26
- readonly json?: string
27
- readonly junit?: string
28
- },
29
- options: {
30
- readonly verbose: boolean
31
- }
32
- ): Effect.Effect<ReadonlyArray<Reporter>, ReporterError> =>
33
- Effect.forEach(names, (name) => {
34
- switch (name) {
35
- case "text": {
36
- return Effect.succeed(textReporter(outputFiles.text, options.verbose))
37
- }
38
- case "html": {
39
- return outputFiles.html === undefined
40
- ? Effect.fail(new ReporterError({ message: "Reporter html requires --output-file.html" }))
41
- : Effect.succeed(htmlReporter(outputFiles.html))
42
- }
43
- case "json": {
44
- return Effect.succeed(jsonReporter(outputFiles.json))
45
- }
46
- case "junit": {
47
- return outputFiles.junit === undefined
48
- ? Effect.fail(new ReporterError({ message: "Reporter junit requires --output-file.junit" }))
49
- : Effect.succeed(junitReporter(outputFiles.junit))
50
- }
51
- }
52
- })
53
-
54
- /** @internal */
55
- export const emitAll: (
56
- reporters: ReadonlyArray<Reporter>,
57
- result: CliRunResult
58
- ) => Effect.Effect<void, ReporterError, FileSystem.FileSystem | Path.Path> = Effect.fnUntraced(function*(
59
- reporters: ReadonlyArray<Reporter>,
60
- result: CliRunResult
61
- ) {
62
- const exits = yield* Effect.forEach(
63
- reporters,
64
- (reporter) => Effect.exit(reporter.emit(result)),
65
- { concurrency: "unbounded" }
66
- )
67
- const failures = pipe(
68
- exits,
69
- Arr.filter((exit) => exit._tag === "Failure"),
70
- Arr.map((exit) => exit.cause)
71
- )
72
- if (failures.length > 0) {
73
- return yield* Effect.fail(
74
- new ReporterError({
75
- message: "One or more reporters failed",
76
- cause: failures
77
- })
78
- )
79
- }
80
- })
81
-
82
- const textReporter = (outputFile: string | undefined, verbose: boolean): Reporter => ({
83
- name: "text",
84
- emit: (result) =>
85
- outputFile === undefined
86
- ? Console.log(renderText(result, verbose))
87
- : writeFile(outputFile, renderText(result, verbose))
88
- })
89
-
90
- const htmlReporter = (outputFile: string): Reporter => ({
91
- name: "html",
92
- emit: (result) => writeFile(outputFile, renderHtml(result))
93
- })
94
-
95
- const jsonReporter = (outputFile: string | undefined): Reporter => ({
96
- name: "json",
97
- emit: (result) =>
98
- outputFile === undefined
99
- ? Console.log(renderJson(result))
100
- : writeFile(outputFile, renderJson(result))
101
- })
102
-
103
- const junitReporter = (outputFile: string): Reporter => ({
104
- name: "junit",
105
- emit: (result) => writeFile(outputFile, renderJunit(result))
106
- })
107
-
108
- const writeFile: (
109
- outputFile: string,
110
- content: string
111
- ) => Effect.Effect<void, ReporterError, FileSystem.FileSystem | Path.Path> = Effect.fnUntraced(function*(
112
- outputFile: string,
113
- content: string
114
- ) {
115
- const fs = yield* FileSystem.FileSystem
116
- const path = yield* Path.Path
117
- const directory = path.dirname(outputFile)
118
- if (directory !== ".") {
119
- yield* fs.makeDirectory(directory, { recursive: true }).pipe(
120
- Effect.mapError((cause) =>
121
- new ReporterError({
122
- message: `Could not create report directory "${directory}"`,
123
- cause
124
- })
125
- )
126
- )
127
- }
128
- yield* fs.writeFileString(outputFile, content).pipe(
129
- Effect.mapError((cause) =>
130
- new ReporterError({
131
- message: `Could not write report file "${outputFile}"`,
132
- cause
133
- })
134
- )
135
- )
136
- })
137
-
138
- const renderText = (result: CliRunResult, verbose: boolean): string => {
139
- const summary = [
140
- `Features: ${result.summary.features}, Scenarios: ${result.summary.total}, passed: ${result.summary.passed}, failed: ${result.summary.failed}`,
141
- `Duration: ${result.summary.durationMillis}ms`,
142
- ""
143
- ]
144
- const scenarioLines = verbose
145
- ? Arr.map(result.results, renderScenarioText)
146
- : pipe(
147
- result.results,
148
- Arr.filter((scenario) => scenario.outcome._tag === "Failed"),
149
- Arr.map(renderScenarioText)
150
- )
151
- const diagnosticLines = renderDiagnosticsText(result.diagnostics)
152
- return pipe(
153
- summary,
154
- Arr.appendAll(scenarioLines),
155
- Arr.appendAll(diagnosticLines),
156
- Arr.join("\n")
157
- )
158
- }
159
-
160
- const renderScenarioText = (result: ScenarioResult): string => {
161
- const prefix = result.outcome._tag === "Passed" ? "PASS" : "FAIL"
162
- const base = `${prefix} ${result.task.featurePath}:${result.task.core.scenarioLine} ${
163
- renderScenarioName(result)
164
- } (${result.durationMillis}ms)`
165
- return result.outcome._tag === "Passed"
166
- ? base
167
- : `${base}\n ${renderError(result.outcome.error)}`
168
- }
169
-
170
- const renderHtml = (result: CliRunResult): string =>
171
- `<!doctype html>
172
- <html lang="en">
173
- <head>
174
- <meta charset="utf-8">
175
- <title>effect-bdd report</title>
176
- <style>
177
- body { font-family: system-ui, sans-serif; margin: 2rem; }
178
- table { border-collapse: collapse; width: 100%; }
179
- th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
180
- .passed { color: #166534; }
181
- .failed { color: #991b1b; }
182
- </style>
183
- </head>
184
- <body>
185
- <h1>effect-bdd report</h1>
186
- <p>Features: ${result.summary.features}, scenarios: ${result.summary.total}, passed: ${result.summary.passed}, failed: ${result.summary.failed}</p>
187
- <p>Duration: ${result.summary.durationMillis}ms</p>
188
- <table>
189
- <thead>
190
- <tr><th>Status</th><th>Source</th><th>Feature</th><th>Scenario</th><th>Tags</th><th>Duration</th><th>Error</th></tr>
191
- </thead>
192
- <tbody>
193
- ${pipe(result.results, Arr.map(renderScenarioHtml), Arr.join("\n"))}
194
- </tbody>
195
- </table>
196
- <h2>Diagnostics</h2>
197
- <pre>${escapeHtml(pipe(renderDiagnosticsText(result.diagnostics), Arr.join("\n")))}</pre>
198
- </body>
199
- </html>
200
- `
201
-
202
- const renderScenarioHtml = (result: ScenarioResult): string => {
203
- const status = result.outcome._tag === "Passed" ? "passed" : "failed"
204
- const error = result.outcome._tag === "Passed"
205
- ? ""
206
- : renderError(result.outcome.error)
207
- return ` <tr>
208
- <td class="${status}">${status}</td>
209
- <td>${escapeHtml(`${result.task.featurePath}:${result.task.core.scenarioLine}`)}</td>
210
- <td>${escapeHtml(result.task.core.featureName)}</td>
211
- <td>${escapeHtml(renderScenarioName(result))}</td>
212
- <td>${escapeHtml(pipe(result.task.core.tags, Arr.join(", ")))}</td>
213
- <td>${result.durationMillis}ms</td>
214
- <td>${escapeHtml(error)}</td>
215
- </tr>`
216
- }
217
-
218
- const renderDiagnosticsText = (diagnostics: ReadonlyArray<CliDiagnostic>): ReadonlyArray<string> => {
219
- if (diagnostics.length === 0) {
220
- return []
221
- }
222
- const unmatched = pipe(
223
- diagnostics,
224
- Arr.filter((diagnostic) =>
225
- diagnostic._tag === "UnmatchedFeature" ||
226
- diagnostic._tag === "UnmatchedScenario" ||
227
- diagnostic._tag === "UnmatchedStep"
228
- )
229
- )
230
- const unused = pipe(
231
- diagnostics,
232
- Arr.filter((diagnostic) =>
233
- diagnostic._tag === "UnusedFeatureDefinition" ||
234
- diagnostic._tag === "UnusedStepDefinition"
235
- )
236
- )
237
- return pipe(
238
- unmatched.length === 0 ? [] : ["", "Unmatched source:"],
239
- Arr.appendAll(Arr.map(unmatched, renderDiagnosticText)),
240
- Arr.appendAll(unused.length === 0 ? [] : ["", "Unused definitions:"]),
241
- Arr.appendAll(Arr.map(unused, renderDiagnosticText))
242
- )
243
- }
244
-
245
- const renderDiagnosticText = (diagnostic: CliDiagnostic): string => {
246
- switch (diagnostic._tag) {
247
- case "UnmatchedFeature": {
248
- return ` ${diagnostic.featurePath}:${diagnostic.line}\n Feature: ${diagnostic.featureName}\n Reason: ${diagnostic.message}`
249
- }
250
- case "UnmatchedScenario": {
251
- return ` ${diagnostic.featurePath}:${diagnostic.scenarioLine}\n Scenario: ${diagnostic.scenarioName}\n Reason: ${diagnostic.message}`
252
- }
253
- case "UnmatchedStep": {
254
- return ` ${diagnostic.featurePath}:${
255
- Parser.stepLine(diagnostic.step, diagnostic.source)
256
- }\n Scenario: ${diagnostic.featureName} / ${diagnostic.scenarioName}\n Step: ${
257
- Parser.stepKeyword(diagnostic.step, diagnostic.source)
258
- } ${diagnostic.step.text}\n Reason: ${diagnostic.message}`
259
- }
260
- case "UnusedFeatureDefinition": {
261
- return ` ${diagnostic.message}`
262
- }
263
- case "UnusedStepDefinition": {
264
- return ` ${diagnostic.message}`
265
- }
266
- }
267
- }
268
-
269
- const renderJson = (result: CliRunResult): string =>
270
- JSON.stringify(
271
- {
272
- summary: result.summary,
273
- scenarios: Arr.map(result.results, (scenario) => ({
274
- source: {
275
- path: scenario.task.featurePath,
276
- line: scenario.task.core.scenarioLine
277
- },
278
- feature: scenario.task.core.featureName,
279
- rule: scenario.task.core.ruleName === undefined
280
- ? undefined
281
- : {
282
- name: scenario.task.core.ruleName,
283
- line: scenario.task.core.ruleLine
284
- },
285
- scenario: scenario.task.core.scenarioName,
286
- tags: scenario.task.core.tags,
287
- durationMillis: scenario.durationMillis,
288
- outcome: scenario.outcome._tag === "Passed"
289
- ? {
290
- status: "passed",
291
- steps: scenario.outcome.steps
292
- }
293
- : {
294
- status: "failed",
295
- error: renderError(scenario.outcome.error)
296
- }
297
- })),
298
- diagnostics: result.diagnostics
299
- },
300
- null,
301
- 2
302
- )
303
-
304
- const renderJunit = (result: CliRunResult): string => {
305
- const diagnostics = result.diagnostics.length
306
- return `<?xml version="1.0" encoding="UTF-8"?>
307
- <testsuite name="effect-bdd" tests="${result.summary.total + diagnostics}" failures="${
308
- result.summary.failed + diagnostics
309
- }" time="${result.summary.durationMillis / 1000}">
310
- ${pipe(result.results, Arr.map(renderJunitScenario), Arr.join("\n"))}
311
- ${pipe(result.diagnostics, Arr.map(renderJunitDiagnostic), Arr.join("\n"))}
312
- </testsuite>
313
- `
314
- }
315
-
316
- const renderJunitScenario = (result: ScenarioResult): string => {
317
- const name = renderScenarioName(result)
318
- const failure = result.outcome._tag === "Passed"
319
- ? ""
320
- : `
321
- <failure message="${escapeXml(renderError(result.outcome.error))}">${
322
- escapeXml(renderError(result.outcome.error))
323
- }</failure>`
324
- return ` <testcase classname="${escapeXml(result.task.core.featureName)}" name="${escapeXml(name)}" file="${
325
- escapeXml(result.task.featurePath)
326
- }" line="${result.task.core.scenarioLine}" time="${result.durationMillis / 1000}">${failure}
327
- </testcase>`
328
- }
329
-
330
- const renderJunitDiagnostic = (diagnostic: CliDiagnostic): string =>
331
- ` <testcase classname="effect-bdd diagnostics" name="${escapeXml(diagnostic.message)}">
332
- <failure message="${escapeXml(diagnostic.message)}">${escapeXml(renderDiagnosticText(diagnostic))}</failure>
333
- </testcase>`
334
-
335
- const renderScenarioName = (result: ScenarioResult): string =>
336
- result.task.core.ruleName === undefined
337
- ? `${result.task.core.featureName} / ${result.task.core.scenarioName}`
338
- : `${result.task.core.featureName} / ${result.task.core.ruleName} / ${result.task.core.scenarioName}`
339
-
340
- const renderError = (error: { readonly _tag: string; readonly message: string; readonly cause?: unknown }): string => {
341
- const cause = renderCause(error.cause)
342
- return cause === undefined
343
- ? `${error._tag}: ${error.message}`
344
- : `${error._tag}: ${error.message}\n Cause: ${cause}`
345
- }
346
-
347
- const renderCause = (cause: unknown): string | undefined => {
348
- if (cause === undefined) {
349
- return undefined
350
- }
351
- if (typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string") {
352
- return cause.message
353
- }
354
- return String(cause)
355
- }
356
-
357
- const escapeHtml = (text: string): string =>
358
- pipe(
359
- text,
360
- Str.replaceAll("&", "&amp;"),
361
- Str.replaceAll("<", "&lt;"),
362
- Str.replaceAll(">", "&gt;"),
363
- Str.replaceAll("\"", "&quot;"),
364
- Str.replaceAll("'", "&#039;")
365
- )
366
-
367
- const escapeXml = escapeHtml
@@ -1,336 +0,0 @@
1
- import * as Arr from "effect/Array"
2
- import * as Clock from "effect/Clock"
3
- import * as Effect from "effect/Effect"
4
- import type * as FileSystem from "effect/FileSystem"
5
- import { pipe } from "effect/Function"
6
- import * as Option from "effect/Option"
7
- import type * as Path from "effect/Path"
8
- import type * as Bdd from "../../Bdd.ts"
9
- import type { ParseError } from "../../Errors.ts"
10
- import * as Matching from "../matching.ts"
11
- import * as Parser from "../parser.ts"
12
- import * as CoreRunner from "../runner.ts"
13
- import { DiscoveryError, type ModuleLoadError } from "./errors.ts"
14
- import type { GlobResolver } from "./glob.ts"
15
- import { loadFeatureDefinitions, loadFeatureSources } from "./loaders.ts"
16
- import type {
17
- CliDiagnostic,
18
- CliOptions,
19
- CliRunResult,
20
- FeatureSource,
21
- RunSummary,
22
- ScenarioResult,
23
- ScenarioTask
24
- } from "./models.ts"
25
- import type { ModuleLoader } from "./moduleLoader.ts"
26
- import * as TagExpression from "./tagExpression.ts"
27
-
28
- interface BuiltScenarios {
29
- readonly tasks: ReadonlyArray<ScenarioTask>
30
- readonly diagnostics: ReadonlyArray<CliDiagnostic>
31
- readonly matchedFeatureNames: ReadonlyArray<string>
32
- }
33
-
34
- interface StepCoverage {
35
- readonly diagnostics: ReadonlyArray<CliDiagnostic>
36
- readonly usedTransitionKeys: ReadonlyArray<string>
37
- }
38
-
39
- /** @internal */
40
- export const run: (
41
- options: CliOptions
42
- ) => Effect.Effect<
43
- CliRunResult,
44
- CliRunError,
45
- FileSystem.FileSystem | GlobResolver | ModuleLoader | Path.Path | Parser.GherkinCompiler
46
- > = Effect.fnUntraced(function*(options: CliOptions) {
47
- const startedAt = yield* Clock.currentTimeMillis
48
- const sources = yield* loadFeatureSources(options.features)
49
- const definitions = yield* loadFeatureDefinitions(options.steps)
50
- const built = yield* pipe(
51
- sources,
52
- Effect.forEach((source) => buildScenarioTasks(source, definitions)),
53
- Effect.map(combineBuiltScenarios)
54
- )
55
- const filteredTasks = yield* filterTasks(options, built.tasks)
56
- const coverage = collectStepCoverage(filteredTasks)
57
- const results = yield* runScenarios(options, filteredTasks)
58
- const finishedAt = yield* Clock.currentTimeMillis
59
- const diagnostics: ReadonlyArray<CliDiagnostic> = pipe(
60
- built.diagnostics,
61
- Arr.appendAll(coverage.diagnostics),
62
- Arr.appendAll(unusedFeatureDefinitions(definitions, built.matchedFeatureNames)),
63
- Arr.appendAll(
64
- hasScenarioFilter(options)
65
- ? []
66
- : unusedStepDefinitions(definitions, built.matchedFeatureNames, coverage.usedTransitionKeys)
67
- )
68
- )
69
- return {
70
- results,
71
- diagnostics,
72
- summary: summarize(sources.length, results, finishedAt - startedAt)
73
- } satisfies CliRunResult
74
- })
75
-
76
- const buildScenarioTasks: (
77
- source: FeatureSource,
78
- definitions: ReadonlyArray<Bdd.Feature<unknown, unknown, never>>
79
- ) => Effect.Effect<BuiltScenarios, DiscoveryError | ParseError, Parser.GherkinCompiler> = Effect.fnUntraced(function*(
80
- source: FeatureSource,
81
- definitions: ReadonlyArray<Bdd.Feature<unknown, unknown, never>>
82
- ) {
83
- const parsed = yield* Parser.parse(source.source, source.path)
84
- const matches = Arr.filter(definitions, (definition) => definition.name === parsed.name)
85
- if (matches.length > 1) {
86
- return yield* Effect.fail(
87
- new DiscoveryError({
88
- message: `Multiple feature definitions matched "${parsed.name}"`
89
- })
90
- )
91
- }
92
- const definition = matches[0]
93
- if (definition === undefined) {
94
- return {
95
- tasks: [],
96
- diagnostics: pipe(
97
- [
98
- {
99
- _tag: "UnmatchedFeature",
100
- featurePath: source.path,
101
- featureName: parsed.name,
102
- line: parsed.line,
103
- message: `Feature file has no matching Bdd.feature export: ${parsed.name}`
104
- } satisfies CliDiagnostic
105
- ],
106
- Arr.appendAll(Arr.map(parsed.pickles, (pickle): CliDiagnostic => ({
107
- _tag: "UnmatchedScenario",
108
- featurePath: source.path,
109
- featureName: parsed.name,
110
- scenarioName: pickle.name,
111
- scenarioLine: pickle.location?.line ?? parsed.line,
112
- message: `Scenario cannot run because no feature definition matched "${parsed.name}"`
113
- })))
114
- ),
115
- matchedFeatureNames: []
116
- }
117
- }
118
- return pipe(
119
- CoreRunner.buildScenarioTasks(definition, parsed),
120
- Arr.map((task): ScenarioTask => ({
121
- featurePath: source.path,
122
- core: task
123
- })),
124
- (tasks): BuiltScenarios => ({
125
- tasks,
126
- diagnostics: [],
127
- matchedFeatureNames: [definition.name]
128
- })
129
- )
130
- })
131
-
132
- const runScenario = Effect.fnUntraced(function*(task: ScenarioTask) {
133
- const startedAt = yield* Clock.currentTimeMillis
134
- const result = yield* Effect.result(CoreRunner.runScenarioTask(task.core))
135
- const finishedAt = yield* Clock.currentTimeMillis
136
- return {
137
- task,
138
- outcome: result._tag === "Success"
139
- ? { _tag: "Passed", steps: result.success.steps }
140
- : { _tag: "Failed", error: result.failure },
141
- durationMillis: finishedAt - startedAt
142
- } satisfies ScenarioResult
143
- })
144
-
145
- const runScenarios = (
146
- options: CliOptions,
147
- tasks: ReadonlyArray<ScenarioTask>
148
- ): Effect.Effect<ReadonlyArray<ScenarioResult>, never, never> =>
149
- options.filters.failFast
150
- ? runScenariosFailFast(tasks, [])
151
- : Effect.forEach(tasks, runScenario, { concurrency: options.parallel })
152
-
153
- const runScenariosFailFast = (
154
- tasks: ReadonlyArray<ScenarioTask>,
155
- results: ReadonlyArray<ScenarioResult>,
156
- index = 0
157
- ): Effect.Effect<ReadonlyArray<ScenarioResult>, never, never> => {
158
- if (index >= tasks.length) {
159
- return Effect.succeed(results)
160
- }
161
- return pipe(
162
- runScenario(tasks[index]),
163
- Effect.flatMap((result) =>
164
- result.outcome._tag === "Failed"
165
- ? Effect.succeed(Arr.append(results, result))
166
- : runScenariosFailFast(tasks, Arr.append(results, result), index + 1)
167
- )
168
- )
169
- }
170
-
171
- const filterTasks = (
172
- options: CliOptions,
173
- tasks: ReadonlyArray<ScenarioTask>
174
- ): Effect.Effect<ReadonlyArray<ScenarioTask>, DiscoveryError> =>
175
- pipe(
176
- TagExpression.compileAll(options.filters.tags),
177
- Effect.map((tagPredicate) => {
178
- const filtered = Arr.filter(tasks, (task) =>
179
- tagPredicate(task.core.tags) && matchesNameFilter(options.filters.names, task))
180
- return filtered
181
- }),
182
- Effect.flatMap((filtered) =>
183
- tasks.length > 0 && filtered.length === 0
184
- ? Effect.fail(new DiscoveryError({ message: "No scenarios matched the provided filters" }))
185
- : Effect.succeed(filtered)
186
- )
187
- )
188
-
189
- const matchesNameFilter = (patterns: ReadonlyArray<string>, task: ScenarioTask): boolean =>
190
- patterns.length === 0 ||
191
- Arr.some(patterns, (pattern) => `${task.core.featureName} / ${task.core.scenarioName}`.includes(pattern))
192
-
193
- const hasScenarioFilter = (options: CliOptions): boolean =>
194
- options.filters.tags.length > 0 || options.filters.names.length > 0
195
-
196
- const summarize = (features: number, results: ReadonlyArray<ScenarioResult>, durationMillis: number): RunSummary => {
197
- const failed = Arr.filter(results, (result) => result.outcome._tag === "Failed").length
198
- return {
199
- features,
200
- total: results.length,
201
- passed: results.length - failed,
202
- failed,
203
- durationMillis
204
- }
205
- }
206
-
207
- const combineBuiltScenarios = (built: ReadonlyArray<BuiltScenarios>): BuiltScenarios => ({
208
- tasks: pipe(built, Arr.flatMap((item) => item.tasks)),
209
- diagnostics: pipe(built, Arr.flatMap((item) => item.diagnostics)),
210
- matchedFeatureNames: pipe(
211
- built,
212
- Arr.flatMap((item) => item.matchedFeatureNames),
213
- Arr.dedupe
214
- )
215
- })
216
-
217
- const collectStepCoverage = (tasks: ReadonlyArray<ScenarioTask>): StepCoverage =>
218
- pipe(
219
- tasks,
220
- Arr.reduce({ diagnostics: [], usedTransitionKeys: [] } as StepCoverage, (coverage, task) =>
221
- pipe(
222
- task.core.pickle.steps,
223
- Arr.reduce(coverage, (coverage, step) => appendStepCoverage(coverage, task, step))
224
- ))
225
- )
226
-
227
- const appendStepCoverage = (
228
- coverage: StepCoverage,
229
- task: ScenarioTask,
230
- step: CoreRunner.ScenarioTask<unknown, unknown, never>["pickle"]["steps"][number]
231
- ): StepCoverage => {
232
- const kind = Matching.concreteStepKind(step)
233
- const textMatches = Matching.matchingTextTransitions(task.core.featureDefinition.transitions, step.text)
234
- const matches = pipe(
235
- kind,
236
- Option.match({
237
- onNone: () => [],
238
- onSome: (kind) => Matching.matchingKeywordTransitions(textMatches, kind)
239
- })
240
- )
241
- if (matches.length === 0) {
242
- return {
243
- diagnostics: Arr.append(coverage.diagnostics, {
244
- _tag: "UnmatchedStep",
245
- featurePath: task.featurePath,
246
- featureName: task.core.featureName,
247
- scenarioName: task.core.scenarioName,
248
- scenarioLine: task.core.scenarioLine,
249
- step,
250
- source: task.core.source,
251
- reason: textMatches.length === 0 ? "NoMatch" : "WrongKeyword",
252
- candidates: textMatches.length === 0
253
- ? Arr.map(task.core.featureDefinition.transitions, (transition) => transition.expression.source)
254
- : Arr.map(textMatches, (match) => match.transition.expression.source),
255
- message: textMatches.length === 0
256
- ? `No transition matched step "${step.text}"`
257
- : `No ${
258
- pipe(kind, Option.getOrElse(() => "Step"))
259
- } transition matched step "${step.text}"; matching text exists for ${
260
- Matching.renderTransitionKinds(Arr.map(textMatches, (match) => match.transition))
261
- }`
262
- }),
263
- usedTransitionKeys: coverage.usedTransitionKeys
264
- }
265
- }
266
- if (matches.length > 1) {
267
- return {
268
- diagnostics: Arr.append(coverage.diagnostics, {
269
- _tag: "UnmatchedStep",
270
- featurePath: task.featurePath,
271
- featureName: task.core.featureName,
272
- scenarioName: task.core.scenarioName,
273
- scenarioLine: task.core.scenarioLine,
274
- step,
275
- source: task.core.source,
276
- reason: "MultipleMatches",
277
- candidates: Arr.map(matches, (match) => match.transition.expression.source),
278
- message: `Multiple transitions matched step "${step.text}"`
279
- }),
280
- usedTransitionKeys: coverage.usedTransitionKeys
281
- }
282
- }
283
- return {
284
- diagnostics: coverage.diagnostics,
285
- usedTransitionKeys: pipe(
286
- coverage.usedTransitionKeys,
287
- Arr.append(transitionKey(task.core.featureName, matches[0].transition)),
288
- Arr.dedupe
289
- )
290
- }
291
- }
292
-
293
- const unusedFeatureDefinitions = (
294
- definitions: ReadonlyArray<Bdd.Feature<unknown, unknown, never>>,
295
- matchedFeatureNames: ReadonlyArray<string>
296
- ): ReadonlyArray<CliDiagnostic> =>
297
- pipe(
298
- definitions,
299
- Arr.filter((definition) => !Arr.contains(definition.name)(matchedFeatureNames)),
300
- Arr.map((definition): CliDiagnostic => ({
301
- _tag: "UnusedFeatureDefinition",
302
- featureName: definition.name,
303
- message: `Feature definition exported but no feature file matched: ${definition.name}`
304
- }))
305
- )
306
-
307
- const unusedStepDefinitions = (
308
- definitions: ReadonlyArray<Bdd.Feature<unknown, unknown, never>>,
309
- matchedFeatureNames: ReadonlyArray<string>,
310
- usedTransitionKeys: ReadonlyArray<string>
311
- ): ReadonlyArray<CliDiagnostic> =>
312
- pipe(
313
- definitions,
314
- Arr.filter((definition) => Arr.contains(definition.name)(matchedFeatureNames)),
315
- Arr.flatMap((definition) =>
316
- pipe(
317
- definition.transitions,
318
- Arr.filter((transition) => !Arr.contains(transitionKey(definition.name, transition))(usedTransitionKeys)),
319
- Arr.map((transition): CliDiagnostic => ({
320
- _tag: "UnusedStepDefinition",
321
- featureName: definition.name,
322
- expression: transition.expression.source,
323
- kind: transition.kind,
324
- message: `Step definition never matched: ${transition.expression.source}`
325
- }))
326
- )
327
- )
328
- )
329
-
330
- const transitionKey = (
331
- featureName: string,
332
- transition: Bdd.Transition<unknown, unknown, never>
333
- ): string => `${featureName}:${transition.kind}:${transition.expression.source}`
334
-
335
- /** @internal */
336
- export type CliRunError = DiscoveryError | ModuleLoadError | ParseError