diff --git a/.eslintrc b/.eslintrc index 530f9cde1..437612212 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,10 +7,6 @@ extends: - eslint-config-shakacode - prettier -globals: - __DEBUG_SERVER_ERRORS__: true - __SERVER_ERRORS__: true - parserOptions: # We have @babel/eslint-parser from eslint-config-shakacode, but don't use Babel in the main project requireConfigFile: false @@ -89,3 +85,28 @@ overrides: - error - patterns: - "../*" + - files: "k6/**/*.{js,ts}" + globals: + # See https://github.com/grafana/k6-docs/blob/65d9f8a9a53f57a4f416763f8020d0c7f40eb976/docs/sources/.eslintrc.js + __ENV: readonly + __VU: readonly + __ITER: readonly + console: readonly + open: readonly + window: readonly + setInterval: readonly + clearInterval: readonly + setTimeout: readonly + clearTimeout: readonly + rules: + "import/extensions": + - error + - ignorePackages + "import/no-unresolved": + - error + # k6 and k6/... have to be installed globally and can't be resolved by ESLint + - { ignore: ["k6.*"] } + no-use-before-define: + - error + # We want to allow function.name in options, and place them at the top of the file + - { functions: false } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a24acfd6d..d7d0876f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -293,12 +293,40 @@ If you run `rspec` at the top level, you'll see this message: `require': cannot After running a test, you can view the coverage results in SimpleCov reports by opening `coverage/index.html`. +### Benchmarking +You'll need to [install `k6`](https://grafana.com/docs/k6/latest/set-up/install-k6/) first and start the dummy app in production mode (using [bin/prod](https://github.com/shakacode/react_on_rails_pro/blob/master/spec/dummy/bin/prod)). +You can remove even more overhead by using +```sh +bin/prod &> /dev/null +``` + +The benchmarking scripts are in `k6` directory, so you can run, for example: +```sh +k6 run k6/root.js +``` +to exercise the main page of the dummy app. + +For browser tests like `k6/streaming.js`, it's recommended to include `K6_BROWSER_LOG=fatal` for actual measurement after verifying the tests work. +It avoids unnecessary overhead from outputting console messages. + +For significant changes, please make sure to run all benchmarks before and after, and include the results in the PR. +Later they will be added to CI, if we can make sure the results there are stable enough. + +If you add significant new functionality and add a page in the dummy app showing it, consider adding the corresponding benchmark as well. + +#### Debugging the benchmark scripts +You can add `-e DEBUG_K6=true` to run only a single iteration while showing HTTP requests and responses. See +[How to debug k6 load testing scripts](https://github.com/grafana/k6-learn/blob/main/Modules/III-k6-Intermediate/01-How-to-debug-k6-load-testing-scripts.md) for more suggestions if needed. + ### Debugging Start the sample app like this for some debug printing: ```sh -TRACE_REACT_ON_RAILS=true && foreman start -f Procfile.dev +TRACE_REACT_ON_RAILS=true overmind start -f Procfile.dev ``` +Using `overmind` instead of `foreman` lets you restart separate processes, connect with them when stopped with a `debugger` call, and so on. +See https://railsnotes.xyz/blog/overmind-better-bin-dev-for-your-procfile-dev for more details. +If you don't need that, you can use `bin/dev` or `foreman` as well. # Releasing Contact Justin Gordon, justin@shakacode.com diff --git a/k6/lib/util.js b/k6/lib/util.js new file mode 100644 index 000000000..1ab08f146 --- /dev/null +++ b/k6/lib/util.js @@ -0,0 +1,56 @@ +export const url = (path) => `${__ENV.BASE_URL ?? 'http://localhost:3000'}/${path}`; + +/** @type {(envVar: string) => boolean} */ +const envToBoolean = (envVar) => { + const value = __ENV[envVar]; + return !!value && ['true', '1', 'yes'].includes(value.toLowerCase()); +}; + +/** + * @param {boolean} [inScenario=isBrowser] Is this used as `scenarios: { : defaultOptions(...) }`? + * @param {boolean} [isBrowser=false] Is this a browser test? + * @param {boolean} [isDebug=env.DEBUG_K6] Are we running in debug mode? + * @param {import('k6/options').Options} [otherOptions] Other options to merge in + * @return {import('k6/options').Options} + * */ +export const defaultOptions = ({ + isBrowser = false, + isDebug = envToBoolean('DEBUG_K6'), + // Browser tests options can only be set inside `scenarios` + // https://grafana.com/docs/k6/latest/using-k6-browser/ + inScenario = isBrowser, + ...otherOptions +} = {}) => { + const thresholds = { + checks: ['rate>0.90'], + http_req_failed: ['rate<0.05'], + }; + const baseOptions = isDebug + ? { + vus: 1, + iterations: 1, + httpDebug: inScenario ? undefined : 'full', + thresholds, + ...otherOptions, + } + : { + vus: 10, + duration: '30s', + thresholds, + ...otherOptions, + }; + if (inScenario) { + // See https://github.com/grafana/k6-learn/blob/main/Modules/III-k6-Intermediate/08-Setting-load-profiles-with-executors.md + baseOptions.executor = isDebug ? 'shared-iterations' : 'constant-vus'; + } + return isBrowser + ? { + ...baseOptions, + options: { + browser: { + type: 'chromium', + }, + }, + } + : baseOptions; +}; diff --git a/k6/root.js b/k6/root.js new file mode 100644 index 000000000..674913eab --- /dev/null +++ b/k6/root.js @@ -0,0 +1,24 @@ +import { check } from 'k6'; +import http from 'k6/http'; +import { defaultOptions, url } from './lib/util.js'; + +export const options = defaultOptions(); + +export default () => { + const rootUrl = url(''); + check(http.get(rootUrl), { + 'status was 200': (res) => res.status === 200, + 'renders components successfully': (res) => { + const body = res.html().text(); + return Object.entries({ + // This is visible on the page in the browser 4 times, but for some reason, the one under + // "Server Rendered React Component Without Redux" is missing in `body`. + 'Hello, Mr. Server Side Rendering!': 3, + 'Hello, Mrs. Client Side Rendering!': 2, + 'Hello, Mrs. Client Side Hello Again!': 1, + 'Hello ES5, Mrs. Client Side Rendering!': 1, + 'Hello WORLD! Will this work?? YES! Time to visit Maui': 1, + }).every(([text, count]) => body.split(text).length >= count + 1); + }, + }); +}; diff --git a/k6/streaming.js b/k6/streaming.js new file mode 100644 index 000000000..1b6448fec --- /dev/null +++ b/k6/streaming.js @@ -0,0 +1,34 @@ +import { check } from 'k6'; +import { browser } from 'k6/browser'; +import { defaultOptions, url } from './lib/util.js'; + +const streamingUrl = url('stream_async_components?delay=5'); + +export const options = { + scenarios: { + browser: defaultOptions({ isBrowser: true }), + }, +}; + +export default async () => { + const page = await browser.newPage(); + try { + const response = await page.goto(streamingUrl); + check(response, { + 'status was 200': (res) => res.status() === 200, + }); + await page.waitForFunction(() => !document.body.textContent.includes('Loading'), { + // in milliseconds + timeout: 5000, + }); + check(await page.locator('html').textContent(), { + 'has all comments': (text) => { + // can't define commentIds as a constant outside, this runs in browser context + const commentIds = [1, 2, 3, 4]; + return commentIds.every((id) => text.includes(`Comment ${id}`)); + }, + }); + } finally { + await page.close(); + } +}; diff --git a/package.json b/package.json index 54641cf43..1959deb3d 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@tsconfig/node14": "^14.1.2", "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.12", + "@types/k6": "^0.54.2", "@types/lockfile": "^1.0.4", "@types/touch": "^3.1.5", "babel-jest": "^29.7.0", diff --git a/spec/dummy/app/views/pages/stream_async_components.html.erb b/spec/dummy/app/views/pages/stream_async_components.html.erb index 5d0de9f46..56258ac71 100644 --- a/spec/dummy/app/views/pages/stream_async_components.html.erb +++ b/spec/dummy/app/views/pages/stream_async_components.html.erb @@ -1,4 +1,9 @@ -<%= stream_react_component("StreamAsyncComponents", props: @app_props_server_render, prerender: true, trace: true, id: "StreamAsyncComponents-react-component-0") %> +<%= + props = @app_props_server_render + delay = params[:delay] + props = props.merge(baseDelayMs: delay.to_i) unless delay.nil? + stream_react_component("StreamAsyncComponents", props: props, prerender: true, trace: true, id: "StreamAsyncComponents-react-component-0") +%>

