diff --git a/.gitignore b/.gitignore index 91191804f..83c085bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -112,9 +112,6 @@ storybook-static # Lighthouse .lighthouseci -#VSCode -.vscode - # Ansible deploy/ansible-vault-password.txt @@ -124,4 +121,4 @@ deploy/provision.retry # Migration generated files server/migrations/pg_dump_test_plan_target.sql -server/migrations/test_plan_target_id.csv \ No newline at end of file +server/migrations/test_plan_target_id.csv diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..8ce0f7999 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + "version": "1.0.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest Server Debug Current Test File", + //"env": { "NODE_ENV": "test" }, + "envFile": "${workspaceFolder}/config/test.env", + "program": "${workspaceFolder}/server/node_modules/.bin/jest", + "args": ["${fileBasenameNoExtension}", "--config", "server/jest.config.js"], + "console": "integratedTerminal", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/server/node_modules/jest/bin/jest" + } + } + ] +} diff --git a/deploy/README.md b/deploy/README.md index 5acdb36ad..71dd0d5d3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -58,12 +58,14 @@ To deploy this project to server: - Run `ssh aria-at-app-sandbox.bocoup.com` and confirm you can connect. - Confirm that `sudo su` successfully switches you to the root user. You will need to enter the sodoer password you chose during your Bocoup onboarding. This password will be required when deploying to the Sandbox. 3. Obtain a copy of the `ansible-vault-password.txt` file in LastPass and place it in the directory which contains this document. -4. Install [Ansible](https://www.ansible.com/) version 2.8. Instructions for macOS are as follows: - - Install Python 2.7, which is not included by default on recent macOS versions. - - Verify that Pip, Python's package manager, is using Python 2.7 by running `pip --version`. - - Install Ansible at the specific 2.8 version: `pip install ansible==2.8.20` - - Run `ansible --version` to verify your ansible is on version 2.8. - - You may need to run `ansible-galaxy collection install ansible.posix --ignore-certs` as well. +4. Install [Ansible](https://www.ansible.com/) version 2.11. Instructions for macOS are as follows: + - Install Ansible at the specific 2.11 version: `python3 -m pip install --user ansible-core==2.11.1` + - Add the following line to your `~/.zshrc` file, changing the path below to match where Python installs Ansible for you: + ``` + export PATH=$PATH:/Users/Luigi/Library/Python/3.9/bin + ``` + - Run `source ~/.zshrc` to refresh your shell. + - Run `ansible --version` to verify your ansible is on version 2.11. 5. Execute the following command from the deploy directory: - Sandbox: ``` diff --git a/server/app.js b/server/app.js index c6f7f60e3..719a74662 100644 --- a/server/app.js +++ b/server/app.js @@ -2,19 +2,12 @@ const express = require('express'); const bodyParser = require('body-parser'); const cacheMiddleware = require('apicache').middleware; const proxyMiddleware = require('rawgit/lib/middleware'); -const { ApolloServer } = require('apollo-server-express'); -const { - ApolloServerPluginLandingPageGraphQLPlayground -} = require('apollo-server-core'); const { session } = require('./middleware/session'); const embedApp = require('./apps/embed'); const authRoutes = require('./routes/auth'); const testRoutes = require('./routes/tests'); const path = require('path'); -const graphqlSchema = require('./graphql-schema'); -const getGraphQLContext = require('./graphql-context'); -const resolvers = require('./resolvers'); - +const apolloServer = require('./graphql-server'); const app = express(); // test session @@ -23,15 +16,8 @@ app.use(bodyParser.json()); app.use('/auth', authRoutes); app.use('/test', testRoutes); -const server = new ApolloServer({ - typeDefs: graphqlSchema, - context: getGraphQLContext, - resolvers, - // The newer IDE does not work because of CORS issues - plugins: [ApolloServerPluginLandingPageGraphQLPlayground()] -}); -server.start().then(() => { - server.applyMiddleware({ app }); +apolloServer.start().then(() => { + apolloServer.applyMiddleware({ app }); }); const listener = express(); diff --git a/server/apps/embed.js b/server/apps/embed.js index 5226a83b7..7275a30ab 100644 --- a/server/apps/embed.js +++ b/server/apps/embed.js @@ -1,41 +1,39 @@ const express = require('express'); const { resolve } = require('path'); const { create } = require('express-handlebars'); -const { - ApolloClient, - gql, - HttpLink, - InMemoryCache -} = require('@apollo/client'); -const fetch = require('cross-fetch'); +const { gql } = require('apollo-server-core'); +const apolloServer = require('../graphql-server'); +const staleWhileRevalidate = require('../util/staleWhileRevalidate'); +const hash = require('object-hash'); const app = express(); const handlebarsPath = - process.env.ENVIRONMENT === 'dev' ? 'handlebars' : 'server/handlebars'; + process.env.ENVIRONMENT === 'dev' || process.env.ENVIRONMENT === 'test' + ? 'handlebars' + : 'server/handlebars'; // handlebars const hbs = create({ - layoutsDir: resolve(`${handlebarsPath}/views/layouts`), + layoutsDir: resolve(handlebarsPath, 'views/layouts'), extname: 'hbs', defaultLayout: 'index', - helpers: require(resolve(`${handlebarsPath}/helpers`)) + helpers: require(resolve(handlebarsPath, 'helpers')) }); app.engine('hbs', hbs.engine); app.set('view engine', 'hbs'); -app.set('views', resolve(`${handlebarsPath}/views`)); - -if (process.env.ENVIRONMENT !== 'dev') { - app.enable('view cache'); -} - -const client = new ApolloClient({ - link: new HttpLink({ uri: 'http://localhost:8000/api/graphql', fetch }), - cache: new InMemoryCache() -}); - -const getLatestReportsForPattern = async pattern => { - const { data } = await client.query({ +app.set('views', resolve(handlebarsPath, 'views')); + +// Prevent refreshing cached data for five seconds - using a short time like +// this is possible because the stale-while-revalidate caching strategy works in +// the background and doesn't spin up more than one simultaneous request. +// +// If queries are very slow, anyone trying to get the refreshed data will get +// stale data for however long it takes for the query to complete. +const millisecondsUntilStale = 5000; + +const queryReports = async () => { + const { data, errors } = await apolloServer.executeOperation({ query: gql` query { testPlanReports(statuses: [CANDIDATE, RECOMMENDED]) { @@ -68,9 +66,26 @@ const getLatestReportsForPattern = async pattern => { ` }); + if (errors) { + throw new Error(errors); + } + + const reportsHashed = hash(data.testPlanReports); + + return { allTestPlanReports: data.testPlanReports, reportsHashed }; +}; + +// As of now, a full query for the complete list of reports is needed to build +// the embed for a single pattern. This caching allows that query to be reused +// between pattern embeds. +const queryReportsCached = staleWhileRevalidate(queryReports, { + millisecondsUntilStale +}); + +const getLatestReportsForPattern = ({ allTestPlanReports, pattern }) => { let title; - const testPlanReports = data.testPlanReports.filter(report => { + const testPlanReports = allTestPlanReports.filter(report => { if (report.testPlanVersion.testPlan.id === pattern) { title = report.testPlanVersion.title; return true; @@ -80,7 +95,6 @@ const getLatestReportsForPattern = async pattern => { let allAts = new Set(); let allBrowsers = new Set(); let allAtVersionsByAt = {}; - let status = 'RECOMMENDED'; let reportsByAt = {}; let testPlanVersionIds = new Set(); const uniqueReports = []; @@ -89,9 +103,6 @@ const getLatestReportsForPattern = async pattern => { testPlanReports.forEach(report => { allAts.add(report.at.name); allBrowsers.add(report.browser.name); - if (report.status === 'CANDIDATE') { - status = report.status; - } if (!allAtVersionsByAt[report.at.name]) allAtVersionsByAt[report.at.name] = @@ -155,6 +166,11 @@ const getLatestReportsForPattern = async pattern => { .sort((a, b) => a.browser.name.localeCompare(b.browser.name)); }); + const hasAnyCandidateReports = Object.values(reportsByAt).find(atReports => + atReports.find(report => report.status === 'CANDIDATE') + ); + let status = hasAnyCandidateReports ? 'CANDIDATE' : 'RECOMMENDED'; + return { title, allBrowsers, @@ -165,16 +181,13 @@ const getLatestReportsForPattern = async pattern => { }; }; -app.get('/reports/:pattern', async (req, res) => { - // In the instance where an editor doesn't want to display a certain title - // as it has defined when importing into the ARIA-AT database for being too - // verbose, etc. eg. `Link Example 1 (span element with text content)` - // Usage: https://aria-at.w3.org/embed/reports/command-button?title=Link+Example+(span+element+with+text+content) - const queryTitle = req.query.title; - const pattern = req.params.pattern; - const protocol = /dev|vagrant/.test(process.env.ENVIRONMENT) - ? 'http://' - : 'https://'; +const renderEmbed = ({ + allTestPlanReports, + queryTitle, + pattern, + protocol, + host +}) => { const { title, allBrowsers, @@ -182,8 +195,8 @@ app.get('/reports/:pattern', async (req, res) => { testPlanVersionIds, status, reportsByAt - } = await getLatestReportsForPattern(pattern); - res.render('main', { + } = getLatestReportsForPattern({ pattern, allTestPlanReports }); + return hbs.renderView(resolve(handlebarsPath, 'views/main.hbs'), { layout: 'index', dataEmpty: Object.keys(reportsByAt).length === 0, title: queryTitle || title || 'Pattern Not Found', @@ -192,11 +205,44 @@ app.get('/reports/:pattern', async (req, res) => { allBrowsers, allAtVersionsByAt, reportsByAt, - completeReportLink: `${protocol}${ - req.headers.host - }/report/${testPlanVersionIds.join(',')}`, - embedLink: `${protocol}${req.headers.host}/embed/reports/${pattern}` + completeReportLink: `${protocol}${host}/report/${testPlanVersionIds.join( + ',' + )}`, + embedLink: `${protocol}${host}/embed/reports/${pattern}` + }); +}; + +// Limit the number of times the template is rendered +const renderEmbedCached = staleWhileRevalidate(renderEmbed, { + getCacheKeyFromArguments: ({ reportsHashed, pattern }) => + reportsHashed + pattern, + millisecondsUntilStale +}); + +app.get('/reports/:pattern', async (req, res) => { + // In the instance where an editor doesn't want to display a certain title + // as it has defined when importing into the ARIA-AT database for being too + // verbose, etc. eg. `Link Example 1 (span element with text content)` + // Usage: https://aria-at.w3.org/embed/reports/command-button?title=Link+Example+(span+element+with+text+content) + const queryTitle = req.query.title; + const pattern = req.params.pattern; + const host = req.headers.host; + const protocol = /dev|vagrant/.test(process.env.ENVIRONMENT) + ? 'http://' + : 'https://'; + const { allTestPlanReports, reportsHashed } = await queryReportsCached(); + const embedRendered = await renderEmbedCached({ + allTestPlanReports, + reportsHashed, + queryTitle, + pattern, + protocol, + host }); + + // Disable browser-based caching which could potentially make the embed + // contents appear stale even after being refreshed + res.set('cache-control', 'must-revalidate').send(embedRendered); }); app.use(express.static(resolve(`${handlebarsPath}/public`))); diff --git a/server/graphql-context.js b/server/graphql-context.js index a78f6bfc3..b12969db2 100644 --- a/server/graphql-context.js +++ b/server/graphql-context.js @@ -1,5 +1,6 @@ const getGraphQLContext = ({ req }) => { - const user = req.session && req.session.user ? req.session.user : null; + const user = + req && req.session && req.session.user ? req.session.user : null; return { user }; }; diff --git a/server/graphql-server.js b/server/graphql-server.js new file mode 100644 index 000000000..f7e87a398 --- /dev/null +++ b/server/graphql-server.js @@ -0,0 +1,17 @@ +const { ApolloServer } = require('apollo-server-express'); +const { + ApolloServerPluginLandingPageGraphQLPlayground +} = require('apollo-server-core'); +const graphqlSchema = require('./graphql-schema'); +const getGraphQLContext = require('./graphql-context'); +const resolvers = require('./resolvers'); + +const apolloServer = new ApolloServer({ + typeDefs: graphqlSchema, + context: getGraphQLContext, + resolvers, + // The newer IDE does not work because of CORS issues + plugins: [ApolloServerPluginLandingPageGraphQLPlayground()] +}); + +module.exports = apolloServer; diff --git a/server/handlebars/public/script.js b/server/handlebars/public/script.js index 5ab466b1e..e73f604c2 100644 --- a/server/handlebars/public/script.js +++ b/server/handlebars/public/script.js @@ -1,12 +1,12 @@ -const iframeClass = `support-levels-${document.currentScript.getAttribute( - 'pattern' -)}`; +const iframeClass = `support-levels-${document + .querySelector('script[pattern]') + .getAttribute('pattern')}`; const iframeCode = link => ``; diff --git a/server/handlebars/public/style.css b/server/handlebars/public/style.css index dca8f2dce..113468f2d 100644 --- a/server/handlebars/public/style.css +++ b/server/handlebars/public/style.css @@ -71,8 +71,12 @@ h3#report-title { display: inline-block; } -#candidate-title.recommended h3 { - width: 130px; +#candidate-title.recommended { + border: 1.5px solid #7ac498; + background-color: #e9fbe9; +} +#candidate-title.recommended > span { + background-color: #115b11; } #candidate-content-container { @@ -90,7 +94,7 @@ h3#report-title { margin-bottom: 3px; } -.none { +.no-data-cell { display: block; color: #72777f; font-style: italic; @@ -187,16 +191,19 @@ table tbody tr td { background-color: #f4f4f4; } -#view-report-button, #embed-button { +#view-report-button, +#embed-button { margin-bottom: 1em; padding: 0 12px; line-height: 36px; } -#view-report-button:focus-visible, #embed-button:focus-visible { +#view-report-button:focus-visible, +#embed-button:focus-visible { outline-offset: 2px; outline: 2px solid #3a86d1; } -#view-report-button svg, #embed-button svg { +#view-report-button svg, +#embed-button svg { width: 24px; margin-right: 8px; float: left; diff --git a/server/handlebars/views/layouts/index.hbs b/server/handlebars/views/layouts/index.hbs index 62c2b08fa..03f471d6a 100644 --- a/server/handlebars/views/layouts/index.hbs +++ b/server/handlebars/views/layouts/index.hbs @@ -2,7 +2,7 @@ - + ARIA-AT Report @@ -10,7 +10,5 @@ {{{body}}} - - \ No newline at end of file + diff --git a/server/handlebars/views/main.hbs b/server/handlebars/views/main.hbs index e2b8292ea..50794f025 100644 --- a/server/handlebars/views/main.hbs +++ b/server/handlebars/views/main.hbs @@ -1,6 +1,5 @@
{{#if dataEmpty}} -

{{title}}

There is no data for this pattern. @@ -13,7 +12,7 @@ Warning! Unapproved Report
The information in this report is generated from candidate tests developed and run by the ARIA-AT Project. - ARIA-AT tests are in review by assistive technology developers and lack consensus regarding: + Candidate ARIA-AT tests are in review by assistive technology developers and lack consensus regarding:
  1. applicability and validity of the tests, and
  2. accuracy of test results.
  3. @@ -21,18 +20,17 @@
{{else}} -
- +
+ Recommended Report
The information in this report is generated from recommended tests. - Recommended ARIA-AT tests have been reviewed by assistive technology - developers and represent consensus regarding + Recommended ARIA-AT tests have been reviewed by assistive technology developers and represent consensus regarding:
  1. applicability and validity of the tests, and
  2. accuracy of test results.
-
+ {{/if}}
@@ -55,13 +53,13 @@ - {{this.metrics.supportPercent}}% supported + {{this.metrics.supportPercent}}% supported {{/if}} {{else}} {{#if (isInAllBrowsers "Chrome" @../../this) }} {{#unless (elementExists @../../this @../this this.at.name "Chrome" @last)}} - + {{/unless}} {{/if}} {{/if}} @@ -71,13 +69,13 @@ - {{this.metrics.supportPercent}}% supported + {{this.metrics.supportPercent}}% supported {{/if}} {{else}} {{#if (isInAllBrowsers "Firefox" @../../this) }} {{#unless (elementExists @../../this @../this this.at.name "Firefox" @last)}} - + {{/unless}} {{/if}} {{/if}} @@ -87,13 +85,13 @@ - {{this.metrics.supportPercent}}% supported + {{this.metrics.supportPercent}}% supported {{/if}} {{else}} {{#if (isInAllBrowsers "Safari" @../../this) }} {{#unless (elementExists @../../this @../this this.at.name "Safari" @last)}} - + {{/unless}} {{/if}} {{/if}} diff --git a/server/package.json b/server/package.json index e3cf6f451..608b6c119 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,6 @@ }, "homepage": "https://github.com/bocoup/aria-at-app#readme", "dependencies": { - "@apollo/client": "^3.7.2", "@moebius/http-graceful-shutdown": "^1.1.0", "apicache": "^1.6.3", "apollo-server": "^3.4.0", @@ -58,6 +57,7 @@ "vhost": "^3.0.2" }, "devDependencies": { + "@testing-library/dom": "^9.0.1", "eslint": "^8.31.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-jest": "^27.2.1", @@ -65,6 +65,8 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.11", "jest": "^29.3.1", + "jsdom": "^21.1.1", + "jsdom-global": "^3.0.2", "moxios": "^0.4.0", "node-mocks-http": "^1.8.1", "prettier": "^2.8.4", diff --git a/server/tests/integration/embed.test.js b/server/tests/integration/embed.test.js new file mode 100644 index 000000000..2ef687f54 --- /dev/null +++ b/server/tests/integration/embed.test.js @@ -0,0 +1,94 @@ +const embedApp = require('../../apps/embed'); +const startSupertestServer = require('../util/api-server'); +const db = require('../../models/index'); +const applyJsdomGlobals = require('jsdom-global'); +// Must be called before requiring the testing-library +applyJsdomGlobals(); +const { screen } = require('@testing-library/dom'); + +let apiServer; +let sessionAgent; + +beforeAll(async () => { + apiServer = await startSupertestServer({ + graphql: false, + pathToRoutes: [['/embed', embedApp]] + }); + sessionAgent = apiServer.sessionAgent; +}); + +afterAll(async () => { + await apiServer.tearDown(); + // Closing the DB connection allows Jest to exit successfully. + await db.sequelize.close(); +}); + +describe('embed', () => { + it('renders support table with data', async () => { + // Load the iframe, twice, one with a normal load and a second time from + // the cache + const initialLoadTimeStart = Number(new Date()); + const res = await sessionAgent.get('/embed/reports/modal-dialog'); + const initialLoadTimeEnd = Number(new Date()); + const initialLoadTime = initialLoadTimeEnd - initialLoadTimeStart; + + const cachedTimeStart = Number(new Date()); + const res2 = await sessionAgent.get('/embed/reports/modal-dialog'); + const cachedTimeEnd = Number(new Date()); + const cachedTime = cachedTimeEnd - cachedTimeStart; + + // Enables the "screen" API of @testing-library/dom + document.body.innerHTML = res.text; + + const nonWarning = screen.queryByText('Recommended Report'); + const warning = screen.queryByText('Warning! Unapproved Report'); + const nonWarningContents = screen.queryByText( + 'The information in this report is generated from candidate tests', + { exact: false } + ); + const warningContents = screen.queryByText( + 'The information in this report is generated from recommended tests', + { exact: false } + ); + const viewReportButton = screen.getByText('View Complete Report'); + const viewReportButtonOnClick = + viewReportButton.getAttribute('onclick'); + const copyEmbedButton = screen.getByText('Copy Embed Code'); + const copyEmbedButtonOnClick = copyEmbedButton.getAttribute('onclick'); + const table = document.querySelector('table'); + const cellWithData = Array.from(table.querySelectorAll('td')).find(td => + // TODO: change check to \d+ instead of \d* as soon as the missing + // number issue is fixed + td.innerHTML.match(/\s*\d*%\s*<\/b>\s*supported/) + ); + + expect(res.text).toEqual(res2.text); + // Caching should speed up the load time by more than 10x + expect(initialLoadTime / 10).toBeGreaterThan(cachedTime); + expect(nonWarning || warning).toBeTruthy(); + expect(nonWarningContents || warningContents).toBeTruthy(); + expect(viewReportButton).toBeTruthy(); + expect(viewReportButtonOnClick).toMatch( + // Onclick should be like the following: + // window.open('https://127.0.0.1:59112/report/26', '_blank') + /window\.open\('https?:\/\/[\w.:]+\/report\/\d+', '_blank'\)/ + ); + expect(copyEmbedButton).toBeTruthy(); + expect(copyEmbedButtonOnClick).toMatch( + /announceCopied\('https?:\/\/[\w.:]+\/embed\/reports\/modal-dialog'\)/ + ); + expect(cellWithData).toBeTruthy(); + }); + + it('renders a failure message without a pattern', async () => { + const res = await sessionAgent.get('/embed/reports/polka-dot-button'); + + document.body.innerHTML = res.text; + + const failText = screen.queryByText( + 'There is no data for this pattern.' + ); + + expect(failText).toBeTruthy(); + }); +}); diff --git a/server/util/staleWhileRevalidate.js b/server/util/staleWhileRevalidate.js new file mode 100644 index 000000000..cad812005 --- /dev/null +++ b/server/util/staleWhileRevalidate.js @@ -0,0 +1,106 @@ +const NodeCache = require('node-cache'); + +const cache = new NodeCache({ + checkperiod: 0, // Caches forever until cleared (or server restarts) + useClones: false // Does not make copies of data +}); + +/** + * A good compromise caching solution which is extremely fast because it + * immediately serves stale data, but also avoids aggressive caching which is + * nearly impossible to debug (where you have to wait an hour or longer before + * seeing the latest data appear). + * + * @param {Function} expensiveFunction A function which loads + * expensive-to-calculate data. + * @param {*} options + * @param {Boolean} options.getCacheKeyFromArguments An optional function that + * receives the arguments passed to the expensiveFunction and returns a string + * which will be used to correlate equivalent results. If this key matches the + * key from a previous run, its existing stale data will be returned. If no + * function is provided, a single key will be used for all results. + * @param {Boolean} options.millisecondsUntilStale How many milliseconds before + * data is considered stale. To keep the benefits of stale-while-revalidate + * caching, it's probably a good idea to keep this value low, no more than 30 + * seconds or a minute. Optional and defaults to 0. + * @returns {Function} A version of the expensiveFunction which includes + * stale-while-revalidate caching. + */ +const staleWhileRevalidate = ( + expensiveFunction, + { getCacheKeyFromArguments, millisecondsUntilStale = 0 } = {} +) => { + // Allows the same cache key to be reused for different + // staleWhileRevalidate instances + const randomString = Math.random().toString().substr(2, 10); + + return async (...args) => { + let cacheKey = getCacheKeyFromArguments?.(...args) ?? '' + randomString; + + const { existingData, isLoading, loadingPromise, timestamp } = + cache.get(cacheKey) ?? {}; + + if (existingData) { + // Immediately return the existing data, and then refresh the data + // in the background. Only make one simultaneous query per cacheKey + + const isStale = + Number(new Date()) - timestamp > millisecondsUntilStale; + + if (isLoading || !isStale) { + return existingData; + } + + const newActivePromise = expensiveFunction(...args); + + cache.set(cacheKey, { + existingData, // Keep stale data in place + isLoading: true, + loadingPromise: newActivePromise, + timestamp + }); + + // Occurs in background + newActivePromise.then(data => { + cache.set(cacheKey, { + existingData: data, + isLoading: false, + loadingPromise: null, + timestamp: Number(new Date()) + }); + }); + + return existingData; + } + + if (isLoading) { + // When no data is ready, but a request is already in progress, + // prevent multiple requests for the same data + return loadingPromise; + } + + // Initial load + + const newActivePromise = expensiveFunction(...args); + + cache.set(cacheKey, { + existingData: null, + isLoading: true, + loadingPromise: newActivePromise, + timestamp: null + }); + + const data = await newActivePromise; + + cache.set(cacheKey, { + existingData: data, + isLoading: false, + loadingPromise: null, + timestamp: Number(new Date()) + }); + + return data; + }; +}; + +module.exports = staleWhileRevalidate; diff --git a/server/util/staleWhileRevalidate.test.js b/server/util/staleWhileRevalidate.test.js new file mode 100644 index 000000000..a6f7604cc --- /dev/null +++ b/server/util/staleWhileRevalidate.test.js @@ -0,0 +1,141 @@ +const staleWhileRevalidate = require('./staleWhileRevalidate'); + +describe('staleWhileRevalidate', () => { + const timeToCalculate = 80; + const timeUntilStale = 80; + const buffer = 40; + + const waitMs = async ms => { + await new Promise(resolve => { + setTimeout(resolve, ms); + }); + }; + + const getCounter = () => { + let count = 0; + return async () => { + await waitMs(timeToCalculate); + count += 1; + return `count is ${count}`; + }; + }; + + it('immediately serves data and refreshes in the background', async () => { + const getCount = getCounter(); + + const getCountCached = staleWhileRevalidate(getCount, { + millisecondsUntilStale: timeUntilStale + }); + + const initialCall = await getCountCached(); + + const staleCall = await getCountCached(); + + await waitMs(timeUntilStale + buffer); + + const refreshTriggeringCall = await getCountCached(); + await waitMs(timeToCalculate + buffer); + const refreshShowingCall = await getCountCached(); + + await waitMs(timeUntilStale + buffer); + + const simultaneous1Promise = getCountCached(); + const simultaneous2Promise = getCountCached(); + const [simultaneous1, simultaneous2] = await Promise.all([ + simultaneous1Promise, + simultaneous2Promise + ]); + await waitMs(timeUntilStale + buffer); + const simultaneousAfter = await getCountCached(); + + expect(initialCall).toBe('count is 1'); + expect(staleCall).toBe('count is 1'); + expect(refreshTriggeringCall).toBe('count is 1'); + expect(refreshShowingCall).toBe('count is 2'); + expect(simultaneous1).toBe('count is 2'); + expect(simultaneous2).toBe('count is 2'); + expect(simultaneousAfter).toBe('count is 3'); + }); + + it('only loads once even when there are multiple immediate requests', async () => { + const getCount = getCounter(); + + const getCountCached = staleWhileRevalidate(getCount, { + millisecondsUntilStale: timeUntilStale + }); + + const initial1Promise = getCountCached(); + const initial2Promise = getCountCached(); + const [initial1, initial2] = await Promise.all([ + initial1Promise, + initial2Promise + ]); + + const afterInitial = await getCountCached(); + + expect(initial1).toBe('count is 1'); + expect(initial2).toBe('count is 1'); + expect(afterInitial).toBe('count is 1'); + }); + + it('separates caching for different instances', async () => { + const getCount1 = getCounter(); + const getCountCached1 = staleWhileRevalidate(getCount1, { + millisecondsUntilStale: timeUntilStale + }); + + const getCount2 = getCounter(); + const getCountCached2 = staleWhileRevalidate(getCount2, { + millisecondsUntilStale: timeUntilStale + }); + + await getCountCached1(); + await waitMs(timeUntilStale + buffer); + await getCountCached1(); + await waitMs(timeUntilStale + buffer); + const count1 = await getCountCached1(); + + await getCountCached2(); + await waitMs(timeUntilStale + buffer); + await getCountCached2(); + await waitMs(timeUntilStale + buffer); + const count2 = await getCountCached2(); + + expect(count1).toBe('count is 2'); + expect(count2).toBe('count is 2'); + }); + + it('supports caching based on function arguments', async () => { + const getLetterCounter = () => { + let letterCounts = {}; + return async letter => { + await waitMs(timeToCalculate); + if (letterCounts[letter] === undefined) { + letterCounts[letter] = 0; + } + letterCounts[letter] += 1; + return `${letter} is ${letterCounts[letter]}`; + }; + }; + + const countLetters = getLetterCounter(); + const countLettersCached = staleWhileRevalidate(countLetters, { + getCacheKeyFromArguments: letter => letter, + millisecondsUntilStale: timeUntilStale + }); + + await Promise.all([countLettersCached('A'), countLettersCached('B')]); + await waitMs(timeUntilStale + buffer); + await countLettersCached('A'); + await waitMs(timeToCalculate + timeUntilStale + buffer); + await countLettersCached('A'); + await waitMs(timeToCalculate + timeUntilStale + buffer); + const countA = await countLettersCached('A'); + await countLettersCached('B'); + await waitMs(timeUntilStale + buffer); + const countB = await countLettersCached('B'); + + expect(countA).toBe('A is 3'); + expect(countB).toBe('B is 2'); + }); +}); diff --git a/yarn.lock b/yarn.lock index fec81c0c6..42361033f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,25 +15,6 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@apollo/client@^3.7.2": - version "3.7.3" - resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.7.3.tgz#ab3fe31046e74bc1a3762363a185ba5bcfffc58b" - integrity sha512-nzZ6d6a4flLpm3pZOGpuAUxLlp9heob7QcCkyIqZlCLvciUibgufRfYTwfkWCc4NaGHGSZyodzvfr79H6oUwGQ== - dependencies: - "@graphql-typed-document-node/core" "^3.1.1" - "@wry/context" "^0.7.0" - "@wry/equality" "^0.5.0" - "@wry/trie" "^0.3.0" - graphql-tag "^2.12.6" - hoist-non-react-statics "^3.3.2" - optimism "^0.16.1" - prop-types "^15.7.2" - response-iterator "^0.2.6" - symbol-observable "^4.0.0" - ts-invariant "^0.10.3" - tslib "^2.3.0" - zen-observable-ts "^1.2.5" - "@apollo/client@^3.7.9": version "3.7.9" resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.7.9.tgz#459454dc4a7c81adaa66e13e626ce41f633dc862" @@ -3447,6 +3428,20 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/dom@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.0.1.tgz#fb9e3837fe2a662965df1536988f0863f01dbf51" + integrity sha512-fTOVsMY9QLFCCXRHG3Ese6cMH5qIWwSbgxZsgeF5TNsy81HKaZ4kgehnSF8FsR3OF+numlIV2YcU79MzbnhSig== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^5.16.5": version "5.16.5" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e" @@ -4471,6 +4466,11 @@ acorn@^8.1.0, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0, acorn@^8.8.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== +acorn@^8.8.2: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== + address@^1.0.1: version "1.2.2" resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" @@ -6707,6 +6707,13 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +cssstyle@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" + integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== + dependencies: + rrweb-cssom "^0.6.0" + csstype@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" @@ -6757,6 +6764,15 @@ data-urls@^3.0.2: whatwg-mimetype "^3.0.0" whatwg-url "^11.0.0" +data-urls@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" + integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.0" + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -6790,7 +6806,7 @@ decamelize@^1.1.2, decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -decimal.js@^10.4.2: +decimal.js@^10.4.2, decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== @@ -11043,6 +11059,11 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== +jsdom-global@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/jsdom-global/-/jsdom-global-3.0.2.tgz#6bd299c13b0c4626b2da2c0393cd4385d606acb9" + integrity sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg== + jsdom@^11.5.1: version "11.12.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8" @@ -11107,6 +11128,38 @@ jsdom@^20.0.0: ws "^8.11.0" xml-name-validator "^4.0.0" +jsdom@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.1.tgz#ab796361e3f6c01bcfaeda1fea3c06197ac9d8ae" + integrity sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w== + dependencies: + abab "^2.0.6" + acorn "^8.8.2" + acorn-globals "^7.0.0" + cssstyle "^3.0.0" + data-urls "^4.0.0" + decimal.js "^10.4.3" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.2" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.1" + ws "^8.13.0" + xml-name-validator "^4.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -11581,6 +11634,11 @@ lz-string@^1.4.4: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ== +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -12822,7 +12880,7 @@ parse5@^6.0.0: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -parse5@^7.0.0, parse5@^7.1.1: +parse5@^7.0.0, parse5@^7.1.1, parse5@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== @@ -13530,6 +13588,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +punycode@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" + integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== + puppeteer-core@^13.7.0: version "13.7.0" resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b" @@ -14364,6 +14427,11 @@ router@2.0.0-beta.1: setprototypeof "1.2.0" utils-merge "1.0.1" +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + rst-selector-parser@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" @@ -15787,6 +15855,13 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== + dependencies: + punycode "^2.3.0" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -16744,6 +16819,14 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" +whatwg-url@^12.0.0, whatwg-url@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" + integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== + dependencies: + tr46 "^4.1.1" + webidl-conversions "^7.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -16944,6 +17027,11 @@ ws@^8.11.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.0.tgz#485074cc392689da78e1828a9ff23585e06cddd8" integrity sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig== +ws@^8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" + integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== + ws@^8.2.3, ws@^8.4.2: version "8.11.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
NoneNo DataNoneNo DataNoneNo Data