diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..73caa39 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,50 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +; top-most EditorConfig file +root = true + +; define basic and global for any file +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = off +trim_trailing_whitespace = true +curly_bracket_next_line = false +spaces_around_operators = true + +; DOS/Windows batch scripts - +[*.{bat,cmd}] +end_of_line = crlf + +; JavaScript files - +[*.{js,ts}] +curly_bracket_next_line = true +indent_size = 2 +quote_type = double + +; JSON files (normal and commented version) - +[*.{json,jsonc}] +indent_size = 2 +quote_type = double + +; Make - match it own default syntax +[Makefile] +indent_style = tab + +; Markdown files - preserve trail spaces that means break line +[*.{md,markdown}] +trim_trailing_whitespace = false + +; PowerShell - match defaults for New-ModuleManifest and PSScriptAnalyzer Invoke-Formatter +[*.{ps1,psd1,psm1}] +charset = utf-8-bom +end_of_line = crlf + +; YML config files - match it own default syntax +[*.{yaml,yml}] +indent_size = 2 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e385ad0 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +build/ +node_modules/ +fpb.json \ No newline at end of file diff --git a/.github/workflows/webapp.yml b/.github/workflows/webapp.yml new file mode 100644 index 0000000..0674554 --- /dev/null +++ b/.github/workflows/webapp.yml @@ -0,0 +1,148 @@ +name: "CI-CD: free-programming-books-search" + +on: # This pipeline is executed when: + push: # - A push event is fired + branches: # over this branches + - main + pull_request: # - A pull request + branches: # over this base branches + - main + types: # is... + - opened # submitted + - synchronize # or push more commits + - reopened # or reopened after closed (no merged) + +permissions: + # needed to checkouts/branching + contents: read + +# This allows a subsequently queued workflow run to interrupt/wait for previous runs +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true # true: interrupt, false = wait for + +jobs: + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + - name: Install dependencies + run: | + npm ci + - name: Run code linter + run: | + npm run lint + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + - name: Install dependencies + run: | + npm ci + - name: Run webapp builder + run: | + npm run build + - name: Upload build artifact + uses: actions/upload-artifact@v3 + with: + name: build + path: build + + # Testcases with Jest + test: + needs: [lint, build] + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + - name: Install dependencies + run: | + npm ci + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: build + path: build + - name: Run tests + run: npm test + + + # Tests E2E with Cypress + e2e: + needs: [lint, build] + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + - name: Install dependencies + run: | + npm ci + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: build + path: build + - name: Run E2E tests + # TODO: Enable again after configure Cypress test environment + if: ${{ false }} + run: npm run test:e2e + + deploy: + needs: [test, e2e] + permissions: + # needs branching/commit contents + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: build + path: build + - name: Deploy to GitHub Pages + uses: JamesIves/github-pages-deploy-action@v4 + with: + token: ${{ github.token }} + # The branch the action should deploy. + branch: gh-pages + # The folder the action should deploy. + folder: build + # Test git push mode?? + dry-run: ${{ github.event_name == 'pull_request' }} + diff --git a/package-lock.json b/package-lock.json index 46b54ff..9f165a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,9 @@ "web-vitals": "^1.1.2" }, "devDependencies": { - "gh-pages": "^3.2.3" + "babel-jest": "26.6.0", + "gh-pages": "^3.2.3", + "react-test-renderer": "^17.0.2" } }, "node_modules/@ampproject/remapping": { @@ -4399,15 +4401,15 @@ } }, "node_modules/babel-jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", - "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.0.tgz", + "integrity": "sha512-JI66yILI7stzjHccAoQtRKcUwJrJb4oMIxLTirL3GdAjGpaUBQSjZDFi9LsPkN4gftsS4R2AThAJwOjJxadwbg==", "dependencies": { - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/transform": "^26.6.0", + "@jest/types": "^26.6.0", "@types/babel__core": "^7.1.7", "babel-plugin-istanbul": "^6.0.0", - "babel-preset-jest": "^26.6.2", + "babel-preset-jest": "^26.5.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.4", "slash": "^3.0.0" @@ -11375,6 +11377,27 @@ } } }, + "node_modules/jest-config/node_modules/babel-jest": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", + "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "dependencies": { + "@jest/transform": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/babel__core": "^7.1.7", + "babel-plugin-istanbul": "^6.0.0", + "babel-preset-jest": "^26.6.2", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "slash": "^3.0.0" + }, + "engines": { + "node": ">= 10.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/jest-config/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -16733,6 +16756,34 @@ "node": ">=0.10.0" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", + "integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==", + "dev": true, + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^17.0.2", + "react-shallow-renderer": "^16.13.1", + "scheduler": "^0.20.2" + }, + "peerDependencies": { + "react": "17.0.2" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -25293,15 +25344,15 @@ } }, "babel-jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", - "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.0.tgz", + "integrity": "sha512-JI66yILI7stzjHccAoQtRKcUwJrJb4oMIxLTirL3GdAjGpaUBQSjZDFi9LsPkN4gftsS4R2AThAJwOjJxadwbg==", "requires": { - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/transform": "^26.6.0", + "@jest/types": "^26.6.0", "@types/babel__core": "^7.1.7", "babel-plugin-istanbul": "^6.0.0", - "babel-preset-jest": "^26.6.2", + "babel-preset-jest": "^26.5.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.4", "slash": "^3.0.0" @@ -30638,6 +30689,21 @@ "pretty-format": "^26.6.2" }, "dependencies": { + "babel-jest": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", + "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "requires": { + "@jest/transform": "^26.6.2", + "@jest/types": "^26.6.2", + "@types/babel__core": "^7.1.7", + "babel-plugin-istanbul": "^6.0.0", + "babel-preset-jest": "^26.6.2", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "slash": "^3.0.0" + } + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -34718,6 +34784,28 @@ } } }, + "react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + } + }, + "react-test-renderer": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", + "integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^17.0.2", + "react-shallow-renderer": "^16.13.1", + "scheduler": "^0.20.2" + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", diff --git a/package.json b/package.json index 36a9371..83797ef 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "lint": "eslint **/*.{js,jsx}" }, "eslintConfig": { "extends": [ @@ -34,6 +35,19 @@ "react-app/jest" ] }, + "jest": { + "moduleNameMapper": { + "react-markdown": "/node_modules/react-markdown/react-markdown.min.js" + }, + "testMatch": [ + "**/*.test.js" + ], + "transform": { + "^.+\\.(js|jsx)$": "babel-jest" + }, + "transformIgnorePatterns": [ + ] + }, "browserslist": { "production": [ ">0.2%", @@ -47,6 +61,8 @@ ] }, "devDependencies": { - "gh-pages": "^3.2.3" + "babel-jest": "26.6.0", + "gh-pages": "^3.2.3", + "react-test-renderer": "^17.0.2" } } diff --git a/src/App.js b/src/App.js index b2a4b71..acd6eba 100644 --- a/src/App.js +++ b/src/App.js @@ -89,13 +89,13 @@ function App() { async function fetchData() { try { setQueries(queryString.parse(document.location.search)); - if (queries.lang) { - if (queries.lang === "langs" || queries.lang === "subjects") { - changeParameter("lang.code", "en"); - } else { - changeParameter("lang.code", queries.lang); - } - } + if (queries.lang) { + if (queries.lang === "langs" || queries.lang === "subjects") { + changeParameter("lang.code", "en"); + } else { + changeParameter("lang.code", queries.lang); + } + } // setLoading(true); let result = await axios.get( "https://raw.githubusercontent.com/EbookFoundation/free-programming-books-search/main/fpb.json" @@ -110,7 +110,7 @@ function App() { setLoading(false); } fetchData(); - }, []); + }, [cookies, queries.lang]);// eslint-disable-line react-hooks/exhaustive-deps // fires when searchTerm changes // Finds most relevant title or author diff --git a/src/App.test.js b/src/App.test.js index 1f03afe..5a8b6d9 100644 --- a/src/App.test.js +++ b/src/App.test.js @@ -1,8 +1,78 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; +import React from "react"; +import { + render, + cleanup, + waitFor, + waitForElementToBeRemoved, +} from "@testing-library/react"; +import App from "./App"; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); +afterEach(cleanup); + +test('should render "free-programming-books" link', async () => { + const { getByText, queryByText } = render(); + await waitForElementToBeRemoved(() => queryByText("Loading")); + const linkElement = await waitFor(() => getByText("free-programming-books")); + expect(linkElement).not.toBeNull(); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute( + "href", + "/free-programming-books-search/" + ); +}); + +test('should render "EbookFoundation/free-programming-books" link', async () => { + const { getByText, queryByText } = render(); + + await waitForElementToBeRemoved(() => queryByText("Loading")); + const linkElement = await waitFor(() => + getByText("EbookFoundation/free-programming-books").closest("a") + ); + expect(linkElement).not.toBeNull(); expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute( + "href", + "https://github.com/EbookFoundation/free-programming-books" + ); +}); + +it("should display loading state", async () => { + const { getByText, queryByText } = render(); + + expect(getByText("Loading")).toBeInTheDocument(); + await waitFor(() => { + expect(queryByText("Loading")).not.toBeInTheDocument(); + }); +}); + +test('renders "Filter by Language" component', async () => { + const { getByText, queryByText } = render(); + + await waitForElementToBeRemoved(() => queryByText("Loading")); + const component = await waitFor(() => getByText("Filter by Language")); + expect(component).toBeInTheDocument(); +}); + +test('renders "SearchBar" input component', async () => { + const { getByPlaceholderText, queryByText } = render(); + + await waitForElementToBeRemoved(() => queryByText("Loading")); + const component = await waitFor(() => + getByPlaceholderText("Search Book or Author") + ); + expect(component).toBeInTheDocument(); + expect(component).toHaveAttribute("id", "searchBar"); + expect(component.nodeName).toBe("INPUT"); + expect(component).toHaveAttribute("type", "text"); +}); + +test('renders Markdown "List of Free Learning Resources In Many Languages" H1 component', async () => { + const { getByText, queryByText } = render(); + + await waitForElementToBeRemoved(() => queryByText("Loading")); + const component = await waitFor(() => + getByText(/List of Free Learning Resources In Many Languages/i) + ); + expect(component).toBeInTheDocument(); + expect(component.nodeName).toBe("H1"); }); diff --git a/src/components/LangFilters.js b/src/components/LangFilters.js index 8dd44a1..2c4bb9a 100644 --- a/src/components/LangFilters.js +++ b/src/components/LangFilters.js @@ -26,7 +26,7 @@ function LangFilters({ changeParameter, data, langCode }) { changeParameter("lang.code", ""); setSelected("") } - }, []); + }, []);// eslint-disable-line react-hooks/exhaustive-deps useEffect( // run whenever data changes diff --git a/src/components/ParsedLink.js b/src/components/ParsedLink.js index e0eb2f5..0b4971b 100644 --- a/src/components/ParsedLink.js +++ b/src/components/ParsedLink.js @@ -26,7 +26,7 @@ function ParsedLink({ children, sect, href, id }) { setFolder(null); } } - }, [href]); + }, [href, sect]); if (folder && file) { return {children}; diff --git a/src/components/SearchBar.js b/src/components/SearchBar.js index 36ed623..3016f13 100644 --- a/src/components/SearchBar.js +++ b/src/components/SearchBar.js @@ -3,7 +3,7 @@ import React, {useEffect} from "react"; function SearchBar(props) { useEffect(() => { document.getElementById("searchBar").value = props.defaultTerm - }, []); + }, [props]); const handleChange = (e) => { props.changeParameter("searchTerm", e.target.value);