Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: update cucumber format parsing #284

Merged
merged 24 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 77 additions & 12 deletions src/cucumber-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,32 @@ import type { CucumberRunnerConfig } from './types';
import * as utils from './utils';
import { NodeContext } from 'sauce-testrunner-utils/lib/types';

function buildArgs(runCfg: CucumberRunnerConfig, cucumberBin: string) {
export function buildArgs(runCfg: CucumberRunnerConfig, cucumberBin: string) {
const paths: string[] = [];
runCfg.suite.options.paths.forEach((p) => {
paths.push(path.join(runCfg.projectPath, p));
});
const procArgs = [
cucumberBin,
...paths,
'--publish-quiet', // Deprecated in 9.4.0. Will be removed in 11.0.0 or later.
'--force-exit',
'--require-module',
'ts-node/register',
// NOTE: The Cucumber formatter (--format) option uses the "type":"path" format.
// If the "path" is not specified, the output defaults to stdout.
// Cucumber supports only one stdout formatter; if multiple are specified,
// the last one listed takes precedence.
//
// To ensure the Sauce test report file is reliably generated and not overridden
// by a user-specified stdout formatter, configure the following:
// 1. In the --format option, set the output to a file (e.g., cucumber.log) to
// avoid writing to stdout.
// 2. Use the --format-options flag to explicitly specify the outputFile
// (e.g., sauce-test-report.json).
//
// Both settings must be configured correctly to ensure the Sauce test report file is generated.
'--format',
'@saucelabs/cucumber-reporter',
'"@saucelabs/cucumber-reporter":"cucumber.log"',
'--format-options',
JSON.stringify(buildFormatOption(runCfg)),
];
Expand Down Expand Up @@ -50,15 +62,12 @@ function buildArgs(runCfg: CucumberRunnerConfig, cucumberBin: string) {
procArgs.push('-t');
procArgs.push(tag);
});

runCfg.suite.options.format?.forEach((format) => {
procArgs.push('--format');
const opts = format.split(':');
if (opts.length === 2) {
procArgs.push(`${opts[0]}:${path.join(runCfg.assetsDir, opts[1])}`);
} else {
procArgs.push(format);
}
procArgs.push(normalizeFormat(format, runCfg.assetsDir));
});

if (runCfg.suite.options.parallel) {
procArgs.push('--parallel');
procArgs.push(runCfg.suite.options.parallel.toString(10));
Expand All @@ -67,6 +76,64 @@ function buildArgs(runCfg: CucumberRunnerConfig, cucumberBin: string) {
return procArgs;
}

/**
* Normalizes a Cucumber-js format string.
*
* This function handles structured inputs in the format `key:value`, `"key:value"`,
* or `"key":"value"` and returns a normalized string in the form `"key":"value"`.
* For simple inputs (e.g., `usage`) or unstructured formats, the function returns the
* input unchanged.
*
* If the input starts with `file://`, an error is thrown to indicate an invalid format.
*
* @param {string} format - The input format string. Examples include:
* - `"key:value"`
* - `"key":"value"`
* - `key:value`
* - `usage`
* - `"file://implementation":"output_file"`
* @param {string} assetDir - The directory to prepend to the value for relative paths.
* @returns {string} The normalized format string.
*
* Examples:
* - Input: `"html:formatter/report.html"`, `"/project/assets"`
* Output: `"html":"/project/assets/formatter/report.html"`
* - Input: `"usage"`, `"/project/assets"`
* Output: `"usage"`
* - Input: `"file://implementation":"output_file"`, `"/project/assets"`
* Output: `"file://implementation":"output_file"` (unchanged)
*/
export function normalizeFormat(format: string, assetDir: string): string {
// Formats starting with file:// are not supported by the current implementation.
// Restrict users from using this format.
if (format.startsWith('file://')) {
throw new Error(
`Ambiguous colon usage detected. The provided format "${format}" is not allowed.`,
);
}
// Try to match structured inputs in the format key:value, "key:value", or "key":"value".
let match = format.match(/^"?([^:]+):"?([^"]+)"?$/);

if (!match) {
if (!format.startsWith('"file://')) {
return format;
}

// Match file-based structured inputs like "file://implementation":"output_file".
match = format.match(/^"([^"]+)":"([^"]+)"$/);
}

if (!match) {
return format;
}

let [, key, value] = match;
key = key.replaceAll('"', '');
value = value.replaceAll('"', '');

return `"${key}":"${path.join(assetDir, value)}"`;
}

export async function runCucumber(
nodeBin: string,
runCfg: CucumberRunnerConfig,
Expand Down Expand Up @@ -142,9 +209,7 @@ export async function runCucumber(
function buildFormatOption(cfg: CucumberRunnerConfig) {
return {
upload: false,
suiteName: cfg.suite.name,
build: cfg.sauce.metadata?.build,
tags: cfg.sauce.metadata?.tags,
outputFile: path.join(cfg.assetsDir, 'sauce-test-report.json'),
...cfg.suite.options.formatOptions,
};
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface CucumberSuite {
import?: string[];
tags?: string[];
format?: string[];
formatOptions?: { [key: string]: string };
parallel?: number;
paths: string[];
};
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/src/cucumber-runner.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const { buildArgs, normalizeFormat } = require('../../../src/cucumber-runner');

describe('buildArgs', () => {
const cucumberBin = '/usr/local/bin/cucumber';

it('should build correct arguments with basic configuration', () => {
const runCfg = {
sauce: {
metadata: {},
},
projectPath: '/project',
assetsDir: '/project/assets',
suite: {
options: {
paths: ['features/test.feature'],
formatOptions: {
myOption: 'test',
},
},
},
};

const result = buildArgs(runCfg, cucumberBin);

expect(result).toEqual([
cucumberBin,
'/project/features/test.feature',
'--force-exit',
'--require-module',
'ts-node/register',
'--format',
'"@saucelabs/cucumber-reporter":"cucumber.log"',
'--format-options',
'{"upload":false,"outputFile":"/project/assets/sauce-test-report.json","myOption":"test"}',
]);
});
});

describe('normalizeFormat', () => {
const assetDir = '/project/assets';

it('should normalize format with both quoted format type and path', () => {
expect(normalizeFormat(`"html":"formatter/report.html"`, assetDir)).toBe(
`"html":"/project/assets/formatter/report.html"`,
);
});

it('should normalize format with only one pair of quote', () => {
expect(normalizeFormat(`"html:formatter/report.html"`, assetDir)).toBe(
`"html":"/project/assets/formatter/report.html"`,
);
});

it('should normalize format with no quotes', () => {
expect(normalizeFormat(`html:formatter/report.html`, assetDir)).toBe(
`"html":"/project/assets/formatter/report.html"`,
);
});

it('should normalize format with file path type', () => {
expect(
normalizeFormat(
`"file://formatter/implementation":"report.json"`,
assetDir,
),
).toBe(`"file://formatter/implementation":"/project/assets/report.json"`);
});

it('should throw an error for an invalid file path type', () => {
expect(() => {
normalizeFormat(`file://formatter/implementation:report.json`, assetDir);
}).toThrow('Ambiguous colon usage detected');
});

it('should return simple strings as-is', () => {
expect(normalizeFormat(`"usage"`, assetDir)).toBe('"usage"');
expect(normalizeFormat(`usage`, assetDir)).toBe('usage');
});
});
Loading