1. /**
    
  2.  * Copyright (c) Meta Platforms, Inc. and affiliates.
    
  3.  *
    
  4.  * This source code is licensed under the MIT license found in the
    
  5.  * LICENSE file in the root directory of this source tree.
    
  6.  *
    
  7.  * @emails react-core
    
  8.  */
    
  9. 
    
  10. 'use strict';
    
  11. 
    
  12. const stream = require('stream');
    
  13. const shouldIgnoreConsoleError = require('../../../../../scripts/jest/shouldIgnoreConsoleError');
    
  14. 
    
  15. module.exports = function (initModules) {
    
  16.   let ReactDOM;
    
  17.   let ReactDOMServer;
    
  18.   let act;
    
  19. 
    
  20.   function resetModules() {
    
  21.     ({ReactDOM, ReactDOMServer} = initModules());
    
  22.     act = require('internal-test-utils').act;
    
  23.   }
    
  24. 
    
  25.   function shouldUseDocument(reactElement) {
    
  26.     // Used for whole document tests.
    
  27.     return reactElement && reactElement.type === 'html';
    
  28.   }
    
  29. 
    
  30.   function getContainerFromMarkup(reactElement, markup) {
    
  31.     if (shouldUseDocument(reactElement)) {
    
  32.       const doc = document.implementation.createHTMLDocument('');
    
  33.       doc.open();
    
  34.       doc.write(
    
  35.         markup ||
    
  36.           '<!doctype html><html><meta charset=utf-8><title>test doc</title>',
    
  37.       );
    
  38.       doc.close();
    
  39.       return doc;
    
  40.     } else {
    
  41.       const container = document.createElement('div');
    
  42.       container.innerHTML = markup;
    
  43.       return container;
    
  44.     }
    
  45.   }
    
  46. 
    
  47.   // Helper functions for rendering tests
    
  48.   // ====================================
    
  49. 
    
  50.   // promisified version of ReactDOM.render()
    
  51.   async function asyncReactDOMRender(reactElement, domElement, forceHydrate) {
    
  52.     if (forceHydrate) {
    
  53.       await act(() => {
    
  54.         ReactDOM.hydrate(reactElement, domElement);
    
  55.       });
    
  56.     } else {
    
  57.       await act(() => {
    
  58.         ReactDOM.render(reactElement, domElement);
    
  59.       });
    
  60.     }
    
  61.   }
    
  62.   // performs fn asynchronously and expects count errors logged to console.error.
    
  63.   // will fail the test if the count of errors logged is not equal to count.
    
  64.   async function expectErrors(fn, count) {
    
  65.     if (console.error.mockClear) {
    
  66.       console.error.mockClear();
    
  67.     } else {
    
  68.       // TODO: Rewrite tests that use this helper to enumerate expected errors.
    
  69.       // This will enable the helper to use the .toErrorDev() matcher instead of spying.
    
  70.       spyOnDev(console, 'error').mockImplementation(() => {});
    
  71.     }
    
  72. 
    
  73.     const result = await fn();
    
  74.     if (
    
  75.       console.error.mock &&
    
  76.       console.error.mock.calls &&
    
  77.       console.error.mock.calls.length !== 0
    
  78.     ) {
    
  79.       const filteredWarnings = [];
    
  80.       for (let i = 0; i < console.error.mock.calls.length; i++) {
    
  81.         const args = console.error.mock.calls[i];
    
  82.         const [format, ...rest] = args;
    
  83.         if (!shouldIgnoreConsoleError(format, rest)) {
    
  84.           filteredWarnings.push(args);
    
  85.         }
    
  86.       }
    
  87.       if (filteredWarnings.length !== count) {
    
  88.         console.log(
    
  89.           `We expected ${count} warning(s), but saw ${filteredWarnings.length} warning(s).`,
    
  90.         );
    
  91.         if (filteredWarnings.length > 0) {
    
  92.           console.log(`We saw these warnings:`);
    
  93.           for (let i = 0; i < filteredWarnings.length; i++) {
    
  94.             console.log(...filteredWarnings[i]);
    
  95.           }
    
  96.         }
    
  97.         if (__DEV__) {
    
  98.           expect(console.error).toHaveBeenCalledTimes(count);
    
  99.         }
    
  100.       }
    
  101.     }
    
  102.     return result;
    
  103.   }
    
  104. 
    
  105.   // renders the reactElement into domElement, and expects a certain number of errors.
    
  106.   // returns a Promise that resolves when the render is complete.
    
  107.   function renderIntoDom(
    
  108.     reactElement,
    
  109.     domElement,
    
  110.     forceHydrate,
    
  111.     errorCount = 0,
    
  112.   ) {
    
  113.     return expectErrors(async () => {
    
  114.       await asyncReactDOMRender(reactElement, domElement, forceHydrate);
    
  115.       return domElement.firstChild;
    
  116.     }, errorCount);
    
  117.   }
    
  118. 
    
  119.   async function renderIntoString(reactElement, errorCount = 0) {
    
  120.     return await expectErrors(
    
  121.       () =>
    
  122.         new Promise(resolve =>
    
  123.           resolve(ReactDOMServer.renderToString(reactElement)),
    
  124.         ),
    
  125.       errorCount,
    
  126.     );
    
  127.   }
    
  128. 
    
  129.   // Renders text using SSR and then stuffs it into a DOM node; returns the DOM
    
  130.   // element that corresponds with the reactElement.
    
  131.   // Does not render on client or perform client-side revival.
    
  132.   async function serverRender(reactElement, errorCount = 0) {
    
  133.     const markup = await renderIntoString(reactElement, errorCount);
    
  134.     return getContainerFromMarkup(reactElement, markup).firstChild;
    
  135.   }
    
  136. 
    
  137.   // this just drains a readable piped into it to a string, which can be accessed
    
  138.   // via .buffer.
    
  139.   class DrainWritable extends stream.Writable {
    
  140.     constructor(options) {
    
  141.       super(options);
    
  142.       this.buffer = '';
    
  143.     }
    
  144. 
    
  145.     _write(chunk, encoding, cb) {
    
  146.       this.buffer += chunk;
    
  147.       cb();
    
  148.     }
    
  149.   }
    
  150. 
    
  151.   async function renderIntoStream(reactElement, errorCount = 0) {
    
  152.     return await expectErrors(
    
  153.       () =>
    
  154.         new Promise((resolve, reject) => {
    
  155.           const writable = new DrainWritable();
    
  156.           const s = ReactDOMServer.renderToPipeableStream(reactElement, {
    
  157.             onShellError(e) {
    
  158.               reject(e);
    
  159.             },
    
  160.           });
    
  161.           s.pipe(writable);
    
  162.           writable.on('finish', () => resolve(writable.buffer));
    
  163.         }),
    
  164.       errorCount,
    
  165.     );
    
  166.   }
    
  167. 
    
  168.   // Renders text using node stream SSR and then stuffs it into a DOM node;
    
  169.   // returns the DOM element that corresponds with the reactElement.
    
  170.   // Does not render on client or perform client-side revival.
    
  171.   async function streamRender(reactElement, errorCount = 0) {
    
  172.     const markup = await renderIntoStream(reactElement, errorCount);
    
  173.     let firstNode = getContainerFromMarkup(reactElement, markup).firstChild;
    
  174.     if (firstNode && firstNode.nodeType === Node.DOCUMENT_TYPE_NODE) {
    
  175.       // Skip document type nodes.
    
  176.       firstNode = firstNode.nextSibling;
    
  177.     }
    
  178.     return firstNode;
    
  179.   }
    
  180. 
    
  181.   const clientCleanRender = (element, errorCount = 0) => {
    
  182.     if (shouldUseDocument(element)) {
    
  183.       // Documents can't be rendered from scratch.
    
  184.       return clientRenderOnServerString(element, errorCount);
    
  185.     }
    
  186.     const container = document.createElement('div');
    
  187.     return renderIntoDom(element, container, false, errorCount);
    
  188.   };
    
  189. 
    
  190.   const clientRenderOnServerString = async (element, errorCount = 0) => {
    
  191.     const markup = await renderIntoString(element, errorCount);
    
  192.     resetModules();
    
  193. 
    
  194.     const container = getContainerFromMarkup(element, markup);
    
  195.     let serverNode = container.firstChild;
    
  196. 
    
  197.     const firstClientNode = await renderIntoDom(
    
  198.       element,
    
  199.       container,
    
  200.       true,
    
  201.       errorCount,
    
  202.     );
    
  203.     let clientNode = firstClientNode;
    
  204. 
    
  205.     // Make sure all top level nodes match up
    
  206.     while (serverNode || clientNode) {
    
  207.       expect(serverNode != null).toBe(true);
    
  208.       expect(clientNode != null).toBe(true);
    
  209.       expect(clientNode.nodeType).toBe(serverNode.nodeType);
    
  210.       // Assert that the DOM element hasn't been replaced.
    
  211.       // Note that we cannot use expect(serverNode).toBe(clientNode) because
    
  212.       // of jest bug #1772.
    
  213.       expect(serverNode === clientNode).toBe(true);
    
  214.       serverNode = serverNode.nextSibling;
    
  215.       clientNode = clientNode.nextSibling;
    
  216.     }
    
  217.     return firstClientNode;
    
  218.   };
    
  219. 
    
  220.   function BadMarkupExpected() {}
    
  221. 
    
  222.   const clientRenderOnBadMarkup = async (element, errorCount = 0) => {
    
  223.     // First we render the top of bad mark up.
    
  224. 
    
  225.     const container = getContainerFromMarkup(
    
  226.       element,
    
  227.       shouldUseDocument(element)
    
  228.         ? '<html><body><div id="badIdWhichWillCauseMismatch" /></body></html>'
    
  229.         : '<div id="badIdWhichWillCauseMismatch"></div>',
    
  230.     );
    
  231. 
    
  232.     await renderIntoDom(element, container, true, errorCount + 1);
    
  233. 
    
  234.     // This gives us the resulting text content.
    
  235.     const hydratedTextContent =
    
  236.       container.lastChild && container.lastChild.textContent;
    
  237. 
    
  238.     // Next we render the element into a clean DOM node client side.
    
  239.     let cleanContainer;
    
  240.     if (shouldUseDocument(element)) {
    
  241.       // We can't render into a document during a clean render,
    
  242.       // so instead, we'll render the children into the document element.
    
  243.       cleanContainer = getContainerFromMarkup(
    
  244.         element,
    
  245.         '<html></html>',
    
  246.       ).documentElement;
    
  247.       element = element.props.children;
    
  248.     } else {
    
  249.       cleanContainer = document.createElement('div');
    
  250.     }
    
  251.     await asyncReactDOMRender(element, cleanContainer, true);
    
  252.     // This gives us the expected text content.
    
  253.     const cleanTextContent =
    
  254.       (cleanContainer.lastChild && cleanContainer.lastChild.textContent) || '';
    
  255. 
    
  256.     // The only guarantee is that text content has been patched up if needed.
    
  257.     expect(hydratedTextContent).toBe(cleanTextContent);
    
  258. 
    
  259.     // Abort any further expects. All bets are off at this point.
    
  260.     throw new BadMarkupExpected();
    
  261.   };
    
  262. 
    
  263.   // runs a DOM rendering test as four different tests, with four different rendering
    
  264.   // scenarios:
    
  265.   // -- render to string on server
    
  266.   // -- render on client without any server markup "clean client render"
    
  267.   // -- render on client on top of good server-generated string markup
    
  268.   // -- render on client on top of bad server-generated markup
    
  269.   //
    
  270.   // testFn is a test that has one arg, which is a render function. the render
    
  271.   // function takes in a ReactElement and an optional expected error count and
    
  272.   // returns a promise of a DOM Element.
    
  273.   //
    
  274.   // You should only perform tests that examine the DOM of the results of
    
  275.   // render; you should not depend on the interactivity of the returned DOM element,
    
  276.   // as that will not work in the server string scenario.
    
  277.   function itRenders(desc, testFn) {
    
  278.     it(`renders ${desc} with server string render`, () => testFn(serverRender));
    
  279.     it(`renders ${desc} with server stream render`, () => testFn(streamRender));
    
  280.     itClientRenders(desc, testFn);
    
  281.   }
    
  282. 
    
  283.   // run testFn in three different rendering scenarios:
    
  284.   // -- render on client without any server markup "clean client render"
    
  285.   // -- render on client on top of good server-generated string markup
    
  286.   // -- render on client on top of bad server-generated markup
    
  287.   //
    
  288.   // testFn is a test that has one arg, which is a render function. the render
    
  289.   // function takes in a ReactElement and an optional expected error count and
    
  290.   // returns a promise of a DOM Element.
    
  291.   //
    
  292.   // Since all of the renders in this function are on the client, you can test interactivity,
    
  293.   // unlike with itRenders.
    
  294.   function itClientRenders(desc, testFn) {
    
  295.     it(`renders ${desc} with clean client render`, () =>
    
  296.       testFn(clientCleanRender));
    
  297.     it(`renders ${desc} with client render on top of good server markup`, () =>
    
  298.       testFn(clientRenderOnServerString));
    
  299.     it(`renders ${desc} with client render on top of bad server markup`, async () => {
    
  300.       try {
    
  301.         await testFn(clientRenderOnBadMarkup);
    
  302.       } catch (x) {
    
  303.         // We expect this to trigger the BadMarkupExpected rejection.
    
  304.         if (!(x instanceof BadMarkupExpected)) {
    
  305.           // If not, rethrow.
    
  306.           throw x;
    
  307.         }
    
  308.       }
    
  309.     });
    
  310.   }
    
  311. 
    
  312.   function itThrows(desc, testFn, partialMessage) {
    
  313.     it(`throws ${desc}`, () => {
    
  314.       return testFn().then(
    
  315.         () => expect(false).toBe('The promise resolved and should not have.'),
    
  316.         err => {
    
  317.           expect(err).toBeInstanceOf(Error);
    
  318.           expect(err.message).toContain(partialMessage);
    
  319.         },
    
  320.       );
    
  321.     });
    
  322.   }
    
  323. 
    
  324.   function itThrowsWhenRendering(desc, testFn, partialMessage) {
    
  325.     itThrows(
    
  326.       `when rendering ${desc} with server string render`,
    
  327.       () => testFn(serverRender),
    
  328.       partialMessage,
    
  329.     );
    
  330.     itThrows(
    
  331.       `when rendering ${desc} with clean client render`,
    
  332.       () => testFn(clientCleanRender),
    
  333.       partialMessage,
    
  334.     );
    
  335. 
    
  336.     // we subtract one from the warning count here because the throw means that it won't
    
  337.     // get the usual markup mismatch warning.
    
  338.     itThrows(
    
  339.       `when rendering ${desc} with client render on top of bad server markup`,
    
  340.       () =>
    
  341.         testFn((element, warningCount = 0) =>
    
  342.           clientRenderOnBadMarkup(element, warningCount - 1),
    
  343.         ),
    
  344.       partialMessage,
    
  345.     );
    
  346.   }
    
  347. 
    
  348.   // renders serverElement to a string, sticks it into a DOM element, and then
    
  349.   // tries to render clientElement on top of it. shouldMatch is a boolean
    
  350.   // telling whether we should expect the markup to match or not.
    
  351.   async function testMarkupMatch(serverElement, clientElement, shouldMatch) {
    
  352.     const domElement = await serverRender(serverElement);
    
  353.     resetModules();
    
  354.     return renderIntoDom(
    
  355.       clientElement,
    
  356.       domElement.parentNode,
    
  357.       true,
    
  358.       shouldMatch ? 0 : 1,
    
  359.     );
    
  360.   }
    
  361. 
    
  362.   // expects that rendering clientElement on top of a server-rendered
    
  363.   // serverElement does NOT raise a markup mismatch warning.
    
  364.   function expectMarkupMatch(serverElement, clientElement) {
    
  365.     return testMarkupMatch(serverElement, clientElement, true);
    
  366.   }
    
  367. 
    
  368.   // expects that rendering clientElement on top of a server-rendered
    
  369.   // serverElement DOES raise a markup mismatch warning.
    
  370.   function expectMarkupMismatch(serverElement, clientElement) {
    
  371.     return testMarkupMatch(serverElement, clientElement, false);
    
  372.   }
    
  373. 
    
  374.   return {
    
  375.     resetModules,
    
  376.     expectMarkupMismatch,
    
  377.     expectMarkupMatch,
    
  378.     itRenders,
    
  379.     itClientRenders,
    
  380.     itThrowsWhenRendering,
    
  381.     asyncReactDOMRender,
    
  382.     serverRender,
    
  383.     clientCleanRender,
    
  384.     clientRenderOnBadMarkup,
    
  385.     clientRenderOnServerString,
    
  386.     renderIntoDom,
    
  387.     streamRender,
    
  388.   };
    
  389. };