Skip to content

Commit

Permalink
feat: implement --auto-cancel-after-failures (#112)
Browse files Browse the repository at this point in the history
* Offline mode (#110)

* chore: wip

* feat: allow running in offline mode

* chore: add offline e2e test

* chore: add offline e2e test

* feat: implement --auto-cancel-after-failures

* chore: fix unit test

* chore: resolve ts error

* chore: update README [skip ci]

* chore: add tests, break out into modules
  • Loading branch information
agoldis committed Apr 4, 2023
1 parent 023fc76 commit cb5b758
Show file tree
Hide file tree
Showing 22 changed files with 352 additions and 54 deletions.
31 changes: 31 additions & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,37 @@ module.exports = defineConfig({
});
```

### Setup with existing plugins

`cypress-cloud/plugin` needs access to certain environment variables that are injected into the `config` parameter of `setupNodeEvents(on, config)`.

Please make sure to preserve the original `config.env` parameters in case you are using additional plugins, e.g.:

```js
const { defineConfig } = require("cypress");
const { cloudPlugin } = require("cypress-cloud/plugin");

module.exports = defineConfig({
e2e: {
// ...
setupNodeEvents(on, config) {
// alternative: activate the plugin first
// cloudPlugin(on, config)
const enhancedConfig = {
env: {
// preserve the original env
...config.env,
customVariable: "value",
},
};
return cloudPlugin(on, enhancedConfig);
},
},
});
```

As an alternative, you can activate the `cloudPlugin` first and then implement the custom setup. Please contact our support if you have a complex plugin configuration to get assistance with the setup.

## Usage

```sh
Expand Down
4 changes: 2 additions & 2 deletions examples/webapp/cypress/e2e/clear.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ context("Clear completed button", function () {
function () {
cy.get("@todos").eq(0).find(".toggle").check();

cy.get(".clear-completed").contains("Clear completed");
cy.get(".clear-completed").contains("Clear completed X");
}
);

Expand All @@ -30,7 +30,7 @@ context("Clear completed button", function () {
cy.get(".clear-completed").click();
cy.get("@todos").should("have.length", 2);
cy.get(".todo-list li").eq(0).should("contain", TODO_ITEM_ONE);
cy.get(".todo-list li").eq(1).should("contain", TODO_ITEM_THREE);
cy.get(".todo-list li").eq(1).should("contain", "XXXX");
}
);

Expand Down
19 changes: 17 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions packages/cypress-cloud/bin/lib/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export const createProgram = (command: Command = new Command()) =>
"-t, --tag <tag>",
"comma-separated tag(s) for recorded runs in Currents",
parseCommaSeparatedList
)
.addOption(
new Option(
"--auto-cancel-after-failures <number | false>",
"Automatically abort the run after the specified number of failed tests. Overrides the default project settings. If set, must be a positive integer or 'false' to disable (Currents-only)"
).argParser(parseAutoCancelFailures)
);

export const program = createProgram();
Expand All @@ -96,3 +102,19 @@ function parseCommaSeparatedList(value: string, previous: string[] = []) {
}
return previous;
}

function parseAutoCancelFailures(value: string): number | false {
if (value === "false") {
return false;
}

const parsedValue = parseInt(value, 10);

if (isNaN(parsedValue) || parsedValue < 1) {
throw new Error(
"Invalid argument provided. Must be a positive integer or 'false'."
);
}

return parsedValue;
}
3 changes: 3 additions & 0 deletions packages/cypress-cloud/lib/api/types/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,7 @@ export type UpdateInstanceResultsMergedPayload = {
export interface UpdateInstanceResultsResponse {
videoUploadUrl?: string | null;
screenshotUploadUrls: ScreenshotUploadInstruction[];
cloud?: {
shouldCancel: false | string;
};
}
3 changes: 2 additions & 1 deletion packages/cypress-cloud/lib/api/types/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CiParams, CiProvider } from "cypress-cloud/lib/ciProvider";
import { Platform } from "cypress-cloud/types";
import { Platform, ValidatedCurrentsParameters } from "cypress-cloud/types";

export type CreateRunPayload = {
ci: {
Expand All @@ -21,6 +21,7 @@ export type CreateRunPayload = {
testingType: "e2e" | "component";
timeout?: number;
batchSize?: number;
autoCancelAfterFailures: ValidatedCurrentsParameters["autoCancelAfterFailures"];
};

export type CloudWarning = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect } from "@jest/globals";
import { describe, expect, it } from "@jest/globals";
import { ValidationError } from "cypress-cloud/lib/errors";
import { CurrentsRunParameters } from "cypress-cloud/types";
import { getCurrentsConfig } from "../config";
Expand Down Expand Up @@ -111,3 +111,40 @@ describe("validateParams", () => {
});
});
});

