Implement initial Cypress testing configuration (#115)

* Implement initial Cypress testing configuration

* Add docs on cypress for testing

* Cypress cache for GitHub actions
This commit is contained in:
Brandon Lee Kitajchuk
2022-09-26 10:14:41 -07:00
committed by GitHub
parent a6aee3802c
commit 76857c0a4b
24 changed files with 2524 additions and 48 deletions

View File

@@ -21,10 +21,21 @@ jobs:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
- name: Cache cypress binary
id: cypress-cache
uses: actions/cache@v2
with:
path: /home/runner/.cache/Cypress
key: cypress-${{ hashFiles('package-lock.json') }}
- name: Install node_modules
if: steps.node-cache.outputs.cache-hit != 'true'
run: npm install
- name: Install cypress
if: steps.cypress-cache.outputs.cache-hit != 'true'
run: npx cypress install
- name: Run checks
run: |
npm run format

5
.gitignore vendored
View File

@@ -24,4 +24,7 @@ yarn.lock
*.sw?
# Jambonz
/public/fonts
/public/fonts
# Cypress
cypress/videos

View File

@@ -5,7 +5,7 @@
npx lint-staged
# run tests
npm run test
# npm run test
# run build -- tsc
# npm run build

11
cypress.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "cypress";
export default defineConfig({
video: false,
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
});

View File

@@ -0,0 +1,10 @@
[
{
"name": "default account",
"account_sid": "9351f46a-678c-43f5-b8a6-d4eb58d131af"
},
{
"name": "custom account",
"account_sid": "04e36b64-a4e5-4221-be7e-db80da39234d"
}
]

View File

@@ -0,0 +1,10 @@
[
{
"name": "dial time",
"application_sid": "4ca2fb6a-8636-4f2e-96ff-8966c5e26f8e"
},
{
"name": "hello world",
"application_sid": "7087fe50-8acb-4f3b-b820-97b573723aab"
}
]

View File

@@ -0,0 +1,39 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
export {};

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
// ***********************************************************
// This example support/component.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 jambonz global styles
import "src/styles/index.scss";
// Import commands.js using ES2015 syntax:
import "./commands";
import { mount } from "cypress/react18";
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
// Disabling, but: https://typescript-eslint.io/rules/no-namespace/...
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add("mount", mount);
// Example use:
// cy.mount(<MyComponent />)

View File

@@ -29,6 +29,7 @@ with [typescript](https://www.typescriptlang.org/),
[prettier](https://prettier.io/), [eslint](https://eslint.org/),
[husky](https://typicode.github.io/husky/#/)
and [lint-staged](https://www.npmjs.com/package/lint-staged).
For testing we're using [cypress](https://docs.cypress.io/guides/component-testing/writing-your-first-component-test).
## :lock: Auth middleware
@@ -177,6 +178,17 @@ the `BEM` style we use:
}
```
## :robot: Testing
We're using [cypress](https://docs.cypress.io/guides/component-testing/writing-your-first-component-test)
for component testing. Cypress is already configured and we're actively working on backfilling complete
test coverage of the application. There are some issues open for this so you may refer to those to check
out the progress.
All new components should have tests written alongside them. For example, if you component is called
`my-component.tsx` then you would also have a `my-component.cy.tsx` test file to go with it. You can
refer to existing tests for some common patterns we use to write tests.
## :heart: Contributing
If you would like to contribute to this project please follow these simple guidelines:

2049
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-webapp",
"description": "Simple provisioning webapp for jambonz",
"description": "A simple provisioning web app for jambonz",
"version": "v1.0.0",
"license": "MIT",
"type": "module",
@@ -34,7 +34,8 @@
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --max-warnings=0",
"format": "prettier --check .",
"test": "echo 'need to setup testing'",
"test": "cypress run --component --headless",
"test:open": "cypress open --component",
"serve": "serve -s dist -l ${HTTP_PORT:-3001}",
"pm2": "pm2 start npm --name \"jambonz-webapp\" -- run serve",
"deploy": "npm i && npm run build && npm run pm2"
@@ -59,6 +60,7 @@
"@typescript-eslint/parser": "^5.30.6",
"@vitejs/plugin-react": "^1.3.0",
"cors": "^2.8.5",
"cypress": "^10.8.0",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jsx-a11y": "^6.6.0",

View File

@@ -0,0 +1,37 @@
import React from "react";
import { MainApp } from "src/main-app";
import { AccessControl } from "./access-control";
import type { ACLProps } from "./access-control";
/** Wrapper to pass different ACLs */
const AccessControlTestWrapper = (props: Partial<ACLProps>) => {
return (
<MainApp>
<AccessControl acl={props.acl!}>
<div className="acl-div" />
</AccessControl>
</MainApp>
);
};
describe("<AccessControl>", () => {
it("mounts", () => {
cy.mount(<AccessControlTestWrapper acl="hasAdminAuth" />);
});
it("has admin ACL", () => {
cy.mount(<AccessControlTestWrapper acl="hasAdminAuth" />);
/** Default ACL applies admin auth -- the singleton admin user */
cy.get(".acl-div").should("exist");
});
it("has doesn't have teams fqdn ACL", () => {
cy.mount(<AccessControlTestWrapper acl="hasMSTeamsFqdn" />);
/** Default ACL disables MS Teams FQDN */
cy.get(".acl-div").should("not.exist");
});
});

View File

@@ -4,7 +4,7 @@ import { useSelectState } from "src/store";
import type { ACL } from "src/store/types";
type ACLProps = {
export type ACLProps = {
acl: keyof ACL;
children: React.ReactNode;
};

View File

@@ -0,0 +1,74 @@
import React, { useState } from "react";
import { sortLocaleName } from "src/utils";
import { AccountFilter } from "./account-filter";
import type { AccountFilterProps } from "./account-filter";
import type { Account } from "src/api/types";
/** Import fixture data directly so we don't use cy.fixture() ... */
import accounts from "../../cypress/fixtures/accounts.json";
/** Wrapper to perform React state setup */
const AccountFilterTestWrapper = (props: Partial<AccountFilterProps>) => {
const [account, setAccount] = useState("");
return (
<AccountFilter
label="Test"
accounts={accounts as Account[]}
account={[account, setAccount]}
defaultOption={props.defaultOption}
/>
);
};
describe("<AccountFilter>", () => {
/** The AccountFilter uses sort with `localeCompare` */
const accountsSorted = accounts.sort(sortLocaleName);
it("mounts", () => {
cy.mount(<AccountFilterTestWrapper />);
});
it("has label text", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Label text is properly set */
cy.get("label").should("have.text", "Test:");
});
it("has default value", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Default value is properly set to first option */
cy.get("select").should("have.value", accountsSorted[0].account_sid);
});
it("updates value onChange", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Assert onChange value updates */
cy.get("select").select(accountsSorted[1].account_sid);
cy.get("select").should("have.value", accountsSorted[1].account_sid);
});
it("manages the focused state", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Test the `focused` state className (applied onFocus) */
cy.get("select").select(accountsSorted[1].account_sid);
cy.get(".account-filter").should("have.class", "focused");
cy.get("select").blur();
cy.get(".account-filter").should("not.have.class", "focused");
});
it("renders with default option", () => {
/** Test with the `defaultOption` prop */
cy.mount(<AccountFilterTestWrapper defaultOption />);
/** No default value is set when this prop is present */
cy.get("select").should("have.value", "");
});
});

View File

@@ -4,9 +4,9 @@ import { classNames } from "jambonz-ui";
import { Icons } from "src/components/icons";
import type { Account } from "src/api/types";
import { hasLength } from "src/utils";
import { hasLength, sortLocaleName } from "src/utils";
type AccountFilterProps = {
export type AccountFilterProps = {
label?: string;
account: [string, React.Dispatch<React.SetStateAction<string>>];
accounts?: Account[];
@@ -54,15 +54,13 @@ export const AccountFilter = ({
!accounts.length && <option value="">No accounts</option>
)}
{hasLength(accounts) &&
accounts
.sort((a, b) => a.name.localeCompare(b.name))
.map((acct) => {
return (
<option key={acct.account_sid} value={acct.account_sid}>
{acct.name}
</option>
);
})}
accounts.sort(sortLocaleName).map((acct) => {
return (
<option key={acct.account_sid} value={acct.account_sid}>
{acct.name}
</option>
);
})}
</select>
<span>
<Icons.ChevronUp />

View File

@@ -0,0 +1,87 @@
import React, { useState } from "react";
import { sortLocaleName } from "src/utils";
import { ApplicationFilter } from "./application-filter";
import type { ApplicationFilterProps } from "./application-filter";
import type { Application } from "src/api/types";
/** Import fixture data directly so we don't use cy.fixture() ... */
import applications from "../../cypress/fixtures/applications.json";
/** Wrapper to perform React state setup */
const ApplicationFilterTestWrapper = (
props: Partial<ApplicationFilterProps>
) => {
const [application, setApplication] = useState("");
return (
<ApplicationFilter
label="Test"
applications={applications as Application[]}
application={[application, setApplication]}
defaultOption={props.defaultOption}
/>
);
};
describe("<ApplicationFilter>", () => {
/** The AccountFilter uses sort with `localeCompare` */
const applicationsSorted = applications.sort(sortLocaleName);
it("mounts", () => {
cy.mount(<ApplicationFilterTestWrapper />);
});
it("has label text", () => {
cy.mount(<ApplicationFilterTestWrapper />);
/** Label text is properly set */
cy.get("label").should("have.text", "Test:");
});
it("has default value", () => {
cy.mount(<ApplicationFilterTestWrapper />);
/** Default value is properly set to first option */
cy.get("select").should(
"have.value",
applicationsSorted[0].application_sid
);
});
it("updates value onChange", () => {
cy.mount(<ApplicationFilterTestWrapper />);
/** Assert onChange value updates */
cy.get("select").select(applicationsSorted[1].application_sid);
cy.get("select").should(
"have.value",
applicationsSorted[1].application_sid
);
});
it("manages focused state", () => {
cy.mount(<ApplicationFilterTestWrapper />);
/** Test the `focused` state className (applied onFocus) */
cy.get("select").select(applicationsSorted[1].application_sid);
cy.get(".application-filter").should("have.class", "focused");
cy.get("select").blur();
cy.get(".application-filter").should("not.have.class", "focused");
});
it("renders default option", () => {
/** Test with the `defaultOption` prop */
cy.mount(
<ApplicationFilterTestWrapper defaultOption="Choose Application" />
);
/** No default value is set when this prop is present */
cy.get("select").should("have.value", "");
/** Validate that our prop renders correct default option text */
cy.get("option").first().should("have.text", "Choose Application");
});
});

View File

@@ -2,10 +2,11 @@ import React, { useState } from "react";
import { classNames } from "jambonz-ui";
import { Icons } from "src/components/icons";
import { sortLocaleName } from "src/utils";
import type { Application } from "src/api/types";
type ApplicationFilterProps = JSX.IntrinsicElements["select"] & {
export type ApplicationFilterProps = JSX.IntrinsicElements["select"] & {
label?: string;
application: [string, React.Dispatch<React.SetStateAction<string>>];
applications?: Application[];
@@ -40,15 +41,13 @@ export const ApplicationFilter = ({
>
{defaultOption && <option value="">{defaultOption}</option>}
{applications &&
applications
.sort((a, b) => a.name.localeCompare(b.name))
.map((app) => {
return (
<option key={app.application_sid} value={app.application_sid}>
{app.name}
</option>
);
})}
applications.sort(sortLocaleName).map((app) => {
return (
<option key={app.application_sid} value={app.application_sid}>
{app.name}
</option>
);
})}
</select>
<span>
<Icons.ChevronUp />

View File

@@ -0,0 +1,71 @@
import React, { useState } from "react";
import { SelectFilter } from "./select-filter";
/** Wrapper to perform React state setup */
const SelectFilterTestWrapper = ({ onSelectSpy = () => null }) => {
const [selected, setSelected] = useState("");
return (
<SelectFilter
id="test"
label="Test"
options={[
{ name: "Option one", value: "1" },
{ name: "Option two", value: "2" },
{ name: "Option three", value: "3" },
]}
filter={[selected, setSelected]}
handleSelect={onSelectSpy}
/>
);
};
describe("<SelectFilter>", () => {
it("mounts", () => {
cy.mount(<SelectFilterTestWrapper />);
});
it("has label text", () => {
cy.mount(<SelectFilterTestWrapper />);
/** Label text is properly set */
cy.get("label").should("have.text", "Test:");
});
it("has id prop", () => {
cy.mount(<SelectFilterTestWrapper />);
/** Select ID attribute is properly set */
cy.get("select").should("have.id", "test");
});
it("has default value", () => {
cy.mount(<SelectFilterTestWrapper />);
/** Default value is properly set to first option */
cy.get("select").should("have.value", "1");
});
it("calls onChange handler", () => {
/** Create a spy for the `onChange` handler */
const onSelectSpy = cy.spy().as("onSelectSpy");
cy.mount(<SelectFilterTestWrapper onSelectSpy={onSelectSpy} />);
/** Assert onChange value and custom handler is called */
cy.get("select").select("2");
cy.get("select").should("have.value", "2");
cy.get("@onSelectSpy").should("have.been.calledOnce");
});
it("manages focused state", () => {
cy.mount(<SelectFilterTestWrapper />);
/** Test the `focused` state className (applied onFocus) */
cy.get("select").select("3");
cy.get(".select-filter").should("have.class", "focused");
cy.get("select").blur();
cy.get(".select-filter").should("not.have.class", "focused");
});
});

18
src/main-app.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { StateProvider } from "./store";
import { AuthProvider } from "./router/auth";
/** Export `MainApp` so it can be used in Cypress component tests */
export const MainApp = ({ children }: { children: React.ReactNode }) => {
return (
<React.StrictMode>
<StateProvider>
<BrowserRouter>
<AuthProvider>{children}</AuthProvider>
</BrowserRouter>
</StateProvider>
</React.StrictMode>
);
};

View File

@@ -1,27 +1,15 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { MainApp } from "./main-app";
import { Router } from "./router";
import { StateProvider } from "./store";
import { AuthProvider } from "./router/auth";
import "./styles/index.scss";
const App = () => {
return (
<React.StrictMode>
<StateProvider>
<BrowserRouter>
<AuthProvider>
<Router />
</AuthProvider>
</BrowserRouter>
</StateProvider>
</React.StrictMode>
);
};
const root: Element = document.getElementById("root")!;
createRoot(root).render(<App />);
createRoot(root).render(
<MainApp>
<Router />
</MainApp>
);

View File

@@ -1,4 +1,5 @@
import { getUser, getServiceProviders } from "src/api";
import { sortLocaleName } from "src/utils";
import type { State, Action } from "./types";
import type { ServiceProvider, User } from "src/api/types";
@@ -17,9 +18,7 @@ export const serviceProvidersAction = (
action: Action<keyof State>
) => {
// Sorts for consistent list view
action.payload = (<ServiceProvider[]>action.payload).sort((a, b) =>
a.name.localeCompare(b.name)
);
action.payload = (<ServiceProvider[]>action.payload).sort(sortLocaleName);
// Sets initial currentServiceProvider
if (!state.currentServiceProvider) {

View File

@@ -120,6 +120,11 @@ export const formatTime = (seconds: number) => {
return `${minutes}m ${remainingSeconds}s`;
};
export const sortLocaleName = (
a: Required<{ name: string }>,
b: Required<{ name: string }>
) => a.name.localeCompare(b.name);
export {
withSuspense,
useMobileMedia,

View File

@@ -22,6 +22,6 @@
"src": ["./src"]
}
},
"include": ["src"],
"include": ["src", "cypress"],
"references": [{ "path": "./tsconfig.node.json" }]
}