Skip to content

Commit

Permalink
Setup Cypress UI testing
Browse files Browse the repository at this point in the history
Signed-off-by: Griffin-Sullivan <gsulliva@redhat.com>
  • Loading branch information
Griffin-Sullivan committed Jul 31, 2024
1 parent bda3731 commit 3fdebb0
Show file tree
Hide file tree
Showing 16 changed files with 11,966 additions and 6,273 deletions.
2 changes: 2 additions & 0 deletions clients/ui/frontend/.env.cypress.mock
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Test against prod build hosted by lightweight http server
BASE_URL=http://localhost:9001
27 changes: 27 additions & 0 deletions clients/ui/frontend/docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Model Registry UI Testing

## Cypress Tests

Cypress is used to run tests against the frontend while mocking all network requests.

Single command to run all Cypress tests or a specific test (build frontend, start HTTP server, run Cypress):
```bash
npm run test:cypress-ci

npm run test:cypress-ci -- --spec "**/testfile.cy.ts"
```

Cypress tests require a frontend server to be running.

To best match production, build the frontend and use a lightweight HTTP server to host the files. This method will require manual rebuilds when changes are made to the dashboard frontend code.
```bash
npm run cypress:server:build
npm run cypress:server
```

To run all Cypress tests or a specific test headless
```bash
npm run cypress:run:mock

npm run cypress:run:mock -- --spec "**/testfile.cy.ts"
```
17,870 changes: 11,618 additions & 6,252 deletions clients/ui/frontend/package-lock.json

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions clients/ui/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"build": "webpack --config webpack.prod.js",
"start": "sirv dist --cors --single --host --port 8080",
"start:dev": "webpack serve --color --progress --config webpack.dev.js",
"test": "jest",
"test:cypress-ci": "npx concurrently -P -k -s first \"npm run cypress:server:build && npm run cypress:server\" \"npx wait-on tcp:127.0.0.1:9001 && npm run cypress:run:mock -- {@}\" -- ",
"test:jest": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"eslint": "eslint --ext .tsx,.js ./src/",
Expand All @@ -21,9 +22,15 @@
"ci-checks": "npm run type-check && npm run lint && npm run test:coverage",
"build:bundle-profile": "webpack --config webpack.prod.js --profile --json > stats.json",
"bundle-profile:analyze": "npm run build:bundle-profile && webpack-bundle-analyzer ./stats.json",
"clean": "rimraf dist"
"clean": "rimraf dist",
"cypress:run": "cypress run -b chrome --project src/__tests__/cypress",
"cypress:run:mock": "CY_MOCK=1 npm run cypress:run -- ",
"cypress:server:build": "POLL_INTERVAL=9999999 FAST_POLL_INTERVAL=9999999 npm run build",
"cypress:server": "serve ./dist -p 9001 -s -L"
},
"devDependencies": {
"@cypress/code-coverage": "^3.12.34",
"@testing-library/cypress": "^10.0.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "14.4.3",
Expand All @@ -32,9 +39,15 @@
"@types/victory": "^33.1.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"chai-subset": "^1.6.0",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.11.0",
"css-minimizer-webpack-plugin": "^5.0.1",
"cypress": "^13.10.0",
"cypress-axe": "^1.5.0",
"cypress-high-resolution": "^1.0.0",
"cypress-mochawesome-reporter": "^3.8.2",
"cypress-multi-reporters": "^1.6.4",
"dotenv-webpack": "^8.0.1",
"eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.3",
Expand All @@ -44,6 +57,7 @@
"imagemin": "^9.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"junit-report-merger": "^7.0.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.39",
"prettier": "^3.3.0",
Expand All @@ -52,6 +66,7 @@
"react-router-dom": "^5.3.4",
"regenerator-runtime": "^0.13.11",
"rimraf": "^5.0.7",
"serve": "^14.2.1",
"style-loader": "^3.3.4",
"svg-url-loader": "^8.0.0",
"terser-webpack-plugin": "^5.3.10",
Expand Down
2 changes: 2 additions & 0 deletions clients/ui/frontend/src/__tests__/cypress/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
coverage
results
122 changes: 122 additions & 0 deletions clients/ui/frontend/src/__tests__/cypress/cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import path from 'path';
import fs from 'fs';
import { defineConfig } from 'cypress';
import coverage from '@cypress/code-coverage/task';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore no types available
import cypressHighResolution from 'cypress-high-resolution';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore no types available
import { beforeRunHook, afterRunHook } from 'cypress-mochawesome-reporter/lib';
import { mergeFiles } from 'junit-report-merger';
import { env, BASE_URL } from '~/src/__tests__/cypress/cypress/utils/testConfig';


const resultsDir = `${env.CY_RESULTS_DIR || 'results'}/${env.CY_MOCK ? 'mocked' : 'e2e'}`;

export default defineConfig({
experimentalMemoryManagement: true,
// Use relative path as a workaround to https://github.com/cypress-io/cypress/issues/6406
reporter: '../../../node_modules/cypress-multi-reporters',
reporterOptions: {
reporterEnabled: 'cypress-mochawesome-reporter, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `${resultsDir}/junit/junit-[hash].xml`,
},
cypressMochawesomeReporterReporterOptions: {
charts: true,
embeddedScreenshots: false,
ignoreVideos: false,
inlineAssets: true,
reportDir: resultsDir,
videoOnFailOnly: true,
},
},
chromeWebSecurity: false,
viewportWidth: 1920,
viewportHeight: 1080,
numTestsKeptInMemory: 1,
video: true,
screenshotsFolder: `${resultsDir}/screenshots`,
videosFolder: `${resultsDir}/videos`,
env: {
MOCK: !!env.CY_MOCK,
coverage: !!env.CY_COVERAGE,
codeCoverage: {
exclude: [path.resolve(__dirname, '../../third_party/**')],
},
resolution: 'high',
},
defaultCommandTimeout: 10000,
e2e: {
baseUrl: BASE_URL,
specPattern: env.CY_MOCK
? `cypress/tests/mocked/**/*.cy.ts`
: `cypress/tests/e2e/**/*.cy.ts`,
experimentalInteractiveRunEvents: true,
setupNodeEvents(on, config) {
cypressHighResolution(on, config);
coverage(on, config);
on('task', {
readJSON(filePath: string) {
const absPath = path.resolve(__dirname, filePath);
if (fs.existsSync(absPath)) {
try {
return Promise.resolve(JSON.parse(fs.readFileSync(absPath, 'utf8')));
} catch {
// return default value
}
}

return Promise.resolve({});
},
log(message) {
// eslint-disable-next-line no-console
console.log(message);
return null;
},
error(message) {
// eslint-disable-next-line no-console
console.error(message);
return null;
},
table(message) {
// eslint-disable-next-line no-console
console.table(message);
return null;
},
});

// Delete videos for specs without failing or retried tests
on('after:spec', (_, results) => {
if (results.video) {
// Do we have failures for any retry attempts?
const failures = results.tests.some((test) =>
test.attempts.some((attempt) => attempt.state === 'failed'),
);
if (!failures) {
// delete the video if the spec passed and no tests retried
fs.unlinkSync(results.video);
}
}
});

on('before:run', async (details) => {
// cypress-mochawesome-reporter
await beforeRunHook(details);
});

on('after:run', async () => {
// cypress-mochawesome-reporter
await afterRunHook();

// merge junit reports into a single report
const outputFile = path.join(__dirname, resultsDir, 'junit-report.xml');
const inputFiles = [`./${resultsDir}/junit/*.xml`];
await mergeFiles(outputFile, inputFiles);
});

return config;
},
},
});
11 changes: 11 additions & 0 deletions clients/ui/frontend/src/__tests__/cypress/cypress/pages/home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class Home {
visit() {
cy.visit(`/`);
}

findButton() {
return cy.get('button:contains("Primary Action")');
}
}