describe("validateParams", () => {
const baseParams: CurrentsRunParameters = {
cloudServiceUrl: "https://example.com",
projectId: "test-project",
recordKey: "test-record-key",
testingType: "e2e",
batchSize: 5,
};

it.each([
["undefined", undefined, undefined],
["true", true, 1],
["false", false, false],
["positive number", 3, 3],
])("autoCancelAfterFailures: %s", (_description, input, expected) => {
const params = { ...baseParams, autoCancelAfterFailures: input };
// @ts-ignore
const result = validateParams(params);
expect(result.autoCancelAfterFailures).toEqual(expected);
});

it.each([
["zero", 0],
["negative number", -1],
["invalid type (string)", "invalid"],
])(
"autoCancelAfterFailures: throws ValidationError for %s",
(_description, input) => {
const params = { ...baseParams, autoCancelAfterFailures: input };
expect(() => {
// @ts-ignore
validateParams(params);
}).toThrow(ValidationError);
}
);
});
12 changes: 6 additions & 6 deletions packages/cypress-cloud/lib/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ export function getCurrentsConfig(): CurrentsConfig {
if (_config) {
return _config;
}

const configFilePath = getConfigFilePath();
debug("loading currents config file from '%s'", configFilePath);

const defaultConfig: CurrentsConfig = {
e2e: {
batchSize: 3,
Expand All @@ -41,8 +37,12 @@ export function getCurrentsConfig(): CurrentsConfig {
cloudServiceUrl: "https://cy.currents.dev",
};

const configFilePath = getConfigFilePath();
try {
const fsConfig = require(configFilePath);
const resovledPath = path.resolve(configFilePath);
debug("loading currents config file from '%s'", resovledPath);

const fsConfig = require(resovledPath);
_config = {
...defaultConfig,
...fsConfig,
Expand Down Expand Up @@ -95,5 +95,5 @@ export async function getMergedConfig(params: ValidatedCurrentsParameters) {
}

function getConfigFilePath(explicitLocation = null) {
return path.resolve(explicitLocation ?? process.cwd(), "currents.config.js");
return explicitLocation ?? process.cwd(), "currents.config.js";
}
53 changes: 37 additions & 16 deletions packages/cypress-cloud/lib/config/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ export function fallback(...args: string[]) {
return args.find((arg) => arg !== undefined && arg !== null && arg !== "");
}

export function resolveCurrentsParams(
params: CurrentsRunParameters
): CurrentsRunParameters {
export function resolveCurrentsParams(params: CurrentsRunParameters) {
const configFromFile = getCurrentsConfig();

const cloudServiceUrl =
Expand Down Expand Up @@ -87,34 +85,48 @@ export function validateParams(
throw new ValidationError(recordKeyError);
}

// validate cloudServiceUrl
try {
new URL(params.cloudServiceUrl);
} catch (err) {
throw new ValidationError(
`${cloudServiceInvalidUrlError}: "${params.cloudServiceUrl}"`
);
}
validateURL(params.cloudServiceUrl);

const requiredParameters: Array<keyof CurrentsRunParameters> = [
"testingType",
"batchSize",
"projectId",
];
requiredParameters.forEach((key) => {
if (typeof params[key] === "undefined") {
error(
'Missing required parameter "%s". Please provide at least the following parameters: %s',
key,
requiredParameters.join(", ")
);
error('Missing required parameter "%s"', key);
throw new Error("Missing required parameter");
}
});

params.tag = parseTags(params.tag);
params.autoCancelAfterFailures = getAutoCancelValue(
params.autoCancelAfterFailures
);

debug("validated currents params: %o", params);

// TODO: remove this cast after finding a way to properly resolve params type after validations
return params as ValidatedCurrentsParameters;
}

function getAutoCancelValue(value: unknown): number | false | undefined {
if (typeof value === "undefined") {
return undefined;
}
if (typeof value === "boolean") {
return value ? 1 : false;
}

if (typeof value === "number" && value > 0) {
return value;
}

throw new ValidationError(
`autoCancelAfterFailures: should be a positive integer or "false". Got: "${value}"`
);
}

export function isOffline(params: CurrentsRunParameters) {
return params.record === false;
}
Expand All @@ -132,6 +144,14 @@ function parseTags(tagString: CurrentsRunParameters["tag"]): string[] {
.filter(Boolean);
}

function validateURL(url: string): void {
try {
new URL(url);
} catch (err) {
throw new ValidationError(`${cloudServiceInvalidUrlError}: "${url}"`);
}
}

/**
*
* @returns Cypress options without items that affect recording mode
Expand All @@ -142,6 +162,7 @@ export function getCypressRunAPIParams(
return {
..._.pickBy(
_.omit(params, [
"autoCancelAfterFailures",
"cloudServiceUrl",
"batchSize",
"projectId",
Expand Down
11 changes: 8 additions & 3 deletions packages/cypress-cloud/lib/httpClient/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export function getClient() {

_client.interceptors.request.use((config) => {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
...config.headers,
// @ts-ignore
"x-cypress-request-attempt": config["axios-retry"]?.retryCount ?? 0,
Expand All @@ -40,12 +39,18 @@ export function getClient() {
if (_runId) {
headers["x-cypress-run-id"] = _runId;
}
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const req = {
...config,
headers,
};

debug("network request: %o", req);
debug("network request: %o", {
..._.pick(req, "method", "url", "headers"),
data: Buffer.isBuffer(req.data) ? "buffer" : req.data,
});
return req;
});

Expand Down Expand Up @@ -93,7 +98,7 @@ export const makeRequest = <T = any, D = any>(
) => {
return getClient()<D, AxiosResponse<T>>(config)
.then((res) => {
debug("network request response: %o", _.omit(res, "request", "config"));
debug("network response: %o", _.omit(res, "request", "config"));
return res;
})
.catch((error) => {
Expand Down
Loading

0 comments on commit cb5b758

Please sign in to comment.