diff --git a/.circleci/config.yml b/.circleci/config.yml index 817aa5569..c38b41685 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ references: container_config_node: &container_config_node working_directory: ~/project/build docker: - - image: circleci/node:12 + - image: circleci/node:12-browsers workspace_root: &workspace_root ~/project @@ -105,6 +105,14 @@ jobs: - run: name: Run storybook command: npm run start-storybook:ci + + e2e-test: + <<: *container_config_node + steps: + - *attach_workspace + - run: + name: Run end to end test + command: npm run e2e publish: <<: *container_config_node @@ -164,6 +172,9 @@ workflows: - test: requires: - build + - e2e-test: + requires: + - build - deploy: filters: <<: *filters_only_main diff --git a/.eslintignore b/.eslintignore index 114afbe73..0fe84d9ab 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,3 +8,4 @@ **/public-prod/** **/blueprints/** web/static/** +/e2e/** \ No newline at end of file diff --git a/.gitignore b/.gitignore index c0be6db50..78ec717b3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ bower_components npm-debug.log .DS_Store dist -.idea \ No newline at end of file +.idea +coverage \ No newline at end of file diff --git a/components/x-interaction/readme.md b/components/x-interaction/readme.md index 9c7f805f2..b7c614c7a 100644 --- a/components/x-interaction/readme.md +++ b/components/x-interaction/readme.md @@ -179,7 +179,7 @@ When rendered on the server side, components output an extra wrapper element, wi `x-interaction` exports a function `hydrate`. This should be called on the client side. It inspects the global serialisation data on the page, uses the identifiers to find the wrapper elements, and calls `render` from your chosen `x-engine` client-side runtime to render component instances into the wrappers. -Before calling `hydrate`, you must first `import` any `x-interaction` components that will be rendered on the page. The components register themselves with the `x-interaction` runtime when imported; you don't need to do anything with the imported component. This will also ensure the component is included in your client-side bundle. +Before calling `hydrate`, you must first `import` any `x-interaction` components that will be rendered on the page. The components register themselves with the `x-interaction` runtime when imported; you don't need to do anything with the imported component. This will also ensure the component is included in your client-side bundle. Similarly if the component that you're server side rendering is just a component that you've created through `withActions`, make sure you import that component along with its registerComponent invokation. Because `hydrate` expects the wrappers to be present in the DOM when called, it should be called after [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded). Depending on your page structure, it might be appropriate to hydrate the component when it's scrolled into view. diff --git a/e2e/app.js b/e2e/app.js new file mode 100644 index 000000000..28e873a04 --- /dev/null +++ b/e2e/app.js @@ -0,0 +1,5 @@ +// set up app to host main.js file included in server side rendered html +const express = require('express') +const server = express() +server.use(express.static(__dirname)) +exports.app = server diff --git a/e2e/common.js b/e2e/common.js new file mode 100644 index 000000000..d2af05a15 --- /dev/null +++ b/e2e/common.js @@ -0,0 +1,21 @@ +const { withActions, registerComponent } = require('@financial-times/x-interaction') +const { h } = require('@financial-times/x-engine') + +export const greetingActions = withActions({ + actionOne() { + return { greeting: 'world' } + } +}) + +export const GreetingComponent = greetingActions(({ greeting, actions }) => { + return ( +
+ hello {greeting} + +
+ ) +}) + +registerComponent(GreetingComponent, 'GreetingComponent') diff --git a/e2e/e2e.test.js b/e2e/e2e.test.js new file mode 100644 index 000000000..c95ac15f3 --- /dev/null +++ b/e2e/e2e.test.js @@ -0,0 +1,62 @@ +/** + * @jest-environment node + */ + +const { h } = require('@financial-times/x-engine') // required for +const { Serialiser, HydrationData } = require('@financial-times/x-interaction') +const puppeteer = require('puppeteer') +const ReactDOMServer = require('react-dom/server') +const express = require('express') +import React from 'react' +import { GreetingComponent } from './common' + +describe('x-interaction-e2e', () => { + let browser + let page + let app + let server + + beforeAll(async () => { + app = express() + server = app.listen(3004) + app.use(express.static(__dirname)) + browser = await puppeteer.launch() + page = await browser.newPage() + }) + + it('attaches the event listener to SSR components on hydration', async () => { + const ClientComponent = () => { + // main.js is the transpiled version of index.js, which contains the registered GreetingComponent, and invokes hydrate + return + } + + const serialiser = new Serialiser() + const htmlString = ReactDOMServer.renderToString( + <> + + + + + ) + + app.get('/', (req, res) => { + res.send(htmlString) + }) + + // go to page and click button + await page.goto('http://localhost:3004') + await page.waitForSelector('.greeting-button') + await page.click('.greeting-button') + const text = await page.$eval('.greeting-text', (e) => e.textContent) + expect(text).toContain('hello world') + }) + + afterAll(async () => { + try { + ;(await browser) && browser.close() + await server.close() + } catch (e) { + console.log(e) + } + }) +}) diff --git a/e2e/index.js b/e2e/index.js new file mode 100644 index 000000000..a4c4f180f --- /dev/null +++ b/e2e/index.js @@ -0,0 +1,4 @@ +import { hydrate } from '@financial-times/x-interaction' +import './common' + +document.addEventListener('DOMContentLoaded', hydrate) diff --git a/e2e/jest.e2e.config.js b/e2e/jest.e2e.config.js new file mode 100644 index 000000000..12b5a78a7 --- /dev/null +++ b/e2e/jest.e2e.config.js @@ -0,0 +1,11 @@ +module.exports = { + testMatch: ['/e2e.test.js'], + testPathIgnorePatterns: ['/node_modules/', '/bower_components/'], + transform: { + '^.+\\.jsx?$': '../packages/x-babel-config/jest' + }, + moduleNameMapper: { + '^[./a-zA-Z0-9$_-]+\\.scss$': '/__mocks__/styleMock.js' + }, + testEnvironment: 'node' +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..d11824a31 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,43 @@ +{ + "name": "x-dash-e2e", + "version": "0.0.0", + "private": "true", + "description": "This module enables you to write x-dash components that respond to events and change their own data.", + "keywords": [ + "x-dash" + ], + "author": "", + "license": "ISC", + "x-dash": { + "engine": { + "server": { + "runtime": "react", + "factory": "createElement", + "component": "Component", + "fragment": "Fragment", + "renderModule": "react-dom/server", + "render": "renderToStaticMarkup" + }, + "browser": "react" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "puppeteer": "^10.4.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "webpack": "^5.54.0", + "webpack-cli": "^4.8.0", + "@financial-times/x-engine": "file:../packages/x-engine", + "@financial-times/x-interaction": "file:../components/x-interaction" + } +} diff --git a/e2e/webpack.config.js b/e2e/webpack.config.js new file mode 100644 index 000000000..3915d2f09 --- /dev/null +++ b/e2e/webpack.config.js @@ -0,0 +1,34 @@ +const path = require('path') +const xEngine = require('../packages/x-engine/src/webpack') +const webpack = require('webpack') + +module.exports = { + entry: './index.js', + output: { + filename: 'main.js', + path: path.resolve(__dirname) + }, + plugins: [ + new webpack.ProvidePlugin({ + React: 'react' + }), + xEngine() + ], + module: { + rules: [ + { + test: /\.(js|jsx)$/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env', '@babel/preset-react'] + } + }, + exclude: /node_modules/ + } + ] + }, + resolve: { + extensions: ['.js', '.jsx', '.json', '.wasm', '.mjs', '*'] + } +} diff --git a/jest.config.js b/jest.config.js index 164aa925f..108438432 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,5 +7,6 @@ module.exports = { }, moduleNameMapper: { '^[./a-zA-Z0-9$_-]+\\.scss$': '/__mocks__/styleMock.js' - } + }, + modulePathIgnorePatterns: ['/e2e/'] } diff --git a/package.json b/package.json index 17d5013c7..8ae3e7a81 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build-only": "athloi run build", "jest": "jest -c jest.config.js", "test": "npm run lint && npm run jest", + "e2e": "cd e2e && ./node_modules/.bin/webpack && jest -c jest.e2e.config.js", "lint": "eslint . --ext=js,jsx", "blueprint": "node private/scripts/blueprint.js", "start-storybook": "start-storybook -p ${STORYBOOK_PORT:-9001} -s .storybook/static -h local.ft.com", @@ -72,6 +73,7 @@ "workspaces": [ "components/*", "packages/*", - "tools/*" + "tools/*", + "e2e" ] }