export const home = new Home();
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class PageNotFound {
visit() {
cy.visit(`/force-not-found-page`, {'failOnStatusCode': false});
this.wait();
}

private wait() {
this.findPage();
cy.testA11y();
}

findPage() {
return cy.get('h1:contains("404 Page not found")');
}
}

export const pageNotfound = new PageNotFound();
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'cypress-axe';

/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace Cypress {
interface Chainable {
testA11y: (context?: Parameters<cy['checkA11y']>[0]) => void;
}
}
}

Cypress.Commands.add('testA11y', { prevSubject: 'optional' }, (subject, context) => {
const test = (c: Parameters<typeof cy.checkA11y>[0]) => {
cy.window({ log: false }).then((win) => {
// inject on demand
if (!(win as { axe: unknown }).axe) {
cy.injectAxe();
}
cy.checkA11y(
c,
{
includedImpacts: ['serious', 'critical'],
},
(violations) => {
cy.task(
'error',
`${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${
violations.length === 1 ? 'was' : 'were'
} detected`,
);
// pluck specific keys to keep the table readable
const violationData = violations.map(({ id, impact, description, nodes }) => ({
id,
impact,
description,
nodes: nodes.length,
}));

cy.task('table', violationData);

cy.task(
'log',
violations
.map(
({ nodes }, i) =>
`${i}. Affected elements:\n${nodes.map(
({ target, failureSummary, ancestry }) =>
`\t${failureSummary} - ${target
.map((node) => `"${node}"\n${ancestry}`)
.join(', ')}`,
)}`,
)
.join('\n'),
);
},
);
});
};
if (!context && subject) {
cy.wrap(subject).each(($el) => {
Cypress.log({ displayName: 'testA11y', $el });
test($el[0]);
});
} else {
Cypress.log({ displayName: 'testA11y' });
test(context);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import '@testing-library/cypress/add-commands';
import './axe';
34 changes: 34 additions & 0 deletions clients/ui/frontend/src/__tests__/cypress/cypress/support/e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************

import chaiSubset from 'chai-subset';
import '@cypress/code-coverage/support';
import 'cypress-mochawesome-reporter/register';
import './commands';

chai.use(chaiSubset);


Cypress.Keyboard.defaults({
keystrokeDelay: 0,
});

beforeEach(() => {
if (Cypress.env('MOCK')) {
// fallback: return 404 for all api requests
cy.intercept({ pathname: '/api/**' }, { statusCode: 404 });

}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { pageNotfound } from "~/src/__tests__/cypress/cypress/pages/pageNoteFound";
import { home } from "~/src/__tests__/cypress/cypress/pages/home";

describe('Application', () => {

it('Page not found should render', () => {
pageNotfound.visit()
});

it('Home page should have primary button', () => {
home.visit()
home.findButton();
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import path from 'path';
import { env } from 'process';
import dotenv from 'dotenv';

[
`.env.cypress${env.CY_MOCK ? '.mock' : ''}.local`,
`.env.cypress${env.CY_MOCK ? '.mock' : ''}`,
'.env.local',
'.env',
].forEach((file) =>
dotenv.config({
path: path.resolve(__dirname, '../../../../../', file),
}),
);

export const BASE_URL = env.BASE_URL || '';

// re-export the updated process env
export { env };
Loading

0 comments on commit 3fdebb0

Please sign in to comment.