mirror of https://github.com/coder/coder.git
chore: Add initial jest tests + code coverage (#13)
- Adds initial infra for running front-end tests (`jest`, `ts-jest`, `jest.config.js`, etc) - Adds codecov integration front-end code
This commit is contained in:
parent
afc2fa3b62
commit
423611b001
|
@ -136,7 +136,7 @@ jobs:
|
|||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./gotests.coverage
|
||||
flags: ${{ matrix.os }}
|
||||
flags: unittest-go-${{ matrix.os }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
test-js:
|
||||
|
@ -161,3 +161,12 @@ jobs:
|
|||
- run: yarn install
|
||||
|
||||
- run: yarn build
|
||||
|
||||
- run: yarn test:coverage
|
||||
|
||||
- uses: codecov/codecov-action@v2
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/lcov.info
|
||||
flags: unittest-js
|
||||
fail_ci_if_error: true
|
||||
|
|
|
@ -16,4 +16,5 @@ yarn-error.log
|
|||
|
||||
# Front-end ignore
|
||||
.next/
|
||||
site/.next/
|
||||
site/.next/
|
||||
coverage/
|
|
@ -12,4 +12,5 @@ yarn-error.log
|
|||
|
||||
# Front-end ignore
|
||||
.next/
|
||||
site/.next/
|
||||
site/.next/
|
||||
coverage/
|
|
@ -0,0 +1,26 @@
|
|||
module.exports = {
|
||||
projects: [
|
||||
{
|
||||
coverageReporters: ["text", "lcov"],
|
||||
displayName: "test",
|
||||
preset: "ts-jest",
|
||||
roots: ["<rootDir>/site"],
|
||||
transform: {
|
||||
"^.+\\.tsx?$": "ts-jest",
|
||||
},
|
||||
testEnvironment: "jsdom",
|
||||
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
|
||||
testPathIgnorePatterns: ["/node_modules/", "/__tests__/fakes"],
|
||||
moduleDirectories: ["node_modules", "<rootDir>"],
|
||||
},
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
"<rootDir>/site/**/*.js",
|
||||
"<rootDir>/site/**/*.ts",
|
||||
"<rootDir>/site/**/*.tsx",
|
||||
"!<rootDir>/site/**/*.stories.tsx",
|
||||
"!<rootDir>/site/.next/**/*.*",
|
||||
"!<rootDir>/site/next-env.d.ts",
|
||||
"!<rootDir>/site/next.config.js",
|
||||
],
|
||||
}
|
|
@ -8,20 +8,26 @@
|
|||
"build:dev": "next build site",
|
||||
"dev": "next dev site",
|
||||
"format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
|
||||
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'"
|
||||
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
|
||||
"test": "jest --selectProjects test",
|
||||
"test:coverage": "jest --selectProjects test --collectCoverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@material-ui/core": "4.9.4",
|
||||
"@material-ui/icons": "4.5.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.42",
|
||||
"@testing-library/react": "12.1.2",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/node": "14.18.4",
|
||||
"@types/react": "17.0.38",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/superagent": "4.1.14",
|
||||
"jest": "27.4.7",
|
||||
"next": "12.0.7",
|
||||
"prettier": "2.5.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"ts-jest": "27.1.2",
|
||||
"ts-loader": "9.2.6",
|
||||
"typescript": "4.5.4"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import React from "react"
|
||||
import { SplitButton, SplitButtonProps } from "./SplitButton"
|
||||
|
||||
namespace Helpers {
|
||||
export type SplitButtonOptions = "a" | "b" | "c"
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
|
||||
export const callback = (selectedOption: SplitButtonOptions): void => {}
|
||||
|
||||
export const options: SplitButtonProps<SplitButtonOptions>["options"] = [
|
||||
{
|
||||
label: "test a",
|
||||
value: "a",
|
||||
},
|
||||
{
|
||||
label: "test b",
|
||||
value: "b",
|
||||
},
|
||||
{
|
||||
label: "test c",
|
||||
value: "c",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe("SplitButton", () => {
|
||||
describe("onClick", () => {
|
||||
it("is called when primary action is clicked", () => {
|
||||
// Given
|
||||
const mockedAndSpyedCallback = jest.fn(Helpers.callback)
|
||||
|
||||
// When
|
||||
render(<SplitButton onClick={mockedAndSpyedCallback} options={Helpers.options} />)
|
||||
fireEvent.click(screen.getByText("test a"))
|
||||
|
||||
// Then
|
||||
expect(mockedAndSpyedCallback.mock.calls.length).toBe(1)
|
||||
expect(mockedAndSpyedCallback.mock.calls[0][0]).toBe("a")
|
||||
})
|
||||
|
||||
it("is called when clicking option in pop-up", () => {
|
||||
// Given
|
||||
const mockedAndSpyedCallback = jest.fn(Helpers.callback)
|
||||
|
||||
// When
|
||||
render(<SplitButton onClick={mockedAndSpyedCallback} options={Helpers.options} />)
|
||||
const buttons = screen.getAllByRole("button")
|
||||
const dropdownButton = buttons[1]
|
||||
fireEvent.click(dropdownButton)
|
||||
fireEvent.click(screen.getByText("test c"))
|
||||
|
||||
// Then
|
||||
expect(mockedAndSpyedCallback.mock.calls.length).toBe(1)
|
||||
expect(mockedAndSpyedCallback.mock.calls[0][0]).toBe("c")
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,14 @@
|
|||
import { screen } from "@testing-library/react"
|
||||
import { render } from "../../test_helpers"
|
||||
import React from "react"
|
||||
import { EmptyState, EmptyStateProps } from "./index"
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders (smoke test)", async () => {
|
||||
// When
|
||||
render(<EmptyState message="Hello, world" />)
|
||||
|
||||
// Then
|
||||
await screen.findByText("Hello, world")
|
||||
})
|
||||
})
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react"
|
||||
import { SvgIcon } from "@material-ui/core"
|
||||
import { render } from "./../../test_helpers"
|
||||
|
||||
import * as Icons from "./index"
|
||||
|
||||
const getAllIcons = (): [string, typeof SvgIcon][] => {
|
||||
let k: keyof typeof Icons
|
||||
let ret: [string, typeof SvgIcon][] = []
|
||||
for (k in Icons) {
|
||||
ret.push([k, Icons[k]])
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
describe("Icons", () => {
|
||||
const allIcons = getAllIcons()
|
||||
|
||||
it.each(allIcons)(`rendering icon %p`, (_name, Icon) => {
|
||||
render(<Icon />)
|
||||
})
|
||||
})
|
|
@ -1,51 +0,0 @@
|
|||
import ListItemIcon from "@material-ui/core/ListItemIcon"
|
||||
import MenuItem from "@material-ui/core/MenuItem"
|
||||
import { SvgIcon, Typography } from "@material-ui/core"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React from "react"
|
||||
|
||||
export interface NavMenuEntryProps {
|
||||
icon: typeof SvgIcon
|
||||
path: string
|
||||
label?: string
|
||||
selected: boolean
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const NavMenuEntry: React.FC<NavMenuEntryProps> = ({
|
||||
className,
|
||||
icon,
|
||||
path,
|
||||
label = path,
|
||||
selected,
|
||||
onClick,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const Icon = icon
|
||||
return (
|
||||
<MenuItem selected={selected} className={className} onClick={onClick}>
|
||||
<div className={styles.root}>
|
||||
{icon && (
|
||||
<ListItemIcon>
|
||||
<Icon className={styles.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<Typography>{label}</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
padding: "2em",
|
||||
},
|
||||
icon: {
|
||||
color: theme.palette.text.primary,
|
||||
|
||||
"& path": {
|
||||
fill: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
}))
|
|
@ -0,0 +1,15 @@
|
|||
import React from "react"
|
||||
import { screen } from "@testing-library/react"
|
||||
|
||||
import { render } from "../../test_helpers"
|
||||
import { Navbar } from "./index"
|
||||
|
||||
describe("Navbar", () => {
|
||||
it("renders content", async () => {
|
||||
// When
|
||||
render(<Navbar />)
|
||||
|
||||
// Then
|
||||
await screen.findAllByText("Coder", { exact: false })
|
||||
})
|
||||
})
|
|
@ -18,7 +18,7 @@ export const Navbar: React.FC = () => {
|
|||
</Link>
|
||||
</div>
|
||||
<div className={styles.fullWidth}>
|
||||
<div className={styles.title}>Hello, World - Coder v2</div>
|
||||
<div className={styles.title}>Coder v2</div>
|
||||
</div>
|
||||
<div className={styles.fixed}>
|
||||
<List>
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import React from "react"
|
||||
import { screen } from "@testing-library/react"
|
||||
|
||||
import { render } from "../../test_helpers"
|
||||
import { Footer } from "./Footer"
|
||||
|
||||
describe("Footer", () => {
|
||||
it("renders content", async () => {
|
||||
// When
|
||||
render(<Footer />)
|
||||
|
||||
// Then
|
||||
await screen.findByText("Copyright", { exact: false })
|
||||
})
|
||||
})
|
|
@ -1,50 +1 @@
|
|||
import React from "react"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
|
||||
import { Footer } from "./Footer"
|
||||
import { Navbar } from "../Navbar"
|
||||
|
||||
export const Page: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// TODO: More interesting styling here!
|
||||
|
||||
const styles = useStyles()
|
||||
|
||||
const header = (
|
||||
<div className={styles.header}>
|
||||
<Navbar />
|
||||
</div>
|
||||
)
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
const body = <div className={styles.body}> {children}</div>
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{header}
|
||||
{body}
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
flex: 0,
|
||||
},
|
||||
body: {
|
||||
height: "100%",
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
flex: 0,
|
||||
},
|
||||
}))
|
||||
export * from "./Footer"
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require("path")
|
||||
|
||||
module.exports = {
|
||||
env: {},
|
||||
|
|
|
@ -5,20 +5,69 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider"
|
|||
|
||||
import { dark } from "../theme"
|
||||
import { AppProps } from "next/app"
|
||||
import { makeStyles } from "@material-ui/core"
|
||||
import { Navbar } from "../components/Navbar"
|
||||
import { Footer } from "../components/Page"
|
||||
|
||||
/**
|
||||
* `Contents` is the wrapper around the core app UI,
|
||||
* containing common UI elements like the footer and navbar.
|
||||
*
|
||||
* This can't be inlined in `MyApp` because it requires styling,
|
||||
* and `useStyles` needs to be inside a `<ThemeProvider />`
|
||||
*/
|
||||
const Contents: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
const header = (
|
||||
<div className={styles.header}>
|
||||
<Navbar />
|
||||
</div>
|
||||
)
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{header}
|
||||
<Component {...pageProps} />
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* <App /> is the root rendering logic of the application - setting up our router
|
||||
* and any contexts / global state management.
|
||||
* @returns
|
||||
*/
|
||||
|
||||
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
const MyApp: React.FC<AppProps> = (appProps) => {
|
||||
return (
|
||||
<ThemeProvider theme={dark}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
<Contents {...appProps} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
flex: 0,
|
||||
},
|
||||
body: {
|
||||
height: "100%",
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
flex: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
export default MyApp
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState } from "react"
|
||||
import { Dialog, DialogActions, Button, DialogTitle, DialogContent, makeStyles, Box, Paper } from "@material-ui/core"
|
||||
import React from "react"
|
||||
import { makeStyles, Box, Paper } from "@material-ui/core"
|
||||
import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons"
|
||||
|
||||
import { EmptyState, Page, SplitButton } from "../components"
|
||||
import { EmptyState, SplitButton } from "../components"
|
||||
|
||||
const WorkspacesPage: React.FC = () => {
|
||||
const styles = useStyles()
|
||||
|
@ -17,7 +17,7 @@ const WorkspacesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<SplitButton<string>
|
||||
color="primary"
|
||||
|
@ -37,12 +37,12 @@ const WorkspacesPage: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Paper style={{ maxWidth: "1380px", margin: "1em auto" }}>
|
||||
<Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}>
|
||||
<Box pt={4} pb={4}>
|
||||
<EmptyState message="No workspaces available." button={button} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Page>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { render as wrappedRender, RenderResult } from "@testing-library/react"
|
||||
import React from "react"
|
||||
import ThemeProvider from "@material-ui/styles/ThemeProvider"
|
||||
|
||||
import { dark } from "../theme"
|
||||
|
||||
export const WrapperComponent: React.FC = ({ children }) => {
|
||||
return <ThemeProvider theme={dark}>{children}</ThemeProvider>
|
||||
}
|
||||
|
||||
export const render = (component: React.ReactElement): RenderResult => {
|
||||
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"noImplicitAny": true,
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"jsx": "react",
|
||||
"downlevelIteration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue