diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..6ec1bbde4 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,10 @@ +# This file lists commits which contain large but uninteresting changes (like +# applying code auto-formatting), so that you can ignore them in `git blame` +# output. +# +# To do this, run `git blame --ignore-revs-file .git-blame-ignore-revs`, +# or `git config blame.ignoreRevsFile .git-blame-ignore-revs` to configure +# git to do this by default. + +# Format entire codebase with Biome +40ec714bd10a541ebc33e47e7dfb85ade4fe890c diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 16593641f..f3c33df7a 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -27,6 +27,9 @@ jobs: - name: Install dependencies # --frozen-lockfile: don't generate a lockfile and fail if an update is needed run: yarn install --frozen-lockfile + + - name: Check for formatting and linting errors + run: yarn run check - name: Run build run: yarn run build diff --git a/.gitignore b/.gitignore index dca1b10b6..bef68c152 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ src/customLayers.json # testing /coverage chimp.js +error.png # production /build @@ -37,3 +38,8 @@ yarn-error.log* #vscode /.vscode + +# playwright +/test-results/ +/playwright-report/ +playwright/.auth/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7f63fe7dd..c3a72693a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -194,10 +194,24 @@ Unit tests are built with [Jest](https://facebook.github.io/jest/) + `yarn test` to run them in watch mode. +## Linting and formatting + +Run `yarn format` to format your code. Run `yarn lint` to check for lint errors. + +If you want, you can enable a pre-commit hook to check for linting and formatting +issues automatically when you run `git commit`. To enable the check, run this +command in the root of the repository: + +``` +git config core.hooksPath hooks +``` + +If you want to skip the check for a particular commit (for work-in-progress commits +for example), run `git commit --no-verify`. + ## CSS Styling and Naming -We are using SASS and [Tailwind -CSS](https://tailwindcss.com) with PostCSS. +We are using SASS and [Tailwind CSS](https://tailwindcss.com) with PostCSS. Tailwind configuration is controlled with the `src/tailwind.config.js` file. New CSS classes can be found in `src/styles/` diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..dbc460304 --- /dev/null +++ b/biome.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "include": ["src/"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "lineWidth": 100 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": false, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/eslint.config.js b/eslint.config.js index cccc22ff1..6483d1c27 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,7 +25,7 @@ export default [ }, }, rules: { - "no-unused-vars": "warn", + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", "unused-imports/no-unused-imports": "error", diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 000000000..efabff1d6 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +set -ex + +yarn run check diff --git a/package.json b/package.json index 6eb1aa01a..4f1de90a7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,32 @@ "version": "3.16.1", "type": "module", "private": true, + "scripts": { + "build-intl": "NODE_ENV=production extract-messages -l=en-US -o lang/ -d en-US --flat -f json 'src/**/!(*.test).js'", + "update-layers": "node scripts/update_layers.js", + "update-layers-prod": "NODE_ENV=production node scripts/update_layers.js", + "start-js": "vite", + "start": "npm-run-all -p build-env update-layers start-js", + "build-env": "dotenv -c -- jq -n 'env | with_entries(select(.key | startswith(\"REACT_APP_\")))' > public/env.json", + "build-js": "vite build", + "build": "yarn run build-env && yarn run build-intl && yarn run update-layers-prod && yarn run build-js", + "test": "vitest", + "test:cov": "vitest run --coverage", + "lint": "eslint src/", + "format": "biome format --write", + "check": "biome ci && eslint src/", + "explore": "source-map-explorer --only-mapped --no-border-checks 'dist/**/*.js'", + "test:e2e:start": "NODE_ENV=development yarn run build && npx serve dist", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ], "dependencies": { "@apollo/client": "^3.5.4", "@changey/react-leaflet-markercluster": "^4.0.0-rc1", @@ -95,10 +121,13 @@ "xmltojson": "^1.3.5" }, "devDependencies": { + "@biomejs/biome": "1.9.4", "@eslint/js": "^9.9.0", "@openstreetmap/id-tagging-schema": "^3.0.0", + "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^12.1.2", + "@types/node": "^22.10.5", "@vitejs/plugin-react-swc": "^3.7.1", "@vitest/coverage-v8": "^2.1.2", "dotenv": "^16.4.5", @@ -133,26 +162,6 @@ "vite": "^5.4.8", "vitest": "^2.1.2" }, - "scripts": { - "build-intl": "NODE_ENV=production extract-messages -l=en-US -o lang/ -d en-US --flat -f json 'src/**/!(*.test).js'", - "update-layers": "node scripts/update_layers.js", - "update-layers-prod": "NODE_ENV=production node scripts/update_layers.js", - "start-js": "vite", - "start": "npm-run-all -p build-env update-layers start-js", - "build-env": "dotenv -c -- jq -n 'env | with_entries(select(.key | startswith(\"REACT_APP_\")))' > public/env.json", - "build-js": "vite build", - "build": "yarn run build-env && yarn run build-intl && yarn run update-layers-prod && yarn run build-js", - "test": "vitest", - "test:cov": "vitest run --coverage", - "lint": "eslint src/", - "explore": "source-map-explorer --only-mapped --no-border-checks 'dist/**/*.js'" - }, - "browserslist": [ - ">0.2%", - "not dead", - "not ie <= 11", - "not op_mini all" - ], "resolutions": { "react-error-overlay": "6.0.9" } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..5f5923236 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,87 @@ +import path from "path"; +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import { fileURLToPath } from "url"; + +// Replicate __dirname functionality in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +// Simplified environment variable handling +const requiredEnvVars = { + REACT_APP_USERNAME: process.env.REACT_APP_USERNAME, + REACT_APP_PASSWORD: process.env.REACT_APP_PASSWORD, + REACT_APP_URL: process.env.REACT_APP_URL, +}; + +// Validate required environment variables +Object.entries(requiredEnvVars).forEach(([key, value]) => { + if (!value) { + throw new Error( + `Required environment variable ${key} is missing. Please add it to .env.local` + ); + } +}); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./playwright/tests", + headless: true, // Run in headless mode for faster execution + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + globalSetup: "./playwright/global-setup.js", + + use: { + baseURL: process.env.REACT_APP_URL || "http://localhost:3000", + storageState: "./playwright/.auth/state.json", + trace: "on-first-retry", + navigationTimeout: 30000, + actionTimeout: 15000, + }, + + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + }, + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + }, + }, + { + name: "edge", + use: { + ...devices["Desktop Edge"], + }, + }, + ], + + webServer: { + command: "yarn run test:e2e:start", + url: process.env.REACT_APP_URL || "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 30000, + env: requiredEnvVars, + }, +}); diff --git a/playwright/global-setup.js b/playwright/global-setup.js new file mode 100644 index 000000000..ae4333236 --- /dev/null +++ b/playwright/global-setup.js @@ -0,0 +1,44 @@ +import { chromium } from "@playwright/test"; +import fs from "fs"; +import path from "path"; + +async function globalSetup() { + const storageState = "./playwright/.auth/state.json"; + const storageDir = path.dirname(storageState); + + if (!fs.existsSync(storageDir)) { + fs.mkdirSync(storageDir, { recursive: true }); + } + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Navigate and sign in + await page.goto(process.env.REACT_APP_URL || "http://localhost:3000"); + await page.locator("a").filter({ hasText: "Sign in" }).click(); + + // Handle OSM login + await page.locator("#username").fill(process.env.REACT_APP_USERNAME); + await page.locator("#password").fill(process.env.REACT_APP_PASSWORD); + await page.locator('input[type="submit"][value="Log in"]').click(); + + // Handle OAuth if needed + try { + const authorizeButton = await page.waitForSelector( + 'input[type="submit"][value="Authorize"]', + { timeout: 5000 } + ); + if (authorizeButton) { + await authorizeButton.click(); + } + } catch (e) {} + + await context.storageState({ path: storageState }); + } finally { + await context.close(); + await browser.close(); + } +} + +export default globalSetup; diff --git a/playwright/tests/loggedInNavigation.spec.js b/playwright/tests/loggedInNavigation.spec.js new file mode 100644 index 000000000..755138594 --- /dev/null +++ b/playwright/tests/loggedInNavigation.spec.js @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Logged in navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page + .getByRole("banner") + .locator("a") + .filter({ hasText: "Sign in" }) + .click(); + await page + .getByRole("link", { name: "My Points" }) + .waitFor({ state: "visible", timeout: 5000 }); + }); + + test("should navigate to Find Challenges", async ({ page }) => { + await page + .getByRole("navigation") + .getByRole("link", { name: "Find Challenges" }) + .click(); + await expect( + page.getByRole("heading", { name: "Challenges" }).locator("span") + ).toBeVisible(); + }); +}); diff --git a/playwright/tests/loggedOutNavigation.spec.js b/playwright/tests/loggedOutNavigation.spec.js new file mode 100644 index 000000000..a01eaeb60 --- /dev/null +++ b/playwright/tests/loggedOutNavigation.spec.js @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Logged out navigation", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + }); + + test("should load find challenges page", async ({ page }) => { + await page + .getByRole("navigation") + .getByRole("link", { name: "Find Challenges" }) + .click(); + await expect( + page.getByRole("heading", { name: "Challenges" }).locator("span") + ).toBeVisible(); + await expect( + page.locator("a").filter({ hasText: "Sign in" }) + ).toBeVisible(); + }); + + test("should load leaderboard page", async ({ page }) => { + await page + .getByRole("navigation") + .getByRole("link", { name: "Leaderboard" }) + .click(); + await page.waitForLoadState("networkidle"); + }); + + test("should load learn page", async ({ page }) => { + await page + .getByRole("navigation") + .getByRole("link", { name: "Learn" }) + .click(); + await page.waitForLoadState("networkidle"); + }); + + test("should load blog page", async ({ page }) => { + await page + .getByRole("navigation") + .getByRole("link", { name: "Blog" }) + .click(); + await page.waitForLoadState("networkidle"); + }); + + test("should load donate page", async ({ page }) => { + await page + .getByRole("navigation") + .getByRole("link", { name: "Donate" }) + .click(); + await page.waitForLoadState("networkidle"); + }); +}); diff --git a/playwright/tests/login.spec.js b/playwright/tests/login.spec.js new file mode 100644 index 000000000..c0a2d6770 --- /dev/null +++ b/playwright/tests/login.spec.js @@ -0,0 +1,25 @@ +import { test } from "@playwright/test"; + +test.describe("Logged in navigation", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("should login and redirect to maproulette", async ({ page }) => { + await page.goto("/"); + await page + .getByRole("banner") + .locator("a") + .filter({ hasText: "Sign in" }) + .click(); + await page + .getByLabel("Email Address or Username") + .fill(process.env.REACT_APP_USERNAME || ""); + await page + .getByLabel("Password") + .fill(process.env.REACT_APP_PASSWORD || ""); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForLoadState("networkidle"); + await page + .getByRole("link", { name: "My Points" }) + .waitFor({ state: "visible", timeout: 5000 }); + }); +}); diff --git a/src/App.jsx b/src/App.jsx index a0edb1afe..db9c426ab 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,77 +1,71 @@ -import { Fragment, Component } from 'react' -import { Switch, Route, Redirect } from 'react-router-dom' -import { withRouter } from 'react-router' -import Home from './pages/Home/Home' -import Profile from './pages/Profile/Profile' -import Metrics from './pages/Metrics/Metrics' -import Dashboard from './pages/Dashboard/Dashboard.jsx' -import Leaderboard from './pages/Leaderboard/Leaderboard' -import ChallengeLeaderboard from './pages/Leaderboard/ChallengeLeaderboard' -import ProjectLeaderboard from './pages/Leaderboard/ProjectLeaderboard' +import { Component, Fragment } from "react"; +import { withRouter } from "react-router"; +import { Redirect, Route, Switch } from "react-router-dom"; +import AdminPane from "./components/AdminPane/AdminPane"; +import InspectTask from "./components/AdminPane/Manage/InspectTask/InspectTask"; +import ChallengeDetail from "./components/ChallengeDetail/ChallengeDetail"; // import CountryLeaderboard from './pages/Leaderboard/CountryLeaderboard' -import ChallengePane from './components/ChallengePane/ChallengePane' -import ChallengeDetail from './components/ChallengeDetail/ChallengeDetail' -import ProjectDetail from './components/ProjectDetail/ProjectDetail' -import TaskPane from './components/TaskPane/TaskPane' -import PublicTaskPane from './components/TaskPane/PublicTaskPane' -import ReviewTaskPane from './components/ReviewTaskPane/ReviewTaskPane' -import AdminPane from './components/AdminPane/AdminPane' -import InspectTask from './components/AdminPane/Manage/InspectTask/InspectTask' -import Review from './pages/Review/Review' -import Inbox from './pages/Inbox/Inbox' -import Sent from './pages/Sent/Sent' -import Teams from './pages/Teams/Teams' -import Achievements from './pages/Achievements/Achievements' -import Social from './pages/Social/Social' -import GlobalActivity from './pages/GlobalActivity/GlobalActivity' -import PageNotFound from './components/PageNotFound/PageNotFound' -import { resetCache } from './services/Server/RequestCache' -import WithCurrentUser from './components/HOCs/WithCurrentUser/WithCurrentUser' -import WithCurrentTask from './components/HOCs/WithCurrentTask/WithCurrentTask' -import WithExternalError - from './components/HOCs/WithExternalError/WithExternalError' -import WithVirtualChallenge - from './components/HOCs/WithVirtualChallenge/WithVirtualChallenge' -import LoadRandomChallengeTask - from './components/LoadRandomChallengeTask/LoadRandomChallengeTask' -import LoadRandomVirtualChallengeTask - from './components/LoadRandomVirtualChallengeTask/LoadRandomVirtualChallengeTask' -import HeadTitle from './components/Head/Head' -import Navbar from './components/Navbar/Navbar' -import SystemNotices from './components/SystemNotices/SystemNotices' -import FundraisingNotices from './components/FundraisingNotices/FundraisingNotices' -import Footer from './components/Footer/Footer' -import ErrorModal from './components/ErrorModal/ErrorModal' -import Sprites from './components/Sprites/Sprites' -import SuperAdminContainer from './components/SuperAdmin/SuperAdminContainer' -import MobileNotSupported - from './components/MobileNotSupported/MobileNotSupported' -import CheckForToken from './components/CheckForToken/CheckForToken' -import './components/Widgets/widget_registry' -import './App.scss' -import TestEnvironmentBanner from './components/TestEnvironmentBanner/TestEnvironmentBanner.jsx' +import ChallengePane from "./components/ChallengePane/ChallengePane"; +import CheckForToken from "./components/CheckForToken/CheckForToken"; +import ErrorModal from "./components/ErrorModal/ErrorModal"; +import Footer from "./components/Footer/Footer"; +import FundraisingNotices from "./components/FundraisingNotices/FundraisingNotices"; +import WithCurrentTask from "./components/HOCs/WithCurrentTask/WithCurrentTask"; +import WithCurrentUser from "./components/HOCs/WithCurrentUser/WithCurrentUser"; +import WithExternalError from "./components/HOCs/WithExternalError/WithExternalError"; +import WithVirtualChallenge from "./components/HOCs/WithVirtualChallenge/WithVirtualChallenge"; +import HeadTitle from "./components/Head/Head"; +import LoadRandomChallengeTask from "./components/LoadRandomChallengeTask/LoadRandomChallengeTask"; +import LoadRandomVirtualChallengeTask from "./components/LoadRandomVirtualChallengeTask/LoadRandomVirtualChallengeTask"; +import MobileNotSupported from "./components/MobileNotSupported/MobileNotSupported"; +import Navbar from "./components/Navbar/Navbar"; +import PageNotFound from "./components/PageNotFound/PageNotFound"; +import ProjectDetail from "./components/ProjectDetail/ProjectDetail"; +import ReviewTaskPane from "./components/ReviewTaskPane/ReviewTaskPane"; +import Sprites from "./components/Sprites/Sprites"; +import SuperAdminContainer from "./components/SuperAdmin/SuperAdminContainer"; +import SystemNotices from "./components/SystemNotices/SystemNotices"; +import PublicTaskPane from "./components/TaskPane/PublicTaskPane"; +import TaskPane from "./components/TaskPane/TaskPane"; +import Achievements from "./pages/Achievements/Achievements"; +import Dashboard from "./pages/Dashboard/Dashboard.jsx"; +import GlobalActivity from "./pages/GlobalActivity/GlobalActivity"; +import Home from "./pages/Home/Home"; +import Inbox from "./pages/Inbox/Inbox"; +import ChallengeLeaderboard from "./pages/Leaderboard/ChallengeLeaderboard"; +import Leaderboard from "./pages/Leaderboard/Leaderboard"; +import ProjectLeaderboard from "./pages/Leaderboard/ProjectLeaderboard"; +import Metrics from "./pages/Metrics/Metrics"; +import Profile from "./pages/Profile/Profile"; +import Review from "./pages/Review/Review"; +import Sent from "./pages/Sent/Sent"; +import Social from "./pages/Social/Social"; +import Teams from "./pages/Teams/Teams"; +import { resetCache } from "./services/Server/RequestCache"; +import "./components/Widgets/widget_registry"; +import "./App.scss"; +import TestEnvironmentBanner from "./components/TestEnvironmentBanner/TestEnvironmentBanner.jsx"; // Setup child components with necessary HOCs -const TopNav = withRouter(WithCurrentUser(Navbar)) +const TopNav = withRouter(WithCurrentUser(Navbar)); const CurrentTaskPaneInternal = (props) => { - const loggedIn = localStorage.getItem('isLoggedIn') - return loggedIn ? : -} -const CurrentTaskPane = WithCurrentTask(CurrentTaskPaneInternal) + const loggedIn = localStorage.getItem("isLoggedIn"); + return loggedIn ? : ; +}; +const CurrentTaskPane = WithCurrentTask(CurrentTaskPaneInternal); -const CurrentReviewTaskPane = WithCurrentTask(ReviewTaskPane, true) -const CurrentMetaReviewTaskPane = WithCurrentTask(ReviewTaskPane, true) -const CurrentVirtualChallengeTaskPane = - WithVirtualChallenge(WithCurrentTask(TaskPane)) -const VirtualChallengePane = WithVirtualChallenge(ChallengeDetail) -const ErrorPane = WithExternalError(ChallengePane) +const CurrentReviewTaskPane = WithCurrentTask(ReviewTaskPane, true); +const CurrentMetaReviewTaskPane = WithCurrentTask(ReviewTaskPane, true); +const CurrentVirtualChallengeTaskPane = WithVirtualChallenge(WithCurrentTask(TaskPane)); +const VirtualChallengePane = WithVirtualChallenge(ChallengeDetail); +const ErrorPane = WithExternalError(ChallengePane); const HomeOrDashboard = () => { - const goHome = sessionStorage.getItem('goHome') - const loggedIn = localStorage.getItem('isLoggedIn') - sessionStorage.removeItem('goHome') - return (loggedIn && !goHome) ? : -} + const goHome = sessionStorage.getItem("goHome"); + const loggedIn = localStorage.getItem("isLoggedIn"); + sessionStorage.removeItem("goHome"); + return loggedIn && !goHome ? : ; +}; /** * App represents the top level component of the application. It renders a @@ -83,20 +77,24 @@ const HomeOrDashboard = () => { export class App extends Component { state = { firstTimeModalDismissed: false, - shouldDisplayError: true - } + shouldDisplayError: true, + }; dismissModal = () => { - this.setState({firstTimeModalDismissed: true}) - } + this.setState({ firstTimeModalDismissed: true }); + }; render() { // We don't currently support mobile devices. Unless the mobile feature // is explicitly enabled, inform user that mobile is not supported. - if (window.env.REACT_APP_FEATURE_MOBILE_DEVICES !== 'enabled') { + if (window.env.REACT_APP_FEATURE_MOBILE_DEVICES !== "enabled") { // This is a pretty simplistic check, but it should catch most cases. - if (/iPhone|iPad|iPod|BlackBerry|IEMobile|Fennec|Android|Mobile|Tablet/i.test(navigator.userAgent)) { - return + if ( + /iPhone|iPad|iPod|BlackBerry|IEMobile|Fennec|Android|Mobile|Tablet/i.test( + navigator.userAgent, + ) + ) { + return ; } } @@ -107,48 +105,77 @@ export class App extends Component { -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* */} - - - - - - - -
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + + + + + + +