amp-bind data injection (#6840)

This commit is contained in:
Tim Neutkens 2019-04-02 16:09:34 +02:00 committed by GitHub
parent 84fbd4b594
commit b1fdffec75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 385 additions and 31 deletions

View file

@ -62,6 +62,7 @@
"@babel/preset-flow": "7.0.0", "@babel/preset-flow": "7.0.0",
"@babel/preset-react": "7.0.0", "@babel/preset-react": "7.0.0",
"@mdx-js/loader": "0.18.0", "@mdx-js/loader": "0.18.0",
"@types/string-hash": "1.1.1",
"@zeit/next-css": "1.0.2-canary.2", "@zeit/next-css": "1.0.2-canary.2",
"@zeit/next-sass": "1.0.2-canary.2", "@zeit/next-sass": "1.0.2-canary.2",
"@zeit/next-typescript": "1.1.2-canary.0", "@zeit/next-typescript": "1.1.2-canary.0",

View file

@ -0,0 +1,3 @@
import * as React from 'react'
export const DataManagerContext: React.Context<any> = React.createContext(null)

View file

@ -0,0 +1,21 @@
export class DataManager {
data: Map<string, any>
constructor(data?: any) {
this.data = new Map(data)
}
getData() {
return this.data
}
get(key: string) {
return this.data.get(key)
}
set(key: string, value: any) {
this.data.set(key, value)
}
overwrite(data: any) {
this.data = new Map(data)
}
}

View file

@ -0,0 +1,3 @@
import * as React from 'react'
export const RouterContext: React.Context<any> = React.createContext(null)

View file

@ -27,6 +27,7 @@ const defaultConfig = {
(Number(process.env.CIRCLE_NODE_TOTAL) || (Number(process.env.CIRCLE_NODE_TOTAL) ||
(os.cpus() || { length: 1 }).length) - 1 (os.cpus() || { length: 1 }).length) - 1
), ),
ampBindInitData: false,
exportTrailingSlash: true, exportTrailingSlash: true,
profiling: false, profiling: false,
sharedRuntime: false sharedRuntime: false

View file

