Skip to content

Commit 760342a

Browse files
committed
Allow config overrides per suite / test
This fixes #697.
1 parent bba0576 commit 760342a

File tree

10 files changed

+435
-1
lines changed

10 files changed

+435
-1
lines changed

docs/configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,7 @@ Every configuration option has a similar key which can be use to override it, sh
5151
| `json.output` | `jsonOutput` | `cucumber-report.json` |
5252
| `filterSpecs` | `filterSpecs` | `true`, `false` |
5353
| `omitFiltered` | `omitFiltered` | `true`, `false` |
54+
55+
## Test configuration
56+
57+
Some of Cypress' [configuration options](https://docs.cypress.io/guides/references/configuration) can be overridden per-test, [Test configuration](test-configuration.md).

docs/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
* [JSON report](json-report.md)
99
* [Localisation](localisation.md)
1010
* [Configuration](configuration.md)
11+
* [Test configuration](test-configuration.md)
1112
* [Frequently asked questions](faq.md)

docs/test-configuration.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Test configuration
2+
3+
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.
4+
5+
```gherkin
6+
@animationDistanceThreshold(5)
7+
@blockHosts('http://www.foo.com','http://www.bar.com')
8+
@defaultCommandTimeout(5)
9+
@execTimeout(5)
10+
@includeShadowDom(true)
11+
@includeShadowDom(false)
12+
@keystrokeDelay(5)
13+
@numTestsKeptInMemory(5)
14+
@pageLoadTimeout(5)
15+
@redirectionLimit(5)
16+
@requestTimeout(5)
17+
@responseTimeout(5)
18+
@retries(5)
19+
@retries(runMode=5)
20+
@retries(openMode=5)
21+
@retries(runMode=5,openMode=10)
22+
@retries(openMode=10,runMode=5)
23+
@screenshotOnRunFailure(true)
24+
@screenshotOnRunFailure(false)
25+
@scrollBehavior('center')
26+
@scrollBehavior('top')
27+
@scrollBehavior('bottom')
28+
@scrollBehavior('nearest')
29+
@slowTestThreshold(5)
30+
@viewportHeight(720)
31+
@viewportWidth(1280)
32+
@waitForAnimations(true)
33+
@waitForAnimations(false)
34+
Feature: a feature
35+
Scenario: a scenario
36+
Given a table step
37+
```

features/suite_options.feature

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
Feature: suite options
2+
Scenario: suite specific retry
3+
Given a file named "cypress/e2e/a.feature" with:
4+
"""
5+
@retries(2)
6+
Feature: a feature
7+
Scenario: a scenario
8+
Given a step
9+
"""
10+
And a file named "cypress/support/step_definitions/steps.js" with:
11+
"""
12+
const { Given } = require("@badeball/cypress-cucumber-preprocessor");
13+
let attempt = 0;
14+
Given("a step", () => {
15+
if (attempt++ === 0) {
16+
throw "some error";
17+
}
18+
});
19+
"""
20+
When I run cypress
21+
Then it passes

lib/create-tests.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { getTags } from "./environment-helpers";
2525

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

