diff --git a/.circleci/config.yml b/.circleci/config.yml index 5effc0bb2..cc6bd8d82 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -82,6 +82,9 @@ jobs: - run: name: Test - HTML sanitization command: npm run test:safehtml + - run: + name: Test - Helpers + command: npm run test:helpers - run: name: Test - Coverage command: npm run test:coverage diff --git a/client/homebrew/homebrew.jsx b/client/homebrew/homebrew.jsx index 138b54f85..716291764 100644 --- a/client/homebrew/homebrew.jsx +++ b/client/homebrew/homebrew.jsx @@ -1,6 +1,5 @@ import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers import './homebrew.less'; -import React from 'react'; import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router'; import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js'; diff --git a/client/homebrew/pages/sharePage/sharePage.jsx b/client/homebrew/pages/sharePage/sharePage.jsx index 8df241d7b..7a14234a7 100644 --- a/client/homebrew/pages/sharePage/sharePage.jsx +++ b/client/homebrew/pages/sharePage/sharePage.jsx @@ -16,8 +16,9 @@ import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; const SharePage = (props)=>{ - const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props; + const { disableMeta = false } = props; + const [currentBrew, setCurrentBrew] = useState(props.brew || DEFAULT_BREW_LOAD); const [themeBundle, setThemeBundle] = useState({}); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); @@ -37,7 +38,20 @@ const SharePage = (props)=>{ useEffect(()=>{ document.addEventListener('keydown', handleControlKeys); - fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme); + fetchThemeBundle(undefined, setThemeBundle, currentBrew.renderer, currentBrew.theme); + + // listen for changes in the brew version + const eventSource = new EventSource('/stream'); + eventSource.addEventListener('message', (evt)=>{ + const messageData = JSON.parse(evt.data); + + if(messageData.eventType == 'brewUpdated'){ + if(messageData.shareId == currentBrew.shareId && messageData.version != currentBrew.version) { + console.log(`brew has been updated, viewing ${currentBrew.version}, new version is ${messageData.version}`); + console.log('should fetch brew'); + } + } + }); return ()=>{ document.removeEventListener('keydown', handleControlKeys); @@ -45,13 +59,13 @@ const SharePage = (props)=>{ }, []); const processShareId = ()=>{ - return brew.googleId && !brew.stubbed ? brew.googleId + brew.shareId : brew.shareId; + return currentBrew.googleId && !currentBrew.stubbed ? currentBrew.googleId + currentBrew.shareId : currentBrew.shareId; }; const renderEditLink = ()=>{ - if(!brew.editId) return null; + if(!currentBrew.editId) return null; - const editLink = brew.googleId && ! brew.stubbed ? brew.googleId + brew.editId : brew.editId; + const editLink = currentBrew.googleId && ! currentBrew.stubbed ? currentBrew.googleId + currentBrew.editId : currentBrew.editId; return ( @@ -62,7 +76,7 @@ const SharePage = (props)=>{ const titleEl = ( - {brew.title} + {currentBrew.title} ); @@ -71,11 +85,11 @@ const SharePage = (props)=>{ - {disableMeta ? titleEl : {titleEl}} + {disableMeta ? titleEl : {titleEl}} - {brew.shareId && ( + {currentBrew.shareId && ( <> @@ -108,18 +122,18 @@ const SharePage = (props)=>{ )} - +
{ @@ -182,6 +184,27 @@ export default async function createApp(vite) { } }); + // Create Event Stream source for pages to listen to + app.get('/stream', (req, res)=>{ + res.writeHead(200, { + 'Content-Type' : 'text/event-stream', + 'Cache-Control' : 'no-cache', + 'Connection' : 'keep-alive', + 'Content-Encoding' : 'none' + }); + + Stream.on('sendUpdate', (event, data)=>{ + console.log('Event:', event, '\nData:', data); + res.write(`data: ${JSON.stringify({ ...data, eventType: event })}\n\n`); + }); + }); + + // After Stream starts, send initStream event + setTimeout(()=>{ + Stream.emit('sendUpdate', 'initStream', { time: new Date }); + }, 1000); + + // Local only if(isLocalEnvironment){ // Login diff --git a/server/eventStreamSource.js b/server/eventStreamSource.js new file mode 100644 index 000000000..66fdb3354 --- /dev/null +++ b/server/eventStreamSource.js @@ -0,0 +1,9 @@ +import { EventEmitter } from 'events'; + +const Stream = new EventEmitter; + +export default { + emit : function(event) {return Stream.emit(event, ...([...arguments].slice(1)));}, // Arguments doesn't work for arrow functions + on : (event, listener)=>{return Stream.on(event, listener);}, + off : (event, listener)=>{return Stream.off(event, listener);} +}; \ No newline at end of file diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 7c39c8812..210b7f71a 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -21,6 +21,8 @@ const router = express.Router(); import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js'; import Themes from '../themes/themes.json' with { type: 'json' }; +import Stream from './eventStreamSource.js'; + const isStaticTheme = (renderer, themeName)=>{ return Themes[renderer]?.[themeName] !== undefined; }; @@ -168,8 +170,7 @@ const api = { const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType) .catch((googleError)=>{ - const reason = googleError.errors?.[0].reason; - if(reason == 'notFound') + if(googleError.code === 404 || googleError.status === 404) throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username }; else throw { ...googleError, HBErrorCode: '01' }; @@ -501,6 +502,8 @@ const api = { saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth + Stream.emit('sendUpdate', 'brewUpdated', { time: new Date, shareId: brew.shareId, version: brew.version }); + res.status(200).send(saved); }, deleteGoogleBrew : async (account, id, editId, res)=>{ diff --git a/shared/helpers.js b/shared/helpers.js index db046b810..d20687843 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -229,5 +229,6 @@ export { printCurrentBrew, fetchThemeBundle, brewSnippetsToJSON, - debugTextMismatch + debugTextMismatch, + yamlSnippetsToText }; diff --git a/tests/html/helpers.test.js b/tests/html/helpers.test.js new file mode 100644 index 000000000..a81a62173 --- /dev/null +++ b/tests/html/helpers.test.js @@ -0,0 +1,114 @@ +import { + fetchThemeBundle, + brewSnippetsToJSON, + debugTextMismatch, + yamlSnippetsToText, +} from '../../shared/helpers.js'; + +import dedent from 'dedent'; + +// Marked.js adds line returns after closing tags on some default tokens. +// This removes those line returns for comparison sake. +String.prototype.trimReturns = function(){ + return this.replace(/\r?\n|\r/g, ''); +}; + +const emoji = 'df_d12_2'; + +const brewSnippetsThemeTest = [ + { + name : 'Test Theme', + snippets : dedent ` + \snippet First Theme Snippet + I am the first theme snippet! + + \snippet Second Theme Snippet + I am the second theme Snippet!`, + } +]; + +const brewSnippetsBrewTest = dedent` + \snippet First Brew Snippet + I am the first brew snippet! + + \snippet Second Brew Snippet + I am the second brew Snippet!`; + +describe(`brewSnippetsToJSON`, ()=>{ + it('converts raw brew snippets without theme snippets to JSON', function() { + const testMenuObject = { + groupName : 'Brew Snippets', + icon : 'fas fa-th-list', + view : 'text', + snippets : [{ + name : 'Test Snippets JSON without theme snippets', + subsnippets : [ + { + gen : 'I am the first brew snippet!\n', + name : 'First Brew Snippet' + }, { + gen : 'I am the second brew Snippet!', + name: 'Second Brew Snippet' + } + ]}] + }; + const rendered = brewSnippetsToJSON(`Test Snippets JSON without theme snippets`, brewSnippetsBrewTest, null, true); + expect(rendered).toStrictEqual(testMenuObject); + }); + + it('converts raw brew snippets with theme snippets to JSON', function() { + const testMenuObject = { + groupName : 'Brew Snippets', + icon : 'fas fa-th-list', + view : 'text', + snippets : [{ + gen : '', + icon : '', + name : 'Test Theme', + subsnippets : [ + { + gen : 'I am the first theme snippet!\n', + icon : '', + name : 'First Theme Snippet', + }, + { + gen : 'I am the second theme Snippet!', + icon : '', + name : 'Second Theme Snippet', + }, + ]}, + { + name : 'Test Snippets JSON with theme snippets', + subsnippets : [ + { + gen : 'I am the first brew snippet!\n', + name : 'First Brew Snippet' + }, + { + gen : 'I am the second brew Snippet!', + name: 'Second Brew Snippet' + } + ] + }]}; + const rendered = brewSnippetsToJSON(`Test Snippets JSON with theme snippets`, brewSnippetsBrewTest, brewSnippetsThemeTest, true); + expect(rendered).toStrictEqual(testMenuObject); + }); +}); + +describe(`YAMLSnippetsToText`, ()=>{ + it('converts brew snippet YAML to a string ', function() { + const brewSnippetsYAML = [{ + subsnippets : [ + { + gen : 'I am the first brew snippet!\n', + name : 'First Brew Snippet' + }, { + gen : 'I am the second brew Snippet!', + name: 'Second Brew Snippet' + } + ] + }]; + const rendered = yamlSnippetsToText(brewSnippetsYAML); + expect(rendered).toBe(`${brewSnippetsBrewTest}\n`); + }); +}); \ No newline at end of file