From 6b9b277cd83d397687148edfd18b9417253025e8 Mon Sep 17 00:00:00 2001 From: A Cottrill Date: Tue, 5 Nov 2024 11:14:20 -0500 Subject: [PATCH 1/2] feat: use vite testing --- .github/workflows/ci.yml | 4 +- .gitignore | 1 + cypress-install.js | 23 +-- cypress.config.ts | 8 + cypress.json | 3 - .../{integration/home.js => e2e/home.cy.ts} | 0 .../play-button.js => e2e/play-button.cy.ts} | 12 +- cypress/plugins/index.js | 22 --- cypress/support/{commands.js => commands.ts} | 0 cypress/support/{index.js => e2e.ts} | 0 cypress/tsconfig.json | 17 ++ index.html | 120 +++++++++++++ package.json | 38 ++-- sample.env | 2 +- src/assets/icons/pause.tsx | 15 ++ src/assets/icons/play.tsx | 14 ++ src/assets/pause.svg | 5 - src/assets/play.svg | 4 - src/components/{App.test.js => App.test.tsx} | 6 +- src/components/{App.js => App.tsx} | 167 +++++++++++++----- src/components/CurrentSong.js | 48 ----- src/components/CurrentSong.tsx | 48 +++++ src/components/{Footer.js => Footer.tsx} | 104 +++++++---- src/components/{Main.js => Main.tsx} | 18 +- src/components/{Nav.test.js => Nav.test.tsx} | 1 + src/components/{Nav.js => Nav.tsx} | 6 +- ...PlayPauseButton.js => PlayPauseButton.tsx} | 33 ++-- src/components/{Slider.js => Slider.tsx} | 19 +- .../{SongHistory.js => SongHistory.tsx} | 27 ++- .../{Visualizer.js => Visualizer.tsx} | 78 +++++--- src/{index.js => index.tsx} | 11 +- src/interfaces/EqualizerData.d.ts | 7 + src/interfaces/NetworkInfo.d.ts | 27 +++ src/interfaces/SongDetails.d.ts | 12 ++ src/interfaces/vite-env.d.ts | 16 ++ src/utils/buildEventSource.js | 3 - src/utils/buildEventSource.ts | 3 + testSetup.ts | 15 ++ tsconfg.json | 27 +++ vite.config.ts | 30 ++++ 40 files changed, 731 insertions(+), 263 deletions(-) create mode 100644 cypress.config.ts delete mode 100644 cypress.json rename cypress/{integration/home.js => e2e/home.cy.ts} (100%) rename cypress/{integration/play-button.js => e2e/play-button.cy.ts} (61%) delete mode 100644 cypress/plugins/index.js rename cypress/support/{commands.js => commands.ts} (100%) rename cypress/support/{index.js => e2e.ts} (100%) create mode 100644 cypress/tsconfig.json create mode 100644 index.html create mode 100644 src/assets/icons/pause.tsx create mode 100644 src/assets/icons/play.tsx delete mode 100644 src/assets/pause.svg delete mode 100644 src/assets/play.svg rename src/components/{App.test.js => App.test.tsx} (56%) rename src/components/{App.js => App.tsx} (82%) delete mode 100644 src/components/CurrentSong.js create mode 100644 src/components/CurrentSong.tsx rename src/components/{Footer.js => Footer.tsx} (64%) rename src/components/{Main.js => Main.tsx} (81%) rename src/components/{Nav.test.js => Nav.test.tsx} (95%) rename src/components/{Nav.js => Nav.tsx} (97%) rename src/components/{PlayPauseButton.js => PlayPauseButton.tsx} (73%) rename src/components/{Slider.js => Slider.tsx} (57%) rename src/components/{SongHistory.js => SongHistory.tsx} (83%) rename src/components/{Visualizer.js => Visualizer.tsx} (77%) rename src/{index.js => index.tsx} (50%) create mode 100644 src/interfaces/EqualizerData.d.ts create mode 100644 src/interfaces/NetworkInfo.d.ts create mode 100644 src/interfaces/SongDetails.d.ts create mode 100644 src/interfaces/vite-env.d.ts delete mode 100644 src/utils/buildEventSource.js create mode 100644 src/utils/buildEventSource.ts create mode 100644 testSetup.ts create mode 100644 tsconfg.json create mode 100644 vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb2ff460..0754b7ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,13 +34,13 @@ jobs: 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 + echo "CYPRESS_INSTALL_BINARY=13.14.2" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - name: Cypress run - uses: cypress-io/github-action@v2 + uses: cypress-io/github-action@v4 with: browser: ${{ matrix.browsers }} build: npm run build diff --git a/.gitignore b/.gitignore index 340ac145..a1b805c9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # production /build +/dist # misc .DS_Store diff --git a/cypress-install.js b/cypress-install.js index 1cd9c4bb..07f541bd 100644 --- a/cypress-install.js +++ b/cypress-install.js @@ -1,12 +1,15 @@ -const util = require('cypress/lib/util'); -const execa = require('execa'); +import { spawn } from 'child_process'; -const pkg = util.pkgVersion(); +const child = spawn('npm', ['run', 'cypress:install'], { + stdio: 'inherit' +}); -(async () => { - console.log('Installing Cypress ' + pkg); - await execa('npm', ['run', 'cypress:install'], { - env: { CYPRESS_INSTALL_BINARY: pkg } - }); - console.log('Cypress installed'); -})(); +child.on('close', code => { + if (code) { + console.error('Cypress installation failed with code:', code); + } +}); + +child.on('error', error => { + console.error('Cypress installation error:', error); +}); diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 00000000..beafa1b7 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,8 @@ +const { defineConfig } = require('cypress'); + +module.exports = defineConfig({ + e2e: { + baseUrl: 'http://localhost:3001', + retries: 4 + } +}) 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.ts similarity index 100% rename from cypress/integration/home.js rename to cypress/e2e/home.cy.ts diff --git a/cypress/integration/play-button.js b/cypress/e2e/play-button.cy.ts similarity index 61% rename from cypress/integration/play-button.js rename to cypress/e2e/play-button.cy.ts index 8895cedc..f487807d 100644 --- a/cypress/integration/play-button.js +++ b/cypress/e2e/play-button.cy.ts @@ -1,9 +1,13 @@ describe('Stop and play the music', () => { - beforeEach(() => { - cy.visit('http://localhost:3001'); - }); + it('Click play button', function () { - it('Click play button', () => { + // Endless test trying to load the Discord integration + // https://stackoverflow.com/questions/64673128/cypress-iframe-function-works-on-chrome-but-not-firefox + if (Cypress.browser.name === 'firefox') { + this.skip(); + } + + cy.visit('http://localhost:3001'); cy.get('audio') .invoke('attr', 'src') .should('contain', '.mp3') 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.ts similarity index 100% rename from cypress/support/commands.js rename to cypress/support/commands.ts diff --git a/cypress/support/index.js b/cypress/support/e2e.ts similarity index 100% rename from cypress/support/index.js rename to cypress/support/e2e.ts diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000..9d08d71d --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "esnext", + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "jsx": "preserve", + "allowJs": false, + "noImplicitAny": true, + "moduleResolution": "node", + "isolatedModules": true, + "types": ["node","cypress"] + }, + "include": [ + "**/*.ts", + ], +} diff --git a/index.html b/index.html new file mode 100644 index 00000000..8dce8ca1 --- /dev/null +++ b/index.html @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + freeCodeCamp.org Code Radio + + + + +
+ + + + diff --git a/package.json b/package.json index fa16469c..d6da59b5 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "coderadio", "version": "0.1.0", "private": true, + "type": "module", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", @@ -11,16 +12,15 @@ "react": "18.3.1", "react-device-detect": "2.2.3", "react-dom": "18.3.1", - "react-page-visibility": "7.0.0", - "react-scripts": "5.0.1", - "store": "2.0.12" + "react-page-visibility": "7.0.0" }, "scripts": { - "start": "PORT=3001 react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --watchAll=false", - "test:watch": "react-scripts test", - "eject": "react-scripts eject", + "start": "vite", + "build": "vite build", + "test": "vitest", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage .", + "test:debug": "vitest --inspect-brk --runInBand --no-cache", "precypress": "node cypress-install.js", "cypress": "cypress", "cypress:open": "npm run cypress open", @@ -42,15 +42,29 @@ ] }, "devDependencies": { - "@testing-library/jest-dom": "6.6.2", + "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "16.0.1", - "cypress": "13.15.1", + "@types/jest": "29.5.13", + "@types/node": "22.5.4", + "@types/react": "18.3.5", + "@types/react-dom": "18.3.0", + "@vitejs/plugin-react": "4.3.1", + "cypress": "13.14.2", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.2.1", - "execa": "9.5.1", + "eventsource": "2.0.2", + "eventsourcemock": "2.0.0", + "happy-dom": "15.7.4", "husky": "9.1.6", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", "lint-staged": "15.2.10", - "prettier": "3.3.3" + "prettier": "3.3.3", + "ts-jest": "29.2.5", + "typescript": "5.6.2", + "vite": "5.4.4", + "vite-tsconfig-paths": "5.0.1", + "vitest": "2.1.0" }, "lint-staged": { "*.js": "npm run lint:fix" diff --git a/sample.env b/sample.env index 01c93e9a..389511e6 100644 --- a/sample.env +++ b/sample.env @@ -1,2 +1,2 @@ # Sentry DSN - a public id that identifies your app to Sentry -REACT_APP_SENTRY_DSN= \ No newline at end of file +VITE_SENTRY_DSN= diff --git a/src/assets/icons/pause.tsx b/src/assets/icons/pause.tsx new file mode 100644 index 00000000..915e8a5f --- /dev/null +++ b/src/assets/icons/pause.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +function Pause(): JSX.Element { + + return ( + Pause Button + + + + ); +} + + +Pause.displayName = 'Caret'; +export default Pause; diff --git a/src/assets/icons/play.tsx b/src/assets/icons/play.tsx new file mode 100644 index 00000000..da5425ce --- /dev/null +++ b/src/assets/icons/play.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +function Play(): JSX.Element { + + return ( + + Play Button + + + ); +} + +Play.displayName = 'Play'; +export default Play; diff --git a/src/assets/pause.svg b/src/assets/pause.svg deleted file mode 100644 index 9604603b..00000000 --- a/src/assets/pause.svg +++ /dev/null @@ -1,5 +0,0 @@ - - Pause Button - - - \ No newline at end of file diff --git a/src/assets/play.svg b/src/assets/play.svg deleted file mode 100644 index ed6f9c4d..00000000 --- a/src/assets/play.svg +++ /dev/null @@ -1,4 +0,0 @@ - - Play Button - - \ No newline at end of file diff --git a/src/components/App.test.js b/src/components/App.test.tsx similarity index 56% rename from src/components/App.test.js rename to src/components/App.test.tsx index a754b201..9ee3756c 100644 --- a/src/components/App.test.js +++ b/src/components/App.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import ReactDOM from 'react-dom'; import App from './App'; +import { createRoot } from 'react-dom/client'; it('renders without crashing', () => { const div = document.createElement('div'); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); + const root = createRoot(div!); + root.render(); }); diff --git a/src/components/App.js b/src/components/App.tsx similarity index 82% rename from src/components/App.js rename to src/components/App.tsx index 889c8aa5..dd9aad0b 100644 --- a/src/components/App.js +++ b/src/components/App.tsx @@ -1,12 +1,11 @@ import React from 'react'; import * as Sentry from '@sentry/react'; -import store from 'store'; import { isIOS, isDesktop } from 'react-device-detect'; import Nav from './Nav'; import Main from './Main'; import Footer from './Footer'; -import { buildEventSource } from '../utils/buildEventSource'; + import '../css/App.css'; @@ -14,19 +13,71 @@ const sseUri = 'https://coderadio-admin-v2.freecodecamp.org/api/live/nowplaying/sse?cf_connect=%7B%22subs%22%3A%7B%22station%3Acoderadio%22%3A%7B%22recover%22%3Atrue%7D%7D%7D'; const jsonUri = `https://coderadio-admin-v2.freecodecamp.org/api/nowplaying_static/coderadio.json`; -let sse = buildEventSource(sseUri); + +let sse = new EventSource(sseUri); const CODERADIO_VOLUME = 'coderadio-volume'; -sse.onerror = ({ message, error }) => { +sse.onerror = (error) => { Sentry.addBreadcrumb({ - message: 'WebSocket error: ' + message + message: 'WebSocket error: ' + error }); Sentry.captureException(error); }; -export default class App extends React.Component { - constructor(props) { +interface AppProps +{ + +} + +interface AudioConfig { + maxVolume: number; + targetVolume: number; + fadeSteps: number; + volumeTransitionSpeed: number | undefined; + volumeSteps: number; + currentVolume: number; +} + +interface AppConfig +{ + metadataTimer: number; +} + +interface Remote +{ + url: string; +} + +interface AppState { + fastConnection: boolean; + config: AppConfig; + playing: boolean; + audioConfig: AudioConfig; + eq: Equalizer; + visualizer: any; + url: string; + mounts: Remote[]; + remotes: Remote[]; + + captions: null; + pausing: any; + pullMeta: any; + erroredStreams: any; + + // Note: the crossOrigin is needed to fix a CORS JavaScript requirement + + // There are a few *private* variables used + currentSong: SongDetails; + songStartedAt: number; + songDuration: number; + listeners: number; + songHistory: any; +} + +export default class App extends React.Component { + _player: HTMLAudioElement; + constructor(props: AppProps) { super(props); this.state = { // General configuration options @@ -73,7 +124,7 @@ export default class App extends React.Component { url: '', mounts: [], remotes: [], - playing: null, + playing: false, captions: null, pausing: null, pullMeta: false, @@ -82,7 +133,13 @@ export default class App extends React.Component { // Note: the crossOrigin is needed to fix a CORS JavaScript requirement // There are a few *private* variables used - currentSong: {}, + currentSong: { + id: '', + art: undefined, + title: '', + artist: '', + album: '' + }, songStartedAt: 0, songDuration: 0, listeners: 0, @@ -105,7 +162,7 @@ export default class App extends React.Component { this.handleKeyboardHotKeys = this.handleKeyboardHotKeys.bind(this); } - isSpacePressed(event) { + isSpacePressed(event: { key: string }) { return event.key === ' '; } @@ -118,20 +175,26 @@ export default class App extends React.Component { 'keyboard-controls', 'toggle-button-nav' ]; + if (document.activeElement == null) { + return false; + } return !disallowedIds.includes(document.activeElement.id); } - isUpDownArrowPressed(event) { + isUpDownArrowPressed(event: { key: string }) { return event.key === 'ArrowUp' || event.key === 'ArrowDown'; } canAdjustVolume() { // Ignore arrow hot keys if focus is on volume slider or stream selector. const disallowedIds = ['volume-input', 'stream-select']; + if (document.activeElement == null) { + return false; + } return !disallowedIds.includes(document.activeElement.id); } - handleKeyboardHotKeys(event) { + handleKeyboardHotKeys(event: { key: any }) { const keyMap = new Map(); keyMap.set(' ', this.togglePlay); keyMap.set('k', this.togglePlay); @@ -166,7 +229,8 @@ export default class App extends React.Component { * if not available set to default 0.5. */ const maxVolume = - store.get(CODERADIO_VOLUME) || this.state.audioConfig.maxVolume; + parseInt(localStorage.getItem(CODERADIO_VOLUME) ?? '') || + this.state.audioConfig.maxVolume; this.setState( { audioConfig: { @@ -201,8 +265,10 @@ export default class App extends React.Component { * and begin playing it again. This can happen if the server * resets the URL. */ - async setUrl(url = false) { - if (!url) return; + async setUrl(url = '') { + if (url.trim().length == 0) { + return; + } if (this.state.playing) await this.pause(); @@ -251,7 +317,7 @@ export default class App extends React.Component { // Completely stop the audio element if (!this.state.playing) return Promise.resolve(); - return new Promise(resolve => { + return new Promise(resolve => { this._player.pause(); this._player.load(); @@ -287,9 +353,9 @@ export default class App extends React.Component { } } - setTargetVolume(volume) { + setTargetVolume(volume: number) { let audioConfig = { ...this.state.audioConfig }; - let maxVolume = parseFloat(Math.max(0, Math.min(1, volume).toFixed(2))); + let maxVolume = parseFloat(Math.max(0, Math.min(1, volume)).toFixed(2)); audioConfig.maxVolume = maxVolume; audioConfig.currentVolume = maxVolume; this._player.volume = audioConfig.maxVolume; @@ -299,7 +365,7 @@ export default class App extends React.Component { }, () => { // Save user volume to local storage - store.set(CODERADIO_VOLUME, maxVolume); + localStorage.setItem(CODERADIO_VOLUME, maxVolume.toString()); } ); } @@ -308,7 +374,7 @@ export default class App extends React.Component { * Simple fade command to initiate the playing and pausing * in a more fluid method. */ - fade(direction) { + fade(direction: string) { let audioConfig = { ...this.state.audioConfig }; audioConfig.targetVolume = direction.toLowerCase() === 'up' ? this.state.audioConfig.maxVolume : 0; @@ -388,49 +454,54 @@ export default class App extends React.Component { } } - sortStreams = (streams, lowBitrate = false, shuffle = false) => { + sortStreams = (streams: any[], lowBitrate = false, shuffle = false) => { if (shuffle) { /** * Shuffling should only happen among streams with similar bitrates * since each relay displays listener numbers across relays. Shuffling * should be used to spread the load on initial stream selection. */ - let bitrates = streams.map(stream => stream.bitrate); + let bitrates = streams.map((stream: { bitrate: any }) => stream.bitrate); let maxBitrate = Math.max(...bitrates); return streams - .filter(stream => { + .filter((stream: { bitrate: number }) => { if (!lowBitrate) return stream.bitrate === maxBitrate; else return stream.bitrate !== maxBitrate; }) .sort(() => Math.random() - 0.5); } else { - return streams.sort((a, b) => { - if (lowBitrate) { - // Sort by bitrate from low to high - if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return -1; - if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return 1; - } else { - // Sort by bitrate, from high to low - if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return 1; - if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return -1; + return streams.sort( + ( + a: { bitrate: string; listeners: { current: number } }, + b: { bitrate: string; listeners: { current: number } } + ) => { + if (lowBitrate) { + // Sort by bitrate from low to high + if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return -1; + if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return 1; + } else { + // Sort by bitrate, from high to low + if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return 1; + if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return -1; + } + + // If both items have the same bitrate, sort by listeners from low to high + if (a.listeners.current < b.listeners.current) return -1; + if (a.listeners.current > b.listeners.current) return 1; + return 0; } - - // If both items have the same bitrate, sort by listeners from low to high - if (a.listeners.current < b.listeners.current) return -1; - if (a.listeners.current > b.listeners.current) return 1; - return 0; - }); + ); } }; - getStreamUrl = (streams, lowBitrate) => { + getStreamUrl = (streams: never[], lowBitrate: boolean = false) => { const sorted = this.sortStreams(streams, lowBitrate, true); return sorted[0].url; }; // Choose the stream based on the connection and availability of relay(remotes) setMountToConnection(mounts = [], remotes = []) { - let url = null; + let url = ""; if (this.state.fastConnection === false && remotes.length > 0) { url = this.getStreamUrl(remotes, true); } else if (this.state.fastConnection && remotes.length > 0) { @@ -540,9 +611,11 @@ export default class App extends React.Component { const { mounts, remotes, erroredStreams, url } = this.state; const sortedStreams = this.sortStreams([...remotes, ...mounts]); - const currentStream = sortedStreams.find(stream => stream.url === url); + const currentStream = sortedStreams.find( + (stream: { url: any }) => stream.url === url + ); const isStreamInErroredList = erroredStreams.some( - stream => stream.url === url + (stream: { url: any }) => stream.url === url ); const newErroredStreams = isStreamInErroredList ? erroredStreams @@ -560,9 +633,9 @@ export default class App extends React.Component { */ const availableUrls = sortedStreams .filter( - stream => + (stream: { url: any }) => !newErroredStreams.some( - erroredStream => erroredStream.url === stream.url + (erroredStream: { url: any }) => erroredStream.url === stream.url ) ) .map(({ url }) => url); @@ -591,9 +664,9 @@ export default class App extends React.Component { aria-label='audio' crossOrigin='anonymous' onError={this.onPlayerError} - ref={a => (this._player = a)} + ref={a => (this._player = a as HTMLAudioElement )} > - +