@ -28,6 +28,8 @@ type ServerConstructor = {
conf?: NextConfig, conf?: NextConfig,
} }
const ENDING_IN_JSON_REGEX = /\.json$/
export default class Server { export default class Server {
dir: string dir: string
quiet: boolean quiet: boolean
@ -36,6 +38,7 @@ export default class Server {
buildId: string buildId: string
renderOpts: { renderOpts: {
ampEnabled: boolean ampEnabled: boolean
ampBindInitData: boolean
staticMarkup: boolean staticMarkup: boolean
buildId: string buildId: string
generateEtags: boolean generateEtags: boolean
@ -74,6 +77,7 @@ export default class Server {
this.buildId = this.readBuildId() this.buildId = this.readBuildId()
this.renderOpts = { this.renderOpts = {
ampEnabled: this.nextConfig.experimental.amp, ampEnabled: this.nextConfig.experimental.amp,
ampBindInitData: this.nextConfig.experimental.ampBindInitData,
staticMarkup, staticMarkup,
buildId: this.buildId, buildId: this.buildId,
generateEtags, generateEtags,
@ -260,12 +264,19 @@ export default class Server {
return this.handleRequest(req, res, parsedUrl) return this.handleRequest(req, res, parsedUrl)
} }
const isDataRequest = ENDING_IN_JSON_REGEX.test(pathname)
if (isDataRequest) {
pathname = pathname.replace(ENDING_IN_JSON_REGEX, '')
}
if (isBlockedPage(pathname)) { if (isBlockedPage(pathname)) {
return this.render404(req, res, parsedUrl) return this.render404(req, res, parsedUrl)
} }
const html = await this.renderToHTML(req, res, pathname, query, { const html = await this.renderToHTML(req, res, pathname, query, {
amphtml: query.amp && this.nextConfig.experimental.amp, amphtml: query.amp && this.nextConfig.experimental.amp,
dataOnly: isDataRequest,
}) })
// Request was ended by the user // Request was ended by the user
if (html === null) { if (html === null) {
@ -291,9 +302,10 @@ export default class Server {
res: ServerResponse, res: ServerResponse,
pathname: string, pathname: string,
query: ParsedUrlQuery = {}, query: ParsedUrlQuery = {},
{ amphtml, hasAmp }: { { amphtml, dataOnly, hasAmp }: {
amphtml?: boolean, amphtml?: boolean,
hasAmp?: boolean, hasAmp?: boolean,
dataOnly?: boolean,
} = {}, } = {},
): Promise<string | null> { ): Promise<string | null> {
try { try {
@ -303,7 +315,7 @@ export default class Server {
res, res,
pathname, pathname,
query, query,
{ ...this.renderOpts, amphtml, hasAmp }, { ...this.renderOpts, amphtml, hasAmp, dataOnly },
) )
return html return html
} catch (err) { } catch (err) {

View file

@ -7,7 +7,11 @@ import mitt, {MittEmitter} from '../lib/mitt';
import { loadGetInitialProps, isResSent } from '../lib/utils' import { loadGetInitialProps, isResSent } from '../lib/utils'
import Head, { defaultHead } from '../lib/head' import Head, { defaultHead } from '../lib/head'
import Loadable from '../lib/loadable' import Loadable from '../lib/loadable'
import { DataManagerContext } from '../lib/data-manager-context'
import {LoadableContext} from '../lib/loadable-context' import {LoadableContext} from '../lib/loadable-context'
import { RouterContext } from '../lib/router-context'
import { DataManager } from '..//lib/data-manager'
import { import {
getDynamicImportBundles, getDynamicImportBundles,
Manifest as ReactLoadableManifest, Manifest as ReactLoadableManifest,
@ -107,6 +111,7 @@ function render(
type RenderOpts = { type RenderOpts = {
ampEnabled: boolean ampEnabled: boolean
ampBindInitData: boolean
staticMarkup: boolean staticMarkup: boolean
buildId: string buildId: string
runtimeConfig?: { [key: string]: any } runtimeConfig?: { [key: string]: any }
@ -116,7 +121,8 @@ type RenderOpts = {
dev?: boolean dev?: boolean
ampPath?: string ampPath?: string
amphtml?: boolean amphtml?: boolean
hasAmp?: boolean hasAmp?: boolean,
dataOnly?: boolean,
buildManifest: BuildManifest buildManifest: BuildManifest
reactLoadableManifest: ReactLoadableManifest reactLoadableManifest: ReactLoadableManifest
Component: React.ComponentType Component: React.ComponentType
@ -128,6 +134,7 @@ type RenderOpts = {
function renderDocument( function renderDocument(
Document: React.ComponentType, Document: React.ComponentType,
{ {
dataManagerData,
ampEnabled = false, ampEnabled = false,
props, props,
docProps, docProps,
@ -148,6 +155,7 @@ function renderDocument(
files, files,
dynamicImports, dynamicImports,
}: RenderOpts & { }: RenderOpts & {
dataManagerData: any,
props: any props: any
docProps: any docProps: any
pathname: string pathname: string
@ -167,6 +175,7 @@ function renderDocument(
<IsAmpContext.Provider value={amphtml}> <IsAmpContext.Provider value={amphtml}>
<Document <Document
__NEXT_DATA__={{ __NEXT_DATA__={{
dataManager: dataManagerData,
props, // The result of getInitialProps props, // The result of getInitialProps
page: pathname, // The rendered page page: pathname, // The rendered page
query, // querystring parsed / passed by the user query, // querystring parsed / passed by the user
@ -175,7 +184,7 @@ function renderDocument(
runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML
nextExport, // If this is a page exported by `next export` nextExport, // If this is a page exported by `next export`
dynamicIds: dynamicIds:
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds, dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
}} }}
ampEnabled={ampEnabled} ampEnabled={ampEnabled}
@ -205,6 +214,7 @@ export async function renderToHTML(
const { const {
err, err,
dev = false, dev = false,
ampBindInitData = false,
staticMarkup = false, staticMarkup = false,
amphtml = false, amphtml = false,
hasAmp = false, hasAmp = false,
@ -264,15 +274,79 @@ export async function renderToHTML(
...getPageFiles(buildManifest, '/_app'), ...getPageFiles(buildManifest, '/_app'),
]), ]),
] ]
let dataManager: DataManager | undefined
if (ampBindInitData) {
dataManager = new DataManager()
}
const reactLoadableModules: string[] = [] const reactLoadableModules: string[] = []
const renderPage = ( const renderElementToString = staticMarkup
? renderToStaticMarkup
: renderToString
let renderPage: (options: ComponentsEnhancer) => { html: string, head: any } | Promise<{ html: string; head: any }>
if (ampBindInitData) {
renderPage = async (
options: ComponentsEnhancer = {},
): Promise<{ html: string; head: any }> => {
if (ctx.err && ErrorDebug) {
return render(renderElementToString, <ErrorDebug error={ctx.err} />)
}
if (dev && (props.router || props.Component)) {
throw new Error(
`'router' and 'Component' can not be returned in getInitialProps from _app.js https://err.sh/zeit/next.js/cant-override-next-props.md`,
)
}
const {
App: EnhancedApp,
Component: EnhancedComponent,
} = enhanceComponents(options, App, Component)
let recursionCount = 0
const renderTree = async (): Promise<any> => {
recursionCount++
// This is temporary, we can remove it once the API is finished.
if (recursionCount > 100) {
throw new Error('Did 100 promise recursions, bailing out to avoid infinite loop.')
}
try {
return await render(
renderElementToString,
<RouterContext.Provider value={router}>
<DataManagerContext.Provider value={dataManager}>
<IsAmpContext.Provider value={amphtml}>
<LoadableContext.Provider
value={(moduleName) => reactLoadableModules.push(moduleName)}
>
<EnhancedApp
Component={EnhancedComponent}
router={router}
{...props}
/>
</LoadableContext.Provider>
</IsAmpContext.Provider>
</DataManagerContext.Provider>
</RouterContext.Provider>,
)
} catch (err) {
if (typeof err.then !== 'undefined') {
await err
return await renderTree()
}
throw err
}
}
const res = await renderTree()
return res
}
} else {
renderPage = (
options: ComponentsEnhancer = {}, options: ComponentsEnhancer = {},
): { html: string; head: any } => { ): { html: string; head: any } => {
const renderElementToString = staticMarkup
? renderToStaticMarkup
: renderToString
if (ctx.err && ErrorDebug) { if (ctx.err && ErrorDebug) {
return render(renderElementToString, <ErrorDebug error={ctx.err} />) return render(renderElementToString, <ErrorDebug error={ctx.err} />)
} }
@ -290,19 +364,22 @@ export async function renderToHTML(
return render( return render(
renderElementToString, renderElementToString,
<IsAmpContext.Provider value={amphtml}> <RouterContext.Provider value={router}>
<LoadableContext.Provider <IsAmpContext.Provider value={amphtml}>
value={(moduleName) => reactLoadableModules.push(moduleName)} <LoadableContext.Provider
> value={(moduleName) => reactLoadableModules.push(moduleName)}
<EnhancedApp >
Component={EnhancedComponent} <EnhancedApp
router={router} Component={EnhancedComponent}
{...props} router={router}
/> {...props}
</LoadableContext.Provider> />
</IsAmpContext.Provider>, </LoadableContext.Provider>
</IsAmpContext.Provider>
</RouterContext.Provider>,
) )
} }
}
const docProps = await loadGetInitialProps(Document, { ...ctx, renderPage }) const docProps = await loadGetInitialProps(Document, { ...ctx, renderPage })
// the response might be finished on the getInitialProps call // the response might be finished on the getInitialProps call
@ -313,8 +390,18 @@ export async function renderToHTML(
] ]
const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id) const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id)
let dataManagerData = '[]'
if (dataManager) {
dataManagerData = JSON.stringify([...dataManager.getData()])
}
if (renderOpts.dataOnly) {
return dataManagerData
}
return renderDocument(Document, { return renderDocument(Document, {
...renderOpts, ...renderOpts,
dataManagerData,
props, props,
docProps, docProps,
pathname, pathname,

View file

@ -0,0 +1,51 @@
import {PluginObj} from '@babel/core'
import {NodePath} from '@babel/traverse'
import * as BabelTypes from '@babel/types'
export default function ({ types: t }: {types: typeof BabelTypes}): PluginObj {
return {
visitor: {
ImportDeclaration (path: NodePath<BabelTypes.ImportDeclaration>, state) {
const source = path.node.source.value
if (source !== 'next/data') return
const createHookSpecifier = path.get('specifiers').find(specifier => {
return specifier.isImportSpecifier() && specifier.node.imported.name === 'createHook'
})
if (!createHookSpecifier) return
const bindingName = createHookSpecifier.node.local.name
const binding = path.scope.getBinding(bindingName)
if (!binding) {
return
}
binding.referencePaths.forEach(refPath => {
let callExpression = refPath.parentPath
if (!callExpression.isCallExpression()) return
let args: any = callExpression.get('arguments')
if (!args[0]) {
throw callExpression.buildCodeFrameError('first argument to createHook should be a function')
}
if (!args[1]) {
callExpression.node.arguments.push(t.objectExpression([]))
}
args = callExpression.get('arguments')
args[1].node.properties.push(t.objectProperty(
t.identifier('key'),
t.stringLiteral(state.opts.key)
))
})
}
}
}
}

View file

@ -225,8 +225,14 @@ export default function getBaseWebpackConfig (dir: string, {dev = false, isServe
...nodePathList // Support for NODE_PATH environment variable ...nodePathList // Support for NODE_PATH environment variable
] ]
}, },
// @ts-ignore this is filtered
module: { module: {
rules: [ rules: [
config.experimental.ampBindInitData && !isServer && {
test: /\.(js|mjs|jsx)$/,
include: [path.join(dir, 'data')],
use: 'next-data-loader'
},
{ {
test: /\.(js|mjs|jsx)$/, test: /\.(js|mjs|jsx)$/,
include: [dir, /next-server[\\/]dist[\\/]lib/], include: [dir, /next-server[\\/]dist[\\/]lib/],

View file

@ -1,4 +1,6 @@
import babelLoader from 'babel-loader' import babelLoader from 'babel-loader'
import hash from 'string-hash'
import { basename } from 'path'
module.exports = babelLoader.custom(babel => { module.exports = babelLoader.custom(babel => {
const presetItem = babel.createConfigItem(require('../../babel/preset'), { type: 'preset' }) const presetItem = babel.createConfigItem(require('../../babel/preset'), { type: 'preset' })
@ -21,6 +23,7 @@ module.exports = babelLoader.custom(babel => {
return { loader, custom } return { loader, custom }
}, },
config (cfg, { source, customOptions: { isServer } }) { config (cfg, { source, customOptions: { isServer } }) {
const filename = this.resourcePath
const options = Object.assign({}, cfg.options) const options = Object.assign({}, cfg.options)
if (cfg.hasFilesystemConfig()) { if (cfg.hasFilesystemConfig()) {
for (const file of [cfg.babelrc, cfg.config]) { for (const file of [cfg.babelrc, cfg.config]) {
@ -36,6 +39,12 @@ module.exports = babelLoader.custom(babel => {
options.presets = [...options.presets, presetItem] options.presets = [...options.presets, presetItem]
} }
if (isServer && source.indexOf('next/data') !== -1) {
const nextDataPlugin = babel.createConfigItem([require('../../babel/plugins/next-data'), { key: basename(filename) + '-' + hash(filename) }], { type: 'plugin' })
options.plugins = options.plugins || []
options.plugins.push(nextDataPlugin)
}
// If the file has `module.exports` we have to transpile commonjs because Babel adds `import` statements // If the file has `module.exports` we have to transpile commonjs because Babel adds `import` statements
// That break webpack, since webpack doesn't support combining commonjs and esmodules // That break webpack, since webpack doesn't support combining commonjs and esmodules
if (source.indexOf('module.exports') !== -1) { if (source.indexOf('module.exports') !== -1) {

View file

@ -0,0 +1,13 @@
import {loader} from 'webpack'
import hash from 'string-hash'
import {basename} from 'path'
const nextDataLoader: loader.Loader = function (source) {
const filename = this.resourcePath
return `
import {createHook} from 'next/data'
export default createHook(undefined, {key: ${JSON.stringify(basename(filename) + '-' + hash(filename))}})
`
}
export default nextDataLoader

View file

@ -1,7 +1,7 @@
import React from 'react' import React, { Suspense } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import HeadManager from './head-manager' import HeadManager from './head-manager'
import { createRouter } from 'next/router' import { createRouter, makePublicRouterInstance } from 'next/router'
import mitt from 'next-server/dist/lib/mitt' import mitt from 'next-server/dist/lib/mitt'
import { loadGetInitialProps, getURL } from 'next-server/dist/lib/utils' import { loadGetInitialProps, getURL } from 'next-server/dist/lib/utils'
import PageLoader from './page-loader' import PageLoader from './page-loader'
@ -9,6 +9,9 @@ import * as envConfig from 'next-server/config'
import { ErrorBoundary } from './error-boundary' import { ErrorBoundary } from './error-boundary'
import Loadable from 'next-server/dist/lib/loadable' import Loadable from 'next-server/dist/lib/loadable'
import { HeadManagerContext } from 'next-server/dist/lib/head-manager-context' import { HeadManagerContext } from 'next-server/dist/lib/head-manager-context'
import { DataManagerContext } from 'next-server/dist/lib/data-manager-context'
import { RouterContext } from 'next-server/dist/lib/router-context'
import { DataManager } from 'next-server/dist/lib/data-manager'
// Polyfill Promise globally // Polyfill Promise globally
// This is needed because Webpack's dynamic loading(common chunks) code // This is needed because Webpack's dynamic loading(common chunks) code
@ -33,6 +36,9 @@ const {
dynamicIds dynamicIds
} = data } = data
const d = JSON.parse(window.__NEXT_DATA__.dataManager)
export const dataManager = new DataManager(d)
const prefix = assetPrefix || '' const prefix = assetPrefix || ''
// With dynamic assetPrefix it's no longer possible to set assetPrefix at the build time // With dynamic assetPrefix it's no longer possible to set assetPrefix at the build time
@ -183,17 +189,25 @@ async function doRender ({ App, Component, props, err }) {
// In development runtime errors are caught by react-error-overlay. // In development runtime errors are caught by react-error-overlay.
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
renderReactElement(( renderReactElement((
<HeadManagerContext.Provider value={headManager.updateHead}> <Suspense fallback={<div>Loading...</div>}>
<App {...appProps} /> <RouterContext.Provider value={makePublicRouterInstance(router)}>
</HeadManagerContext.Provider> <DataManagerContext.Provider value={dataManager}>
<HeadManagerContext.Provider value={headManager.updateHead}>
<App {...appProps} />
</HeadManagerContext.Provider>
</DataManagerContext.Provider>
</RouterContext.Provider>
</Suspense>
), appContainer) ), appContainer)
} else { } else {
// In production we catch runtime errors using componentDidCatch which will trigger renderError. // In production we catch runtime errors using componentDidCatch which will trigger renderError.
renderReactElement(( renderReactElement((
<ErrorBoundary fn={(error) => renderError({ App, err: error }).catch(err => console.error('Error rendering page: ', err))}> <ErrorBoundary fn={(error) => renderError({ App, err: error }).catch(err => console.error('Error rendering page: ', err))}>
<HeadManagerContext.Provider value={headManager.updateHead}> <Suspense fallback={<div>Loading...</div>}>
<App {...appProps} /> <HeadManagerContext.Provider value={headManager.updateHead}>
</HeadManagerContext.Provider> <App {...appProps} />
</HeadManagerContext.Provider>
</Suspense>
</ErrorBoundary> </ErrorBoundary>
), appContainer) ), appContainer)
} }

1
packages/next/data.js Normal file
View file

@ -0,0 +1 @@
module.exports = require('./dist/lib/data')

34
packages/next/lib/data.ts Normal file
View file

@ -0,0 +1,34 @@
import {useContext} from 'react'
import { DataManagerContext } from 'next-server/dist/lib/data-manager-context'
import { RouterContext } from 'next-server/dist/lib/router-context'
import fetch from 'unfetch'
export function createHook(fetcher: () => Promise<any>, options: {key: string}) {
if (!options.key) {
throw new Error('key not provided to createHook options.')
}
return function useData() {
const router: import('next-server/lib/router/router').default = useContext(RouterContext)
const dataManager: import('next-server/lib/data-manager').DataManager = useContext(DataManagerContext)
const existing = dataManager.get(options.key)
if (existing && existing.status === 'resolved') {
return existing.result
}
// @ts-ignore webpack optimization
if (process.browser) {
const res = fetch(router.route === '/' ? 'index.json' : router.route + '.json').then((res: any) => res.json()).then((result: any) => {
dataManager.overwrite(result)
})
throw res
} else {
const res = fetcher().then((result) => {
dataManager.set(options.key, {
status: 'resolved',
result,
})
})
throw res
}
}
}

View file

@ -74,6 +74,7 @@
"recursive-copy": "2.0.6", "recursive-copy": "2.0.6",
"serialize-javascript": "1.6.1", "serialize-javascript": "1.6.1",
"source-map": "0.6.1", "source-map": "0.6.1",
"string-hash": "1.1.3",
"strip-ansi": "3.0.1", "strip-ansi": "3.0.1",
"styled-jsx": "3.2.1", "styled-jsx": "3.2.1",
"terser": "3.16.1", "terser": "3.16.1",

View file

@ -15,8 +15,8 @@ export default class Document extends Component {
_devOnlyInvalidateCacheQueryString: PropTypes.string, _devOnlyInvalidateCacheQueryString: PropTypes.string,
} }
static getInitialProps({ renderPage }) { static async getInitialProps({ renderPage }) {
const { html, head } = renderPage() const { html, head } = await renderPage()
const styles = flush() const styles = flush()
return { html, head, styles } return { html, head, styles }
} }

View file

@ -2,6 +2,17 @@ declare module '@babel/plugin-transform-modules-commonjs';
declare module 'next-server/next-config'; declare module 'next-server/next-config';
declare module 'next-server/constants'; declare module 'next-server/constants';
declare module 'webpack/lib/GraphHelpers'; declare module 'webpack/lib/GraphHelpers';
declare module 'unfetch'
declare module 'next-server/dist/lib/data-manager-context' {
import * as all from 'next-server/lib/data-manager-context'
export = all
}
declare module 'next-server/dist/lib/router-context' {
import * as all from 'next-server/lib/router-context'
export = all
}
declare module 'next/dist/compiled/nanoid/index.js' { declare module 'next/dist/compiled/nanoid/index.js' {

View file

@ -0,0 +1,6 @@
import { createHook } from 'next/data'
import os from 'os'
export default createHook(async () => {
return os.uptime()
})

View file

@ -0,0 +1,5 @@
module.exports = {
experimental: {
ampBindInitData: true
}
}

View file

@ -0,0 +1,7 @@
import Link from 'next/link'
export default () => {
return <Link href='/'>
<a>home</a>
</Link>
}

View file

@ -0,0 +1,6 @@
import getUptime from '../data/get-uptime'
export default function Index () {
const uptime = getUptime()
return <h1>The uptime of the server is {uptime}</h1>
}

View file

@ -0,0 +1,52 @@
/* eslint-env jest */
/* global jasmine */
import { join } from 'path'
import webdriver from 'next-webdriver'
import {
killApp,
findPort,
launchApp,
renderViaHTTP
} from 'next-test-utils'
const appDir = join(__dirname, '../')
let appPort
let server
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
describe('AMP Bind Initial Data', () => {
beforeAll(async () => {
appPort = await findPort()
server = await launchApp(appDir, appPort)
})
afterAll(() => killApp(server))
it('responds with json with .json extension on page', async () => {
const data = await renderViaHTTP(appPort, '/index.json')
let isJSON = false
try {
JSON.parse(data)
isJSON = true
} catch (_) {}
expect(isJSON).toBe(true)
})
it('renders the data during SSR', async () => {
const html = await renderViaHTTP(appPort, '/')
expect(html).toMatch(/The uptime of the server is.*?\d.*?\d/)
})
it('renders a page without data', async () => {
const html = await renderViaHTTP(appPort, '/about')
expect(html).toMatch(/<a.*?home/)
})
it('navigates to a page with data correctly', async () => {
const browser = await webdriver(appPort, '/about')
await browser.elementByCss('a').click()
await browser.waitForElementByCss('h1')
const h1Text = await browser.elementByCss('h1').text()
expect(h1Text).toMatch(/The uptime of the server is.*?\d.*?\d/)
await browser.close()
})
})

View file

@ -1679,6 +1679,11 @@
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==
"@types/string-hash@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/string-hash/-/string-hash-1.1.1.tgz#4c336e61d1e13ce2d3efaaa5910005fd080e106b"
integrity sha512-ijt3zdHi2DmZxQpQTmozXszzDo78V4R3EdvX0jFMfnMH2ZzQSmCbaWOMPGXFUYSzSIdStv78HDjg32m5dxc+tA==
"@types/strip-ansi@3.0.0": "@types/strip-ansi@3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-ansi/-/strip-ansi-3.0.0.tgz#9b63d453a6b54aa849182207711a08be8eea48ae" resolved "https://registry.yarnpkg.com/@types/strip-ansi/-/strip-ansi-3.0.0.tgz#9b63d453a6b54aa849182207711a08be8eea48ae"
@ -8560,7 +8565,12 @@ mute-stream@~0.0.4:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
nan@^2.10.0, nan@^2.9.2: nan@^2.10.0:
version "2.13.2"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
nan@^2.9.2:
version "2.12.1" version "2.12.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552" resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552"
integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw== integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==