diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index f9804cf1..00000000 --- a/.eslintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": [ - "react-app", - "./.eslintrc-base.json", - "plugin:prettier/recommended" - ], - "globals": { - "Promise": true, - "window": true, - "$": true, - "ga": true, - "jQuery": true, - "router": true - }, - "settings": { - "import/ignore": ["node_modules", "\\.json$"], - "import/extensions": [".js", ".jsx"] - } -} diff --git a/.eslintrc-base.json b/.eslintrc-base.json deleted file mode 100644 index 331d94d9..00000000 --- a/.eslintrc-base.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "rules": { - "max-len": [ - "error", - { "code": 80, "ignoreUrls": true, "ignoreTemplateLiterals": true } - ], - "block-scoped-var": 0, - "brace-style": [2, "1tbs", { "allowSingleLine": true }], - "camelcase": 2, - "comma-dangle": 2, - "comma-spacing": [2, { "before": false, "after": true }], - "comma-style": [2, "last"], - "complexity": 0, - "consistent-return": 2, - "consistent-this": 0, - "curly": 2, - "default-case": 2, - "dot-notation": 0, - "eol-last": 2, - "eqeqeq": 2, - "func-call-spacing": 2, - "func-names": 0, - "func-style": 0, - "guard-for-in": 2, - "handle-callback-err": 2, - "import/default": 2, - "import/export": 2, - "import/extensions": [0, "always"], - "import/first": 2, - "import/named": 2, - "import/namespace": 2, - "import/newline-after-import": 2, - "import/no-duplicates": 2, - "import/no-unresolved": 2, - "import/unambiguous": 2, - "jsx-quotes": [2, "prefer-single"], - "key-spacing": [2, { "beforeColon": false, "afterColon": true }], - "keyword-spacing": [2], - "max-depth": 0, - "max-nested-callbacks": 0, - "max-params": 0, - "max-statements": 0, - "new-cap": 0, - "new-parens": 2, - "no-alert": 2, - "no-array-constructor": 2, - "no-bitwise": 2, - "no-caller": 2, - "no-cond-assign": 2, - "no-console": 0, - "no-constant-condition": 2, - "no-control-regex": 2, - "no-debugger": 2, - "no-delete-var": 2, - "no-div-regex": 2, - "no-dupe-keys": 2, - "no-else-return": 0, - "no-empty": 2, - "no-empty-character-class": 2, - "no-eq-null": 2, - "no-eval": 2, - "no-ex-assign": 2, - "no-extend-native": 2, - "no-extra-bind": 2, - "no-extra-boolean-cast": 2, - "no-extra-parens": 0, - "no-extra-semi": 2, - "no-fallthrough": 2, - "no-floating-decimal": 2, - "no-func-assign": 2, - "no-global-assign": 2, - "no-implied-eval": 2, - "no-inline-comments": 2, - "no-inner-declarations": 2, - "no-invalid-regexp": 2, - "no-irregular-whitespace": 2, - "no-iterator": 2, - "no-label-var": 2, - "no-labels": 2, - "no-lone-blocks": 2, - "no-lonely-if": 2, - "no-loop-func": 2, - "no-mixed-requires": 0, - "no-mixed-spaces-and-tabs": 2, - "no-multi-spaces": 2, - "no-multi-str": 2, - "no-multiple-empty-lines": [2, { "max": 2 }], - "no-nested-ternary": 2, - "no-new": 2, - "no-new-func": 2, - "no-new-object": 2, - "no-new-require": 2, - "no-new-wrappers": 2, - "no-obj-calls": 2, - "no-octal": 2, - "no-octal-escape": 2, - "no-path-concat": 2, - "no-plusplus": 0, - "no-process-env": 0, - "no-process-exit": 2, - "no-proto": 2, - "no-regex-spaces": 2, - "no-reserved-keys": 0, - "no-restricted-modules": 0, - "no-return-assign": 2, - "no-script-url": 2, - "no-self-compare": 2, - "no-sequences": 2, - "no-shadow": 0, - "no-shadow-restricted-names": 2, - "no-sparse-arrays": 2, - "no-sync": 0, - "no-ternary": 0, - "no-trailing-spaces": 2, - "no-undef": 2, - "no-undef-init": 2, - "no-undefined": 2, - "no-underscore-dangle": 0, - "no-unreachable": 2, - "no-unsafe-negation": 2, - "no-unused-expressions": 2, - "no-unused-vars": 2, - "no-use-before-define": 0, - "no-void": 0, - "no-warning-comments": [2, { "terms": ["fixme"], "location": "start" }], - "no-with": 2, - "one-var": 0, - "operator-assignment": 0, - "padded-blocks": 0, - "prettier/prettier": "error", - "quote-props": [2, "as-needed"], - "quotes": [2, "single", "avoid-escape"], - "radix": 2, - "react/display-name": 2, - "react/jsx-boolean-value": [2, "always"], - "react/jsx-closing-bracket-location": [ - 2, - { "selfClosing": "line-aligned", "nonEmpty": "props-aligned" } - ], - "react/jsx-no-undef": 2, - "react/jsx-sort-props": [2, { "ignoreCase": true }], - "react/jsx-uses-react": 2, - "react/jsx-uses-vars": 2, - "react/jsx-wrap-multilines": 2, - "react/no-did-mount-set-state": 2, - "react/no-did-update-set-state": 2, - "react/no-multi-comp": [2, { "ignoreStateless": true }], - "react/no-unescaped-entities": 0, - "react/no-unknown-property": 2, - "react/prop-types": 2, - "react/react-in-jsx-scope": 2, - "react/self-closing-comp": 2, - "react/sort-prop-types": 2, - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "error", - "semi": [2, "always"], - "semi-spacing": [2, { "before": false, "after": true }], - "sort-vars": 0, - "space-before-blocks": [2, "always"], - "space-before-function-paren": [2, "never"], - "space-in-brackets": 0, - "space-in-parens": 0, - "space-infix-ops": 2, - "space-unary-ops": [2, { "words": true, "nonwords": false }], - "spaced-comment": [2, "always", { "exceptions": ["-"] }], - "strict": 0, - "use-isnan": 2, - "valid-jsdoc": 0, - "valid-typeof": 2, - "vars-on-top": 0, - "wrap-iife": [2, "any"], - "wrap-regex": 2, - "yoda": 0 - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb2ff460..c79c25f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,67 +1,54 @@ -name: Coderadio-client ci +name: CI -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read jobs: lint: name: Lint - runs-on: ubuntu-20.04 - - strategy: - matrix: - node-version: [20.x] - + runs-on: ubuntu-latest steps: - - name: Checkout Source Files - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - - - name: Install modules - run: npm ci - - - name: Run ESLint - run: npm run lint - - cypress-run: - name: Cypress Test - # Netlify deploys onto Ubuntu 20.04, so we should test on that os: - runs-on: ubuntu-20.04 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run lint + + test: + name: Unit tests and build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + + e2e: + name: Cypress (${{ matrix.browser }}) + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - browsers: [chrome, firefox] - node-version: [20.x] + browser: [chrome, firefox] steps: - - name: Set Action Environment Variables - run: | - echo "CYPRESS_RECORD_KEY=${{ secrets.CYPRESS_RECORD_KEY }}" >> $GITHUB_ENV - echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV - echo "CYPRESS_INSTALL_BINARY=6.0.0" >> $GITHUB_ENV - - - name: Checkout - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - - - name: Cypress run - uses: cypress-io/github-action@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + - uses: cypress-io/github-action@18016a55674bd02cabf197de5762a1710cb5577c # v7.4.9 with: - browser: ${{ matrix.browsers }} + browser: ${{ matrix.browser }} build: npm run build - start: npm start + start: npm run preview wait-on: http://localhost:3001 - wait-on-timeout: 1200 - - unit-test: - name: Unit Test - runs-on: ubuntu-20.04 - - strategy: - matrix: - node-version: [20.x] - - steps: - - name: Checkout Source Files - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - - - name: Install modules - run: npm ci - - - name: Run tests - run: npm test diff --git a/.gitignore b/.gitignore index 340ac145..f0f3202f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ .env.production.local # production -/build +/dist # misc .DS_Store @@ -36,3 +36,6 @@ yarn-error.log* ### Netlify ### .netlify + +# cypress +/cypress/screenshots diff --git a/.npmrc b/.npmrc index 4976a3dc..b6f27f13 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1 @@ -CYPRESS_INSTALL_BINARY=0 engine-strict=true -enable-pre-post-scripts=true -package-manager-strict=false diff --git a/.nvmrc b/.nvmrc index 5bd68117..a45fd52c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.19.0 +24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..207c5479 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +package-lock.json +cypress/videos +cypress/screenshots diff --git a/.prettierrc b/.prettierrc index 5e01cb48..4378a1f0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "endOfLine":"auto", + "endOfLine": "auto", "semi": true, "singleQuote": true, "jsxSingleQuote": true, @@ -7,4 +7,3 @@ "trailingComma": "none", "arrowParens": "avoid" } - \ No newline at end of file diff --git a/README.md b/README.md index d4f38b2d..8173f581 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,19 @@ ## Coderadio Client UI -This repository powers the current client application for the Code Radio at: . +This repository powers the current client application for the Code Radio at: . Eventually we will move this over to our Gatsby based client application for our curriculum and user profiles. You can learn more about the coderadio here: ### Local setup -`npm ci` then `npm start` will open the app. +Use the Node version in `.nvmrc` (`nvm use`), then: -To send errors to Sentry: `cp sample.env .env.local` and fill in the Sentry DSN from the project settings \ No newline at end of file +- `npm ci` installs dependencies +- `npm start` runs the dev server on +- `npm test` runs the unit tests +- `npm run e2e` starts the dev server and runs the Cypress tests +- `npm run lint` checks ESLint and Prettier + +To send errors to Sentry: `cp sample.env .env.local` and fill in the Sentry DSN from the project settings. The same file lets you point a local build at a different AzuraCast server. diff --git a/cypress-install.js b/cypress-install.js deleted file mode 100644 index 1cd9c4bb..00000000 --- a/cypress-install.js +++ /dev/null @@ -1,12 +0,0 @@ -const util = require('cypress/lib/util'); -const execa = require('execa'); - -const pkg = util.pkgVersion(); - -(async () => { - console.log('Installing Cypress ' + pkg); - await execa('npm', ['run', 'cypress:install'], { - env: { CYPRESS_INSTALL_BINARY: pkg } - }); - console.log('Cypress installed'); -})(); diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 00000000..cd802a4d --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:3001', + supportFile: false, + video: false + } +}); diff --git a/cypress.json b/cypress.json deleted file mode 100644 index 2a37028d..00000000 --- a/cypress.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "projectId": "kqzjwp" -} \ No newline at end of file diff --git a/cypress/integration/home.js b/cypress/e2e/home.cy.js similarity index 54% rename from cypress/integration/home.js rename to cypress/e2e/home.cy.js index 6d9a3f07..7474bc64 100644 --- a/cypress/integration/home.js +++ b/cypress/e2e/home.cy.js @@ -1,7 +1,6 @@ -/* global cy */ describe('Landing page', () => { - it('Should render', () => { - cy.visit('http://localhost:3001'); + it('renders', () => { + cy.visit('/'); cy.title().should('eq', 'freeCodeCamp.org Code Radio'); }); }); diff --git a/cypress/e2e/play-button.cy.js b/cypress/e2e/play-button.cy.js new file mode 100644 index 00000000..2888aa26 --- /dev/null +++ b/cypress/e2e/play-button.cy.js @@ -0,0 +1,10 @@ +describe('Play button', () => { + it('starts the stream', () => { + cy.visit('/'); + cy.get('#toggle-play-pause').should('be.visible').click(); + cy.get('audio').should($audio => { + expect($audio[0].paused).to.eq(false); + expect($audio[0].src).to.contain('.mp3'); + }); + }); +}); diff --git a/cypress/e2e/reconnect.cy.js b/cypress/e2e/reconnect.cy.js new file mode 100644 index 00000000..ebc2dbb5 --- /dev/null +++ b/cypress/e2e/reconnect.cy.js @@ -0,0 +1,36 @@ +describe('Stream recovery', () => { + it('retries and then falls back to the other stream on errors', () => { + cy.intercept('GET', '**/api/nowplaying_static/coderadio.json', { + fixture: 'nowplaying.json' + }); + cy.intercept('GET', '**/api/live/nowplaying/sse*', { statusCode: 204 }); + cy.intercept('GET', '**/listen/coderadio/radio.mp3*', { + statusCode: 503 + }).as('high'); + cy.intercept('GET', '**/listen/coderadio/low.mp3*', { + statusCode: 503 + }).as('low'); + + cy.visit('/'); + cy.get('#toggle-play-pause').click(); + cy.wait('@high'); + cy.contains('Reconnecting…'); + cy.wait('@high'); + cy.wait('@low', { timeout: 10000 }); + }); + + it('recovers when the stream stalls without an error', () => { + cy.intercept('GET', '**/listen/coderadio/radio.mp3*', req => { + req.on('response', res => res.setThrottle(1)); + }); + + cy.visit('/'); + cy.get('#toggle-play-pause').click(); + cy.contains('Reconnecting…', { timeout: 20000 }); + cy.get('audio', { timeout: 60000 }).should($audio => { + expect($audio[0].src).to.contain('low.mp3'); + expect($audio[0].currentTime).to.be.greaterThan(2); + }); + cy.get('.connection-status').should('have.text', ''); + }); +}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json deleted file mode 100644 index da18d935..00000000 --- a/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} \ No newline at end of file diff --git a/cypress/fixtures/nowplaying.json b/cypress/fixtures/nowplaying.json new file mode 100644 index 00000000..388cbe57 --- /dev/null +++ b/cypress/fixtures/nowplaying.json @@ -0,0 +1,33 @@ +{ + "station": { + "mounts": [ + { + "name": "128kbps MP3", + "url": "https://coderadio-admin-v2.freecodecamp.org/listen/coderadio/radio.mp3", + "bitrate": 128, + "listeners": { "current": 1 } + }, + { + "name": "64kbps MP3", + "url": "https://coderadio-admin-v2.freecodecamp.org/listen/coderadio/low.mp3", + "bitrate": 64, + "listeners": { "current": 0 } + } + ], + "remotes": [] + }, + "listeners": { "current": 1, "unique": 1 }, + "now_playing": { + "played_at": 1700000000, + "elapsed": 0, + "duration": 180, + "song": { + "id": "1", + "title": "Test Song", + "artist": "Test Artist", + "album": "Test Album", + "art": "" + } + }, + "song_history": [] +} diff --git a/cypress/integration/play-button.js b/cypress/integration/play-button.js deleted file mode 100644 index 8895cedc..00000000 --- a/cypress/integration/play-button.js +++ /dev/null @@ -1,18 +0,0 @@ -describe('Stop and play the music', () => { - beforeEach(() => { - cy.visit('http://localhost:3001'); - }); - - it('Click play button', () => { - cy.get('audio') - .invoke('attr', 'src') - .should('contain', '.mp3') - .then(() => { - cy.get('#toggle-play-pause').should('be.visible').click(); - cy.get('audio').should(audioElements => { - const audioIsPaused = audioElements[0].paused; - expect(audioIsPaused).to.eq(false); - }); - }); - }); -}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js deleted file mode 100644 index 7fc486c3..00000000 --- a/cypress/plugins/index.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable no-unused-vars */ -// / -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index ca4d256f..00000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js 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) => { ... }) diff --git a/cypress/support/index.js b/cypress/support/index.js deleted file mode 100644 index 37a498fb..00000000 --- a/cypress/support/index.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js 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 commands.js using ES2015 syntax: -import './commands'; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..3541a0f5 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import prettier from 'eslint-config-prettier'; + +export default [ + { ignores: ['dist', 'cypress/videos', 'cypress/screenshots'] }, + js.configs.recommended, + reactHooks.configs.flat.recommended, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { ...globals.browser }, + parserOptions: { ecmaFeatures: { jsx: true } } + } + }, + { + files: ['src/**/*.test.{js,jsx}', 'src/test/**'], + languageOptions: { globals: { ...globals.node } } + }, + { + files: ['cypress/**', '*.config.js'], + languageOptions: { + globals: { + ...globals.node, + ...globals.mocha, + cy: 'readonly', + expect: 'readonly' + } + } + }, + prettier +]; diff --git a/public/index.html b/index.html similarity index 96% rename from public/index.html rename to index.html index eb265242..82b79387 100644 --- a/public/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -100,8 +100,9 @@
+ -