Skip to content

Commit

Permalink
Allow config overrides per suite / test
Browse files Browse the repository at this point in the history
This fixes #697.
  • Loading branch information
badeball committed Jun 19, 2022
1 parent bba0576 commit 381630f
Show file tree
Hide file tree
Showing 10 changed files with 435 additions and 1 deletion.
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ Every configuration option has a similar key which can be use to override it, sh
| `json.output` | `jsonOutput` | `cucumber-report.json` |
| `filterSpecs` | `filterSpecs` | `true`, `false` |
| `omitFiltered` | `omitFiltered` | `true`, `false` |

## Test configuration

Some of Cypress' [configuration options](https://docs.cypress.io/guides/references/configuration) can be overridden per-test, [Test configuration](test-configuration.md).
1 change: 1 addition & 0 deletions docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
* [JSON report](json-report.md)
* [Localisation](localisation.md)
* [Configuration](configuration.md)
* [Test configuration](test-configuration.md)
* [Frequently asked questions](faq.md)
37 changes: 37 additions & 0 deletions docs/test-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Test configuration

Some of Cypress' [configuration options](https://docs.cypress.io/guides/references/configuration) can be overridden per-test by leveraging tags. Below are all supported configuration options shown.

```gherkin
@animationDistanceThreshold(5)
@blockHosts('http://www.foo.com','http://www.bar.com')
@defaultCommandTimeout(5)
@execTimeout(5)
@includeShadowDom(true)
@includeShadowDom(false)
@keystrokeDelay(5)
@numTestsKeptInMemory(5)
@pageLoadTimeout(5)
@redirectionLimit(5)
@requestTimeout(5)
@responseTimeout(5)
@retries(5)
@retries(runMode=5)
@retries(openMode=5)
@retries(runMode=5,openMode=10)
@retries(openMode=10,runMode=5)
@screenshotOnRunFailure(true)
@screenshotOnRunFailure(false)
@scrollBehavior('center')
@scrollBehavior('top')
@scrollBehavior('bottom')
@scrollBehavior('nearest')
@slowTestThreshold(5)
@viewportHeight(720)
@viewportWidth(1280)
@waitForAnimations(true)
@waitForAnimations(false)
Feature: a feature
Scenario: a scenario
Given a table step
```
21 changes: 21 additions & 0 deletions features/suite_options.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Feature: suite options
Scenario: suite specific retry
Given a file named "cypress/e2e/a.feature" with:
"""
@retries(2)
Feature: a feature
Scenario: a scenario
Given a step
"""
And a file named "cypress/support/step_definitions/steps.js" with:
"""
const { Given } = require("@badeball/cypress-cucumber-preprocessor");
let attempt = 0;
Given("a step", () => {
if (attempt++ === 0) {
throw "some error";
}
});
"""
When I run cypress
Then it passes
9 changes: 8 additions & 1 deletion lib/create-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { getTags } from "./environment-helpers";

import { notNull } from "./type-guards";

import { looksLikeOptions, tagToCypressOptions } from "./tag-parser";

declare global {
namespace globalThis {
var __cypress_cucumber_preprocessor_dont_use_this: true | undefined;
Expand Down Expand Up @@ -347,7 +349,12 @@ function createPickle(

const env = { [INTERNAL_PROPERTY_NAME]: internalProperties };

it(scenarioName, { env }, function () {
const suiteOptions = tags
.filter(looksLikeOptions)
.map(tagToCypressOptions)
.reduce(Object.assign);

it(scenarioName, { env, ...suiteOptions }, function () {
const { remainingSteps, testCaseStartedId } = retrieveInternalProperties();

assignRegistry(registry);
Expand Down
60 changes: 60 additions & 0 deletions lib/tag-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import util from "util";

import assert from "assert";

import { tagToCypressOptions } from "./tag-parser";

function example(tag: string, expectedOptions: Cypress.TestConfigOverrides) {
it(`should return ${util.inspect(expectedOptions)} for ${tag}`, () => {
const actualOptions = tagToCypressOptions(tag);

assert.deepStrictEqual(actualOptions, expectedOptions);
});
}

describe("tagToCypressOptions", () => {
example("@animationDistanceThreshold(5)", { animationDistanceThreshold: 5 });
// example("@baseUrl('http://www.foo.com')'", { baseUrl: "http://www.foo.com" });
example("@blockHosts('http://www.foo.com')", {
blockHosts: "http://www.foo.com",
});
example("@blockHosts('http://www.foo.com','http://www.bar.com')", {
blockHosts: ["http://www.foo.com", "http://www.bar.com"],
});
example("@defaultCommandTimeout(5)", { defaultCommandTimeout: 5 });
example("@execTimeout(5)", { execTimeout: 5 });
// example("@experimentalSessionAndOrigin(true)", {
// experimentalSessionAndOrigin: 5,
// });
// example("@experimentalSessionAndOrigin(false)", {
// experimentalSessionAndOrigin: 5,
// });
example("@includeShadowDom(true)", { includeShadowDom: true });
example("@includeShadowDom(false)", { includeShadowDom: false });
example("@keystrokeDelay(5)", { keystrokeDelay: 5 });
example("@numTestsKeptInMemory(5)", { numTestsKeptInMemory: 5 });
example("@pageLoadTimeout(5)", { pageLoadTimeout: 5 });
example("@redirectionLimit(5)", { redirectionLimit: 5 });
example("@requestTimeout(5)", { requestTimeout: 5 });
example("@responseTimeout(5)", { responseTimeout: 5 });
example("@retries(5)", { retries: 5 });
example("@retries(runMode=5)", { retries: { runMode: 5 } });
example("@retries(openMode=5)", { retries: { openMode: 5 } });
example("@retries(runMode=5,openMode=10)", {
retries: { runMode: 5, openMode: 10 },
});
example("@retries(openMode=10,runMode=5)", {
retries: { runMode: 5, openMode: 10 },
});
example("@screenshotOnRunFailure(true)", { screenshotOnRunFailure: true });
example("@screenshotOnRunFailure(false)", { screenshotOnRunFailure: false });
example("@scrollBehavior('center')", { scrollBehavior: "center" });
example("@scrollBehavior('top')", { scrollBehavior: "top" });
example("@scrollBehavior('bottom')", { scrollBehavior: "bottom" });
example("@scrollBehavior('nearest')", { scrollBehavior: "nearest" });
example("@slowTestThreshold(5)", { slowTestThreshold: 5 });
example("@viewportHeight(720)", { viewportHeight: 720 });
example("@viewportWidth(1280)", { viewportWidth: 1280 });
example("@waitForAnimations(true)", { waitForAnimations: true });
example("@waitForAnimations(false)", { waitForAnimations: false });
});
5 changes: 5 additions & 0 deletions lib/tag-parser/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class TagError extends Error {}

export class TagTokenizerError extends TagError {}

export class TagParserError extends TagError {}
9 changes: 9 additions & 0 deletions lib/tag-parser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Parser from "./parser";

export function tagToCypressOptions(tag: string): Cypress.TestConfigOverrides {
return new Parser(tag).parse();
}

export function looksLikeOptions(tag: string) {
return tag.includes("(");
}
196 changes: 196 additions & 0 deletions lib/tag-parser/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { TagParserError } from "./errors";

import {
isAt,
isClosingParanthesis,
isComma,
isDigit,
isEqual,
isOpeningParanthesis,
isQuote,
isWordChar,
Tokenizer,
} from "./tokenizer";

function createUnexpectedEndOfString() {
return new TagParserError("Unexpected end-of-string");
}

function createUnexpectedToken(
token: TYield<TokenGenerator>,
expectation: string
) {
return new Error(
`Unexpected token at ${token.position}: ${token.value} (${expectation})`
);
}

function expectToken(token: Token) {
if (token.done) {
throw createUnexpectedEndOfString();
}

return token;
}

function parsePrimitiveToken(token: Token) {
if (token.done) {
throw createUnexpectedEndOfString();
}

const value = token.value.value;

const char = value[0];

if (value === "false") {
return false;
} else if (value === "true") {
return true;
}
if (isDigit(char)) {
return parseInt(value);
} else if (isQuote(char)) {
return value.slice(1, -1);
} else {
throw createUnexpectedToken(
token.value,
"expected a string, a boolean or a number"
);
}
}

type TYield<T> = T extends Generator<infer R, any, any> ? R : never;

type TReturn<T> = T extends Generator<any, infer R, any> ? R : never;

interface IteratorYieldResult<TYield> {
done?: false;
value: TYield;
}

interface IteratorReturnResult<TReturn> {
done: true;
value: TReturn;
}

type IteratorResult<T> =
| IteratorYieldResult<TYield<T>>
| IteratorReturnResult<TReturn<T>>;

type TokenGenerator = ReturnType<typeof Tokenizer.prototype["tokens"]>;

type Token = IteratorResult<TokenGenerator>;

class BufferedGenerator<T, TReturn, TNext> {
private tokens: (IteratorYieldResult<T> | IteratorReturnResult<TReturn>)[] =
[];

private position = -1;

constructor(generator: Generator<T, TReturn, TNext>) {
do {
this.tokens.push(generator.next());
} while (!this.tokens[this.tokens.length - 1].done);
}

next() {
if (this.position < this.tokens.length - 1) {
this.position++;
}

return this.tokens[this.position];
}

peak(n: number = 1) {
return this.tokens[this.position + n];
}
}

type Primitive = string | boolean | number;

export default class Parser {
public constructor(private content: string) {}

parse(): Record<string, Primitive | Primitive[] | Record<string, Primitive>> {
const tokens = new BufferedGenerator(new Tokenizer(this.content).tokens());

let next: Token = expectToken(tokens.next());

if (!isAt(next.value.value)) {
throw createUnexpectedToken(next.value, "expected tag to begin with '@'");
}

next = expectToken(tokens.next());

if (!isWordChar(next.value.value[0])) {
throw createUnexpectedToken(
next.value,
"expected tag to start with a property name"
);
}

const propertyName = next.value.value;

next = expectToken(tokens.next());

if (!isOpeningParanthesis(next.value.value)) {
throw createUnexpectedToken(next.value, "expected opening paranthesis");
}

const isObjectMode = isEqual(expectToken(tokens.peak(2)).value.value);
const entries: [string, Primitive][] = [];
const values: Primitive[] = [];

if (isObjectMode) {
while (true) {
const key = expectToken(tokens.next()).value.value;

next = expectToken(tokens.next());

if (!isEqual(next.value.value)) {
throw createUnexpectedToken(next.value, "expected equal sign");
}

const value = parsePrimitiveToken(tokens.next());

entries.push([key, value]);

if (!isComma(expectToken(tokens.peak()).value.value)) {
break;
} else {
tokens.next();
}
}
} else {
while (true) {
const value = parsePrimitiveToken(tokens.next());

values.push(value);

if (!isComma(expectToken(tokens.peak()).value.value)) {
break;
} else {
tokens.next();
}
}
}

next = expectToken(tokens.next());

if (next.done) {
throw createUnexpectedEndOfString();
} else if (!isClosingParanthesis(next.value.value)) {
throw createUnexpectedToken(next.value, "expected closing paranthesis");
}

if (isObjectMode) {
return {
[propertyName]: Object.fromEntries(entries),
};
} else {
return {
[propertyName]: values.length === 1 ? values[0] : values,
};
}
}
}
Loading

0 comments on commit 381630f

Please sign in to comment.