React Rails Server Streaming Server Rendered Async React Components

diff --git a/spec/dummy/bin/prod b/spec/dummy/bin/prod new file mode 100755 index 000000000..3c844bf0f --- /dev/null +++ b/spec/dummy/bin/prod @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +RAILS_ENV=production NODE_ENV=production overmind start -f Procfile.static diff --git a/spec/dummy/client/app/ror-auto-load-components/StreamAsyncComponents.jsx b/spec/dummy/client/app/ror-auto-load-components/StreamAsyncComponents.jsx index a39b87431..4bcdfe2b2 100644 --- a/spec/dummy/client/app/ror-auto-load-components/StreamAsyncComponents.jsx +++ b/spec/dummy/client/app/ror-auto-load-components/StreamAsyncComponents.jsx @@ -5,11 +5,11 @@ const delayPromise = (promise, ms) => new Promise((resolve) => setTimeout(() => const cachedFetches = {}; -const AsyncPost = async () => { +const AsyncPost = async ({ delayMs }) => { console.log('Hello from AsyncPost'); const post = (cachedFetches['post'] ??= await delayPromise( fetch('https://jsonplaceholder.org/posts/1'), - 2000, + delayMs, ).then((response) => response.json())); // Uncomment to test handling of errors occuring outside of the shell @@ -26,10 +26,10 @@ const AsyncPost = async () => { ); }; -const AsyncComment = async ({ commentId }) => { +const AsyncComment = async ({ commentId, delayMs }) => { const comment = (cachedFetches[commentId] ??= await delayPromise( fetch(`https://jsonplaceholder.org/comments/${commentId}`), - 2000 + commentId * 1000, + delayMs, ).then((response) => response.json())); console.log('Hello from AsyncComment', commentId); return ( @@ -40,8 +40,8 @@ const AsyncComment = async ({ commentId }) => { ); }; -function StreamAsyncComponents(props) { - const [name, setName] = useState(props.helloWorldData.name); +function StreamAsyncComponents({ helloWorldData, baseDelayMs = 1000 }) { + const [name, setName] = useState(helloWorldData.name); // Uncomment to test error handling during rendering the shell // throw new Error('Hello from StreamAsyncComponents'); @@ -57,13 +57,13 @@ function StreamAsyncComponents(props) {

Loading...}> - +

Comments Fetched Asynchronously on Server

{[1, 2, 3, 4].map((commentId) => ( Loading Comment {commentId}...}> - + ))} diff --git a/yarn.lock b/yarn.lock index d3cb39e1e..a55246a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1789,6 +1789,11 @@ dependencies: "@types/node" "*" +"@types/k6@^0.54.2": + version "0.54.2" + resolved "https://registry.yarnpkg.com/@types/k6/-/k6-0.54.2.tgz#944d6e20881d0fed3123742654ec8a12e175ea49" + integrity sha512-B5LPxeQm97JnUTpoKNE1UX9jFp+JiJCAXgZOa2P7aChxVoPQXKfWMzK+739xHq3lPkKj1aV+HeOxkP56g/oWBg== + "@types/lockfile@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/lockfile/-/lockfile-1.0.4.tgz#9d6a6d1b6dbd4853cecc7f334bc53ea0ff363b8e"