diff --git a/java/src/main/java/io/cucumber/prettyformatter/SummaryReportWriter.java b/java/src/main/java/io/cucumber/prettyformatter/SummaryReportWriter.java index c2d16bb..f076f69 100644 --- a/java/src/main/java/io/cucumber/prettyformatter/SummaryReportWriter.java +++ b/java/src/main/java/io/cucumber/prettyformatter/SummaryReportWriter.java @@ -224,7 +224,7 @@ private static String formatAttempt(TestCaseStarted testCaseStarted) { if (attempt == 0) { return ""; } - return ", after " + attempt + " attempts"; + return ", after " + (attempt + 1) + " attempts"; } private String formatLocationComment(Pickle pickle) { diff --git a/javascript/package-lock.json b/javascript/package-lock.json index a770c01..0a5fad0 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -9,7 +9,8 @@ "version": "2.3.0", "license": "MIT", "dependencies": { - "@cucumber/query": "^14.0.0" + "@cucumber/query": "14.3.0", + "luxon": "^3.7.2" }, "devDependencies": { "@cucumber/message-streams": "^4.0.1", @@ -18,6 +19,7 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.30.1", "@types/chai": "^5.0.0", + "@types/luxon": "^3.7.1", "@types/mocha": "^10.0.6", "@types/node": "22.18.6", "@typescript-eslint/eslint-plugin": "8.44.0", @@ -72,9 +74,9 @@ } }, "node_modules/@cucumber/query": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-14.0.1.tgz", - "integrity": "sha512-9/Uc96XO4Uvj4+JdVB0RVEgVmddSeHvSJcK978iJITC8idDlAsk9DAZ+BL53cLCdGaRHcBEYI+KBREIM6w19JA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-14.3.0.tgz", + "integrity": "sha512-sWEkwI3G80f4QJcmdrnXj115/G4xTKUt2aS+I0DciIRo3Sw0kkvM/7D9L5DJaegITf1A8vflMx+VuLL9Zno5Gg==", "license": "MIT", "dependencies": { "@teppeis/multimaps": "3.0.0", @@ -557,6 +559,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -3301,6 +3310,15 @@ "dev": true, "license": "ISC" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", diff --git a/javascript/package.json b/javascript/package.json index 82de956..c28fdf6 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -22,7 +22,8 @@ "prepublishOnly": "tsc --build tsconfig.build.json" }, "dependencies": { - "@cucumber/query": "^14.0.0" + "@cucumber/query": "14.3.0", + "luxon": "^3.7.2" }, "peerDependencies": { "@cucumber/messages": "*" @@ -34,6 +35,7 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.30.1", "@types/chai": "^5.0.0", + "@types/luxon": "^3.7.1", "@types/mocha": "^10.0.6", "@types/node": "22.18.6", "@typescript-eslint/eslint-plugin": "8.44.0", diff --git a/javascript/src/index.spec.ts b/javascript/src/PrettyPrinter.spec.ts similarity index 89% rename from javascript/src/index.spec.ts rename to javascript/src/PrettyPrinter.spec.ts index bf876ef..2d1ee92 100644 --- a/javascript/src/index.spec.ts +++ b/javascript/src/PrettyPrinter.spec.ts @@ -8,8 +8,9 @@ import { Envelope, TestStepResultStatus } from '@cucumber/messages' import { expect } from 'chai' import { globbySync } from 'globby' -import type { Options, Theme } from './index.js' -import formatter, { CUCUMBER_THEME } from './index.js' +import { PrettyPrinter } from './PrettyPrinter.js' +import { CUCUMBER_THEME } from './theme.js' +import type { Options, Theme } from './types.js' const DEMO_THEME: Theme = { attachment: 'blue', @@ -59,9 +60,7 @@ const DEMO_THEME: Theme = { tag: ['yellow', 'bold'], } -describe('Acceptance Tests', async function () { - this.timeout(10_000) - +describe('PrettyPrinter', async () => { const ndjsonFiles = globbySync(`*.ndjson`, { cwd: path.join(import.meta.dirname, '..', '..', 'testdata', 'src'), absolute: true, @@ -143,18 +142,19 @@ describe('Acceptance Tests', async function () { const [suiteName] = path.basename(ndjsonFile).split('.') it(suiteName, async () => { - let emit: (message: Envelope) => void let content = '' - formatter.formatter({ - options, - stream: fakeStream, - on(type, handler) { - emit = handler - }, - write: (chunk) => { + const printer = new PrettyPrinter( + fakeStream, + (chunk) => { content += chunk }, - }) + { + attachments: true, + featuresAndRules: true, + theme: CUCUMBER_THEME, + ...options, + } + ) await pipeline( fs.createReadStream(ndjsonFile, { encoding: 'utf-8' }), @@ -162,7 +162,7 @@ describe('Acceptance Tests', async function () { new Writable({ objectMode: true, write(envelope: Envelope, _: BufferEncoding, callback) { - emit(envelope) + printer.update(envelope) callback() }, }) diff --git a/javascript/src/ProgressPrinter.spec.ts b/javascript/src/ProgressPrinter.spec.ts new file mode 100644 index 0000000..7222261 --- /dev/null +++ b/javascript/src/ProgressPrinter.spec.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs' +import * as path from 'node:path' +import { Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +import { NdjsonToMessageStream } from '@cucumber/message-streams' +import { Envelope, TestStepResultStatus } from '@cucumber/messages' +import { expect } from 'chai' +import { globbySync } from 'globby' + +import { ProgressPrinter } from './ProgressPrinter.js' +import { CUCUMBER_THEME } from './theme.js' +import type { Options } from './types.js' + +describe('ProgressPrinter', async () => { + const ndjsonFiles = globbySync(`*.ndjson`, { + cwd: path.join(import.meta.dirname, '..', '..', 'testdata', 'src'), + absolute: true, + }) + + const variants: ReadonlyArray<{ name: string; options: Options }> = [ + { + name: 'cucumber', + options: { + attachments: true, + featuresAndRules: true, + theme: CUCUMBER_THEME, + }, + }, + { + name: 'plain', + options: { + attachments: true, + featuresAndRules: true, + theme: { + status: { + icon: { + [TestStepResultStatus.AMBIGUOUS]: '✘', + [TestStepResultStatus.FAILED]: '✘', + [TestStepResultStatus.PASSED]: '✔', + [TestStepResultStatus.PENDING]: '■', + [TestStepResultStatus.SKIPPED]: '↷', + [TestStepResultStatus.UNDEFINED]: '■', + [TestStepResultStatus.UNKNOWN]: ' ', + }, + }, + }, + }, + }, + ] + + // just enough so Node.js internals consider it a color-supporting stream + const fakeStream = { + _writableState: {}, + isTTY: true, + getColorDepth: () => 3, + } as unknown as NodeJS.WritableStream + + for (const { name, options } of variants) { + describe(name, () => { + for (const ndjsonFile of ndjsonFiles) { + const [suiteName] = path.basename(ndjsonFile).split('.') + + it(suiteName, async () => { + let content = '' + const printer = new ProgressPrinter( + fakeStream, + (chunk) => { + content += chunk + }, + { + attachments: true, + featuresAndRules: true, + theme: CUCUMBER_THEME, + ...options, + } + ) + + await pipeline( + fs.createReadStream(ndjsonFile, { encoding: 'utf-8' }), + new NdjsonToMessageStream(), + new Writable({ + objectMode: true, + write(envelope: Envelope, _: BufferEncoding, callback) { + printer.update(envelope) + callback() + }, + }) + ) + + const expectedOutput = fs.readFileSync( + ndjsonFile.replace('.ndjson', `.${name}.progress.log`), + { + encoding: 'utf-8', + } + ) + + expect(content).to.eq(expectedOutput) + }) + } + }) + } +}) diff --git a/javascript/src/ProgressPrinter.ts b/javascript/src/ProgressPrinter.ts new file mode 100644 index 0000000..77acde2 --- /dev/null +++ b/javascript/src/ProgressPrinter.ts @@ -0,0 +1,43 @@ +import { Envelope } from '@cucumber/messages' +import { Query } from '@cucumber/query' + +import { formatStatusCharacter } from './helpers.js' +import type { Options } from './types.js' + +export class ProgressPrinter { + private readonly query: Query = new Query() + + constructor( + private readonly stream: NodeJS.WritableStream, + private readonly print: (content: string) => void, + private readonly options: Required + ) {} + + update(message: Envelope) { + this.query.update(message) + + if (message.testStepFinished) { + this.print( + formatStatusCharacter( + message.testStepFinished.testStepResult.status, + this.options.theme, + this.stream + ) + ) + } + + if (message.testRunHookFinished) { + this.print( + formatStatusCharacter( + message.testRunHookFinished.result.status, + this.options.theme, + this.stream + ) + ) + } + + if (message.testRunFinished) { + this.print('\n') + } + } +} diff --git a/javascript/src/SummaryPrinter.spec.ts b/javascript/src/SummaryPrinter.spec.ts new file mode 100644 index 0000000..e704bd6 --- /dev/null +++ b/javascript/src/SummaryPrinter.spec.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs' +import * as path from 'node:path' +import { Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +import { NdjsonToMessageStream } from '@cucumber/message-streams' +import { Envelope, TestStepResultStatus } from '@cucumber/messages' +import { expect } from 'chai' +import { globbySync } from 'globby' + +import { SummaryPrinter } from './SummaryPrinter.js' +import { CUCUMBER_THEME } from './theme.js' +import type { Options } from './types.js' + +describe('SummaryPrinter', async () => { + const ndjsonFiles = globbySync(`*.ndjson`, { + cwd: path.join(import.meta.dirname, '..', '..', 'testdata', 'src'), + absolute: true, + }) + + const variants: ReadonlyArray<{ name: string; options: Options }> = [ + { + name: 'cucumber', + options: { + attachments: true, + featuresAndRules: true, + theme: CUCUMBER_THEME, + }, + }, + { + name: 'plain', + options: { + attachments: true, + featuresAndRules: true, + theme: { + status: { + icon: { + [TestStepResultStatus.AMBIGUOUS]: '✘', + [TestStepResultStatus.FAILED]: '✘', + [TestStepResultStatus.PASSED]: '✔', + [TestStepResultStatus.PENDING]: '■', + [TestStepResultStatus.SKIPPED]: '↷', + [TestStepResultStatus.UNDEFINED]: '■', + [TestStepResultStatus.UNKNOWN]: ' ', + }, + }, + }, + }, + }, + ] + + // just enough so Node.js internals consider it a color-supporting stream + const fakeStream = { + _writableState: {}, + isTTY: true, + getColorDepth: () => 3, + } as unknown as NodeJS.WritableStream + + for (const { name, options } of variants) { + describe(name, () => { + for (const ndjsonFile of ndjsonFiles) { + const [suiteName] = path.basename(ndjsonFile).split('.') + + it(suiteName, async () => { + let content = '' + const printer = new SummaryPrinter( + fakeStream, + (chunk) => { + content += chunk + }, + { + attachments: true, + featuresAndRules: true, + theme: CUCUMBER_THEME, + ...options, + } + ) + + await pipeline( + fs.createReadStream(ndjsonFile, { encoding: 'utf-8' }), + new NdjsonToMessageStream(), + new Writable({ + objectMode: true, + write(envelope: Envelope, _: BufferEncoding, callback) { + printer.update(envelope) + callback() + }, + }) + ) + + const expectedOutput = fs.readFileSync( + ndjsonFile.replace('.ndjson', `.${name}.summary.log`), + { + encoding: 'utf-8', + } + ) + + expect(content).to.eq(expectedOutput) + }) + } + }) + } +}) diff --git a/javascript/src/SummaryPrinter.ts b/javascript/src/SummaryPrinter.ts new file mode 100644 index 0000000..33d0577 --- /dev/null +++ b/javascript/src/SummaryPrinter.ts @@ -0,0 +1,228 @@ +import { + Envelope, + Location, + Pickle, + TestCaseFinished, + TestCaseStarted, + TestRunFinished, + TestRunStarted, + TestStepResult, + TestStepResultStatus, +} from '@cucumber/messages' +import { Query } from '@cucumber/query' + +import { + ensure, + ERROR_INDENT_LENGTH, + formatCounts, + formatDuration, + formatNonPassingTitle, + formatPickleLocation, + formatTestStepResultError, + GHERKIN_INDENT_LENGTH, + indent, +} from './helpers.js' +import type { Options } from './types.js' + +export class SummaryPrinter { + private readonly println: (content?: string) => void + private readonly query: Query = new Query() + + constructor( + private readonly stream: NodeJS.WritableStream, + private readonly print: (content: string) => void, + private readonly options: Required + ) { + this.println = (content: string = '') => this.print(`${content}\n`) + } + + update(message: Envelope) { + this.query.update(message) + + if (message.testRunFinished) { + this.printSummary() + } + } + + private printSummary() { + this.printNonPassingScenarios() + this.printStats() + this.printSnippets() + } + + private printStats() { + this.println() + this.printGlobalHookCounts() + this.printScenarioCounts() + this.printStepCounts() + this.printDuration() + } + + private printNonPassingScenarios() { + const theOrder: TestStepResultStatus[] = [ + TestStepResultStatus.UNKNOWN, + TestStepResultStatus.PENDING, + TestStepResultStatus.UNDEFINED, + TestStepResultStatus.AMBIGUOUS, + TestStepResultStatus.FAILED, + ] + + const picklesByStatus = new Map< + TestStepResultStatus, + Array<{ + pickle: Pickle + location: Location | undefined + testCaseStarted: TestCaseStarted + testCaseFinished: TestCaseFinished + testStepResult: TestStepResult + }> + >() + + for (const testCaseFinished of this.query.findAllTestCaseFinished()) { + const testCaseStarted = ensure( + this.query.findTestCaseStartedBy(testCaseFinished), + 'TestCaseStarted must exist for TestCaseFinished' + ) + const pickle = ensure( + this.query.findPickleBy(testCaseFinished), + 'Pickle must exist for TestCaseFinished' + ) + const location = this.query.findLocationOf(pickle) + const testStepResult = this.query.findMostSevereTestStepResultBy(testCaseFinished) + if (testStepResult) { + if (!picklesByStatus.has(testStepResult.status)) { + picklesByStatus.set(testStepResult.status, []) + } + picklesByStatus.get(testStepResult.status)!.push({ + pickle, + location, + testCaseStarted, + testCaseFinished, + testStepResult, + }) + } + } + + for (const status of theOrder) { + const picklesForThisStatus = picklesByStatus.get(status) ?? [] + if (picklesForThisStatus.length > 0) { + this.println() + this.println(formatNonPassingTitle(status, this.options.theme, this.stream)) + picklesForThisStatus.forEach( + ({ pickle, location, testCaseStarted, testStepResult }, index) => { + const formattedLocation = formatPickleLocation( + pickle, + location, + this.options.theme, + this.stream + ) + const formattedAttempt = + testCaseStarted.attempt > 0 ? `, after ${testCaseStarted.attempt + 1} attempts` : '' + this.println( + indent( + `${index + 1}) ${pickle.name}${formattedAttempt} ${formattedLocation}`, + GHERKIN_INDENT_LENGTH + ) + ) + if (status === TestStepResultStatus.FAILED) { + const content = formatTestStepResultError( + testStepResult, + this.options.theme, + this.stream + ) + if (content) { + this.println(indent(content, GHERKIN_INDENT_LENGTH + ERROR_INDENT_LENGTH + 1)) + this.println() + } + } + } + ) + } + } + } + + private printGlobalHookCounts() { + const testRunHookFinished = this.query.findAllTestRunHookFinished() + if (testRunHookFinished.length === 0) { + return + } + + const globalHookCountsByStatus = testRunHookFinished + .map((testRunHookFinished) => testRunHookFinished.result.status) + .reduce( + (prev, status) => { + return { + ...prev, + [status]: (prev[status] ?? 0) + 1, + } + }, + {} as Partial> + ) + this.println(formatCounts('hooks', globalHookCountsByStatus, this.options.theme, this.stream)) + } + + private printScenarioCounts() { + const scenarioCountsByStatus = this.query + .findAllTestCaseFinished() + .map((testCaseFinished) => this.query.findMostSevereTestStepResultBy(testCaseFinished)) + .map((testStepResult) => testStepResult?.status ?? TestStepResultStatus.PASSED) + .reduce( + (prev, status) => { + return { + ...prev, + [status]: (prev[status] ?? 0) + 1, + } + }, + {} as Partial> + ) + this.println(formatCounts('scenarios', scenarioCountsByStatus, this.options.theme, this.stream)) + } + + private printStepCounts() { + const stepCountsByStatus = this.query + .findAllTestCaseFinished() + .flatMap((testCaseFinished) => this.query.findTestStepsFinishedBy(testCaseFinished)) + .map((testStepFinished) => testStepFinished.testStepResult.status) + .reduce( + (prev, status) => { + return { + ...prev, + [status]: (prev[status] ?? 0) + 1, + } + }, + {} as Partial> + ) + this.println(formatCounts('steps', stepCountsByStatus, this.options.theme, this.stream)) + } + + private printDuration() { + const testRunStarted = this.query.findTestRunStarted() as TestRunStarted + const testRunFinished = this.query.findTestRunFinished() as TestRunFinished + + this.println(formatDuration(testRunStarted.timestamp, testRunFinished.timestamp)) + } + + private printSnippets() { + const snippets = this.query + .findAllTestCaseFinished() + .map((testCaseFinished) => this.query.findPickleBy(testCaseFinished)) + .filter((pickle) => !!pickle) + .sort((a, b) => { + // TODO compare by location too + return a?.uri.localeCompare(b?.uri || '') || 0 + }) + .flatMap((pickle) => this.query.findSuggestionsBy(pickle)) + .flatMap((suggestion) => suggestion.snippets) + .map((snippet) => snippet.code) + const uniqueSnippets = new Set(snippets) + if (uniqueSnippets.size > 0) { + this.println() + this.println('You can implement missing steps with the snippets below:') + this.println() + for (const snippet of uniqueSnippets) { + this.println(snippet) + this.println() + } + } + } +} diff --git a/javascript/src/helpers.ts b/javascript/src/helpers.ts index b42253d..2b7ca68 100644 --- a/javascript/src/helpers.ts +++ b/javascript/src/helpers.ts @@ -17,7 +17,10 @@ import { TestStep, TestStepResult, TestStepResultStatus, + TimeConversion, + Timestamp, } from '@cucumber/messages' +import { Interval } from 'luxon' import { TextBuilder } from './TextBuilder.js' import { Theme } from './types.js' @@ -27,6 +30,26 @@ export const STEP_ARGUMENT_INDENT_LENGTH = 2 export const ATTACHMENT_INDENT_LENGTH = 4 export const ERROR_INDENT_LENGTH = 4 +const STATUS_ORDER: TestStepResultStatus[] = [ + TestStepResultStatus.UNKNOWN, + TestStepResultStatus.PASSED, + TestStepResultStatus.SKIPPED, + TestStepResultStatus.PENDING, + TestStepResultStatus.UNDEFINED, + TestStepResultStatus.AMBIGUOUS, + TestStepResultStatus.FAILED, +] +const STATUS_CHARACTERS: Record = { + [TestStepResultStatus.AMBIGUOUS]: 'A', + [TestStepResultStatus.FAILED]: 'F', + [TestStepResultStatus.PASSED]: '.', + [TestStepResultStatus.PENDING]: 'P', + [TestStepResultStatus.SKIPPED]: '-', + [TestStepResultStatus.UNDEFINED]: 'U', + [TestStepResultStatus.UNKNOWN]: '?', +} as const +const DURATION_FORMAT = "m'm' s.S's'" + export function ensure(value: T | undefined, message: string): T { if (!value) { throw new Error(message) @@ -72,6 +95,7 @@ export function formatPickleTags(pickle: Pickle, theme: Theme, stream: NodeJS.Wr .build(theme.tag) } } + export function formatPickleTitle( pickle: Pickle, scenario: Scenario, @@ -307,3 +331,61 @@ function formatBase64Attachment( function formatTextAttachment(content: string, theme: Theme, stream: NodeJS.WritableStream) { return new TextBuilder(stream).append(content).build(theme.attachment) } + +export function formatStatusCharacter( + status: TestStepResultStatus, + theme: Theme, + stream: NodeJS.WritableStream +) { + const character = STATUS_CHARACTERS[status] + return new TextBuilder(stream).append(character).build(theme.status?.all?.[status]) +} + +export function formatNonPassingTitle( + status: TestStepResultStatus, + theme: Theme, + stream: NodeJS.WritableStream +) { + return new TextBuilder(stream) + .append(status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()) + .append(' scenarios:') + .build(theme.status?.all?.[status]) +} + +export function formatCounts( + suffix: string, + counts: Partial>, + theme: Theme, + stream: NodeJS.WritableStream +) { + const builder = new TextBuilder(stream) + const total = Object.values(counts).reduce((prev, curr) => prev + curr, 0) + builder.append(`${total} ${suffix}`) + if (total > 0) { + let first = true + builder.append(' (') + for (const status of STATUS_ORDER) { + const count = counts[status] + if (count) { + if (!first) { + builder.append(', ') + } + builder.append(`${count} ${status.toLowerCase()}`, theme.status?.all?.[status]) + first = false + } + } + builder.append(')') + } + return builder.build() +} + +export function formatDuration(start: Timestamp, finish: Timestamp) { + const startMillis = new Date(TimeConversion.timestampToMillisecondsSinceEpoch(start)) + const finishMillis = new Date(TimeConversion.timestampToMillisecondsSinceEpoch(finish)) + const duration = Interval.fromDateTimes(startMillis, finishMillis).toDuration([ + 'minutes', + 'seconds', + 'milliseconds', + ]) + return duration.toFormat(DURATION_FORMAT) +} diff --git a/testdata/src/retry.cucumber.summary.log b/testdata/src/retry.cucumber.summary.log index d21711c..a95a1e1 100644 --- a/testdata/src/retry.cucumber.summary.log +++ b/testdata/src/retry.cucumber.summary.log @@ -1,6 +1,6 @@ Failed scenarios: - 1) Test cases won't retry after failing more than the --retry limit, after 2 attempts # samples/retry/retry.feature:17 + 1) Test cases won't retry after failing more than the --retry limit, after 3 attempts # samples/retry/retry.feature:17 Error: Exception in step samples/retry/retry.feature:18 diff --git a/testdata/src/retry.plain.summary.log b/testdata/src/retry.plain.summary.log index 48f76d7..5052a62 100644 --- a/testdata/src/retry.plain.summary.log +++ b/testdata/src/retry.plain.summary.log @@ -1,6 +1,6 @@ Failed scenarios: - 1) Test cases won't retry after failing more than the --retry limit, after 2 attempts # samples/retry/retry.feature:17 + 1) Test cases won't retry after failing more than the --retry limit, after 3 attempts # samples/retry/retry.feature:17 Error: Exception in step samples/retry/retry.feature:18