28+
import { looksLikeOptions, tagToCypressOptions } from "./tag-parser";
29+
2830
declare global {
2931
namespace globalThis {
3032
var __cypress_cucumber_preprocessor_dont_use_this: true | undefined;
@@ -347,7 +349,12 @@ function createPickle(
347349

348350
const env = { [INTERNAL_PROPERTY_NAME]: internalProperties };
349351

350-
it(scenarioName, { env }, function () {
352+
const suiteOptions = tags
353+
.filter(looksLikeOptions)
354+
.map(tagToCypressOptions)
355+
.reduce(Object.assign, {});
356+
357+
it(scenarioName, { env, ...suiteOptions }, function () {
351358
const { remainingSteps, testCaseStartedId } = retrieveInternalProperties();
352359

353360
assignRegistry(registry);

lib/tag-parser.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import util from "util";
2+
3+
import assert from "assert";
4+
5+
import { tagToCypressOptions } from "./tag-parser";
6+
7+
function example(tag: string, expectedOptions: any) {
8+
it(`should return ${util.inspect(expectedOptions)} for ${tag}`, () => {
9+
const actualOptions = tagToCypressOptions(tag);
10+
11+
assert.deepStrictEqual(actualOptions, expectedOptions);
12+
});
13+
}
14+
15+
describe("tagToCypressOptions", () => {
16+
example("@animationDistanceThreshold(5)", { animationDistanceThreshold: 5 });
17+
// example("@baseUrl('http://www.foo.com')'", { baseUrl: "http://www.foo.com" });
18+
example("@blockHosts('http://www.foo.com')", {
19+
blockHosts: "http://www.foo.com",
20+
});
21+
example("@blockHosts('http://www.foo.com','http://www.bar.com')", {
22+
blockHosts: ["http://www.foo.com", "http://www.bar.com"],
23+
});
24+
example("@defaultCommandTimeout(5)", { defaultCommandTimeout: 5 });
25+
example("@execTimeout(5)", { execTimeout: 5 });
26+
// example("@experimentalSessionAndOrigin(true)", {
27+
// experimentalSessionAndOrigin: 5,
28+
// });
29+
// example("@experimentalSessionAndOrigin(false)", {
30+
// experimentalSessionAndOrigin: 5,
31+
// });
32+
example("@includeShadowDom(true)", { includeShadowDom: true });
33+
example("@includeShadowDom(false)", { includeShadowDom: false });
34+
example("@keystrokeDelay(5)", { keystrokeDelay: 5 });
35+
example("@numTestsKeptInMemory(5)", { numTestsKeptInMemory: 5 });
36+
example("@pageLoadTimeout(5)", { pageLoadTimeout: 5 });
37+
example("@redirectionLimit(5)", { redirectionLimit: 5 });
38+
example("@requestTimeout(5)", { requestTimeout: 5 });
39+
example("@responseTimeout(5)", { responseTimeout: 5 });
40+
example("@retries(5)", { retries: 5 });
41+
example("@retries(runMode=5)", { retries: { runMode: 5 } });
42+
example("@retries(openMode=5)", { retries: { openMode: 5 } });
43+
example("@retries(runMode=5,openMode=10)", {
44+
retries: { runMode: 5, openMode: 10 },
45+
});
46+
example("@retries(openMode=10,runMode=5)", {
47+
retries: { runMode: 5, openMode: 10 },
48+
});
49+
example("@screenshotOnRunFailure(true)", { screenshotOnRunFailure: true });
50+
example("@screenshotOnRunFailure(false)", { screenshotOnRunFailure: false });
51+
example("@scrollBehavior('center')", { scrollBehavior: "center" });
52+
example("@scrollBehavior('top')", { scrollBehavior: "top" });
53+
example("@scrollBehavior('bottom')", { scrollBehavior: "bottom" });
54+
example("@scrollBehavior('nearest')", { scrollBehavior: "nearest" });
55+
example("@slowTestThreshold(5)", { slowTestThreshold: 5 });
56+
example("@viewportHeight(720)", { viewportHeight: 720 });
57+
example("@viewportWidth(1280)", { viewportWidth: 1280 });
58+
example("@waitForAnimations(true)", { waitForAnimations: true });
59+
example("@waitForAnimations(false)", { waitForAnimations: false });
60+
});

lib/tag-parser/errors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class TagError extends Error {}
2+
3+
export class TagTokenizerError extends TagError {}
4+
5+
export class TagParserError extends TagError {}

lib/tag-parser/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Parser from "./parser";
2+
3+
export function tagToCypressOptions(tag: string): Cypress.TestConfigOverrides {
4+
return new Parser(tag).parse();
5+
}
6+
7+
export function looksLikeOptions(tag: string) {
8+
return tag.includes("(");
9+
}

lib/tag-parser/parser.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { TagParserError } from "./errors";
2+
3+
import {
4+
isAt,
5+
isClosingParanthesis,
6+
isComma,
7+
isDigit,
8+
isEqual,
9+
isOpeningParanthesis,
10+
isQuote,
11+
isWordChar,
12+
Tokenizer,
13+
} from "./tokenizer";
14+
15+
function createUnexpectedEndOfString() {
16+
return new TagParserError("Unexpected end-of-string");
17+
}
18+
19+
function createUnexpectedToken(
20+
token: TYield<TokenGenerator>,
21+
expectation: string
22+
) {
23+
return new Error(
24+
`Unexpected token at ${token.position}: ${token.value} (${expectation})`
25+
);
26+
}
27+
28+
function expectToken(token: Token) {
29+
if (token.done) {
30+
throw createUnexpectedEndOfString();
31+
}
32+
33+
return token;
34+
}
35+
36+
function parsePrimitiveToken(token: Token) {
37+
if (token.done) {
38+
throw createUnexpectedEndOfString();
39+
}
40+
41+
const value = token.value.value;
42+
43+
const char = value[0];
44+
45+
if (value === "false") {
46+
return false;
47+
} else if (value === "true") {
48+
return true;
49+
}
50+
if (isDigit(char)) {
51+
return parseInt(value);
52+
} else if (isQuote(char)) {
53+
return value.slice(1, -1);
54+
} else {
55+
throw createUnexpectedToken(
56+
token.value,
57+
"expected a string, a boolean or a number"
58+
);
59+
}
60+
}
61+
62+
type TYield<T> = T extends Generator<infer R, any, any> ? R : never;
63+
64+
type TReturn<T> = T extends Generator<any, infer R, any> ? R : never;
65+
66+
interface IteratorYieldResult<TYield> {
67+
done?: false;
68+
value: TYield;
69+
}
70+
71+
interface IteratorReturnResult<TReturn> {
72+
done: true;
73+
value: TReturn;
74+
}
75+
76+
type IteratorResult<T> =
77+
| IteratorYieldResult<TYield<T>>
78+
| IteratorReturnResult<TReturn<T>>;
79+
80+
type TokenGenerator = ReturnType<typeof Tokenizer.prototype["tokens"]>;
81+
82+
type Token = IteratorResult<TokenGenerator>;
83+
84+
class BufferedGenerator<T, TReturn, TNext> {
85+
private tokens: (IteratorYieldResult<T> | IteratorReturnResult<TReturn>)[] =
86+
[];
87+
88+
private position = -1;
89+
90+
constructor(generator: Generator<T, TReturn, TNext>) {
91+
do {
92+
this.tokens.push(generator.next());
93+
} while (!this.tokens[this.tokens.length - 1].done);
94+
}
95+
96+
next() {
97+
if (this.position < this.tokens.length - 1) {
98+
this.position++;
99+
}
100+
101+
return this.tokens[this.position];
102+
}
103+
104+
peak(n: number = 1) {
105+
return this.tokens[this.position + n];
106+
}
107+
}
108+
109+
type Primitive = string | boolean | number;
110+
111+
export default class Parser {
112+
public constructor(private content: string) {}
113+
114+
parse(): Record<string, Primitive | Primitive[] | Record<string, Primitive>> {
115+
const tokens = new BufferedGenerator(new Tokenizer(this.content).tokens());
116+
117+
let next: Token = expectToken(tokens.next());
118+
119+
if (!isAt(next.value.value)) {
120+
throw createUnexpectedToken(next.value, "expected tag to begin with '@'");
121+
}
122+
123+
next = expectToken(tokens.next());
124+
125+
if (!isWordChar(next.value.value[0])) {
126+
throw createUnexpectedToken(
127+
next.value,
128+
"expected tag to start with a property name"
129+
);
130+
}
131+
132+
const propertyName = next.value.value;
133+
134+
next = expectToken(tokens.next());
135+
136+
if (!isOpeningParanthesis(next.value.value)) {
137+
throw createUnexpectedToken(next.value, "expected opening paranthesis");
138+
}
139+
140+
const isObjectMode = isEqual(expectToken(tokens.peak(2)).value.value);
141+
const entries: [string, Primitive][] = [];
142+
const values: Primitive[] = [];
143+
144+
if (isObjectMode) {
145+
while (true) {
146+
const key = expectToken(tokens.next()).value.value;
147+
148+
next = expectToken(tokens.next());
149+
150+
if (!isEqual(next.value.value)) {
151+
throw createUnexpectedToken(next.value, "expected equal sign");
152+
}
153+
154+
const value = parsePrimitiveToken(tokens.next());
155+
156+
entries.push([key, value]);
157+
158+
if (!isComma(expectToken(tokens.peak()).value.value)) {
159+
break;
160+
} else {
161+
tokens.next();
162+
}
163+
}
164+
} else {
165+
while (true) {
166+
const value = parsePrimitiveToken(tokens.next());
167+
168+
values.push(value);
169+
170+
if (!isComma(expectToken(tokens.peak()).value.value)) {
171+
break;
172+
} else {
173+
tokens.next();
174+
}
175+
}
176+
}
177+
178+
next = expectToken(tokens.next());
179+
180+
if (next.done) {
181+
throw createUnexpectedEndOfString();
182+
} else if (!isClosingParanthesis(next.value.value)) {
183+
throw createUnexpectedToken(next.value, "expected closing paranthesis");
184+
}
185+
186+
if (isObjectMode) {
187+
return {
188+
[propertyName]: Object.fromEntries(entries),
189+
};
190+
} else {
191+
return {
192+
[propertyName]: values.length === 1 ? values[0] : values,
193+
};
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)