diff --git a/nyc.config.js b/nyc.config.js index b45710ae8..e70f906e0 100644 --- a/nyc.config.js +++ b/nyc.config.js @@ -3,6 +3,7 @@ module.exports = { all: true, include: [ + 'packages/cli/src/data/*.js', 'packages/cli/src/lib/*.js', 'packages/cli/src/lifecycles/*.js', 'packages/cli/src/plugins/*.js', @@ -20,7 +21,7 @@ module.exports = { checkCoverage: true, statements: 80, - branches: 65, + branches: 70, functions: 85, lines: 80, diff --git a/packages/cli/package.json b/packages/cli/package.json index 84ff5e0c9..297110b0a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,10 @@ "@babel/preset-env": "^7.8.3", "@babel/runtime": "^7.8.3", "@webcomponents/webcomponentsjs": "^2.3.0", + "apollo-cache-inmemory": "^1.6.3", + "apollo-client": "^2.6.4", + "apollo-link-http": "^1.5.16", + "apollo-server": "^2.9.12", "babel-loader": "^8.0.5", "chalk": "^2.4.2", "colors": "^1.3.3", @@ -40,15 +44,19 @@ "css-loader": "^2.1.1", "css-to-string-loader": "^0.1.3", "cssnano": "^4.1.10", + "deepmerge": "^4.2.2", "file-loader": "^3.0.1", "filewatcher-webpack-plugin": "^1.2.0", "front-matter": "^3.0.1", "fs-extra": "^8.1.0", "glob-promise": "^3.4.0", + "graphql": "^14.5.8", + "graphql-tag": "^2.10.1", "html-webpack-plugin": "^3.2.0", "lit-element": "^2.0.1", "lit-redux-router": "^0.9.3", "local-web-server": "^2.6.1", + "node-fetch": "^2.6.0", "postcss-loader": "^3.0.0", "postcss-nested": "^4.1.2", "postcss-preset-env": "^6.7.0", @@ -64,4 +72,4 @@ "webpack-manifest-plugin": "^2.0.4", "webpack-merge": "^4.2.1" } -} +} \ No newline at end of file diff --git a/packages/cli/src/config/webpack.config.common.js b/packages/cli/src/config/webpack.config.common.js index f7194ae06..5c6376210 100644 --- a/packages/cli/src/config/webpack.config.common.js +++ b/packages/cli/src/config/webpack.config.common.js @@ -80,6 +80,15 @@ module.exports = ({ config, context }) => { .map((plugin) => plugin.provider({ config, context })); return { + + resolve: { + extensions: ['.js', '.json', '.gql', '.graphql'], + // TODO - determine alias change (e.g. @greenwood/data) and / or seperate package - #278 + alias: { + '@greenwood/cli/data': path.join(__dirname, '..', './data') + } + }, + entry: { index: path.join(context.scratchDir, 'app', 'app.js') }, @@ -124,6 +133,10 @@ module.exports = ({ config, context }) => { }, { test: /\.(ttf|eot|svg|jpe?g|png|gif|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'file-loader' + }, { + test: /\.(graphql|gql)$/, + exclude: /node_modules/, + loader: 'graphql-tag/loader' }] }, diff --git a/packages/cli/src/data/cache.js b/packages/cli/src/data/cache.js new file mode 100644 index 000000000..2ca487703 --- /dev/null +++ b/packages/cli/src/data/cache.js @@ -0,0 +1,52 @@ +const { ApolloClient } = require('apollo-client'); +const createHttpLink = require('apollo-link-http').createHttpLink; +const crypto = require('crypto'); +const fetch = require('node-fetch'); +const fs = require('fs-extra'); +const { gql } = require('apollo-server'); +const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache; +const path = require('path'); + +/* Extract cache server-side */ +module.exports = async (req, context) => { + + return new Promise(async(resolve, reject) => { + try { + // TODO avoid having to duplicate / replay calls - #272 + const client = await new ApolloClient({ + link: createHttpLink({ + uri: 'http://localhost:4000?q=internal', /* internal flag to prevent looping cache on request */ + fetch + }), + cache: new InMemoryCache() + }); + + /* Take the same query from request, and repeat the query for our server side cache */ + const { query, variables } = req.body; + + let { data } = await client.query({ + query: gql`${query}`, + variables + }); + + if (data) { + const cache = JSON.stringify(client.extract()); + const md5 = crypto.createHash('md5').update(cache).digest('hex'); + + /* Get the requests entire (full) route and rootRoute to use as reference for designated cache directory */ + const { origin, referer } = req.headers; + const fullRoute = referer.substring(origin.length, referer.length); + const rootRoute = fullRoute.substring(0, fullRoute.substring(1, fullRoute.length).indexOf('/') + 1); + const targetDir = path.join(context.publicDir, rootRoute); + const targetFile = path.join(targetDir, `${md5}-cache.json`); + + await fs.mkdirs(targetDir, { recursive: true }); + await fs.writeFile(path.join(targetFile), cache, 'utf8'); + } + resolve(); + } catch (err) { + console.error('create cache error', err); + reject(err); + } + }); +}; \ No newline at end of file diff --git a/packages/cli/src/data/client.js b/packages/cli/src/data/client.js new file mode 100644 index 000000000..7e4106152 --- /dev/null +++ b/packages/cli/src/data/client.js @@ -0,0 +1,32 @@ +import { ApolloClient } from 'apollo-client'; +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { HttpLink } from 'apollo-link-http'; + +const APOLLO_STATE = window.__APOLLO_STATE__; // eslint-disable-line no-underscore-dangle +const client = new ApolloClient({ + cache: new InMemoryCache().restore(APOLLO_STATE), + link: new HttpLink({ + uri: 'http://localhost:4000' + }) +}); +const backupQuery = client.query; + +client.query = (params) => { + + if (APOLLO_STATE) { + // __APOLLO_STATE__ defined, in "SSG" mode... + return fetch('./cache.json') + .then(response => response.json()) + .then((response) => { + // mock client.query response + return { + data: new InMemoryCache().restore(response).readQuery(params) + }; + }); + } else { + // __APOLLO_STATE__ NOT defined, in "SPA" mode + return backupQuery(params); + } +}; + +export default client; \ No newline at end of file diff --git a/packages/cli/src/data/queries/children.gql b/packages/cli/src/data/queries/children.gql new file mode 100644 index 000000000..3f7303b29 --- /dev/null +++ b/packages/cli/src/data/queries/children.gql @@ -0,0 +1,10 @@ +query($parent: String!) { + children(parent: $parent) { + id, + title, + link, + filePath, + fileName, + template + } +} \ No newline at end of file diff --git a/packages/cli/src/data/queries/graph.gql b/packages/cli/src/data/queries/graph.gql new file mode 100644 index 000000000..7aaf1fd02 --- /dev/null +++ b/packages/cli/src/data/queries/graph.gql @@ -0,0 +1,10 @@ +query { + graph { + id, + title, + link, + filePath, + fileName, + template + } +} \ No newline at end of file diff --git a/packages/cli/src/data/queries/hello.gql b/packages/cli/src/data/queries/hello.gql new file mode 100644 index 000000000..389bbe488 --- /dev/null +++ b/packages/cli/src/data/queries/hello.gql @@ -0,0 +1,3 @@ +query { + hello +} \ No newline at end of file diff --git a/packages/cli/src/data/queries/navigation.gql b/packages/cli/src/data/queries/navigation.gql new file mode 100644 index 000000000..8c2321992 --- /dev/null +++ b/packages/cli/src/data/queries/navigation.gql @@ -0,0 +1,6 @@ +query { + navigation{ + label, + link + } +} \ No newline at end of file diff --git a/packages/cli/src/data/schema/graph.js b/packages/cli/src/data/schema/graph.js new file mode 100644 index 000000000..8901fca27 --- /dev/null +++ b/packages/cli/src/data/schema/graph.js @@ -0,0 +1,126 @@ +const { gql } = require('apollo-server-express'); + +const getDeriveMetaFromRoute = (route) => { + // TODO hardcoded root / depth - #273 + const root = route.split('/')[1] || ''; + const label = root + .replace('/', '') + .replace('-', ' ') + .split(' ') + .map((word) => `${word.charAt(0).toUpperCase()}${word.substring(1)}`) + .join(' '); + + return { + label, + root + }; +}; + +const getPagesFromGraph = async (root, query, context) => { + const pages = []; + const { graph } = context; + + graph + .forEach((page) => { + const { route, mdFile, fileName, template } = page; + const id = page.label; + const { label } = getDeriveMetaFromRoute(route); + + pages.push({ + id, + filePath: mdFile, + fileName, + template, + title: label, + link: route + }); + }); + + return pages; +}; + +const getNavigationFromGraph = async (root, query, context) => { + const navigation = {}; + const { graph } = context; + + graph + .forEach((page) => { + const { route } = page; + const { root, label } = getDeriveMetaFromRoute(route); + + if (root !== '' && !navigation[root]) { + navigation[root] = { + label, + link: `/${root}/` + }; + } + }); + + // TODO best format for users, hash map? #271 + return Object.keys(navigation).map((key) => { + return navigation[key]; + }); +}; + +const getChildrenFromParentRoute = async (root, query, context) => { + const pages = []; + const { parent } = query; + const { graph } = context; + + graph + .forEach((page) => { + const { route, mdFile, fileName, template } = page; + const root = route.split('/')[1]; + + if (root.indexOf(parent) >= 0) { + const { label } = getDeriveMetaFromRoute(route); + const id = page.label; + + pages.push({ + id, + filePath: mdFile, + fileName, + template, + title: label, + link: route + }); + } + }); + + return pages; +}; + +const graphTypeDefs = gql` + type Page { + id: String, + filePath: String, + fileName: String, + template: String, + link: String, + title: String + } + + type Navigation { + label: String, + link: String + } + + type Query { + graph: [Page] + navigation: [Navigation] + children(parent: String): [Page] + } +`; + +const graphResolvers = { + Query: { + graph: getPagesFromGraph, + navigation: getNavigationFromGraph, + children: getChildrenFromParentRoute + } +}; + +module.exports = { + graphTypeDefs, + graphResolvers +}; \ No newline at end of file diff --git a/packages/cli/src/data/schema/hello.js b/packages/cli/src/data/schema/hello.js new file mode 100644 index 000000000..df08fa39d --- /dev/null +++ b/packages/cli/src/data/schema/hello.js @@ -0,0 +1,18 @@ +const { gql } = require('apollo-server'); + +const helloTypeDefs = gql` + type HelloQuery { + hello: String + } +`; + +const helloResolvers = { + HelloQuery: { + hello: () => 'Hello world!' + } +}; + +module.exports = { + helloTypeDefs, + helloResolvers +}; \ No newline at end of file diff --git a/packages/cli/src/data/schema/schema.js b/packages/cli/src/data/schema/schema.js new file mode 100644 index 000000000..15cd45796 --- /dev/null +++ b/packages/cli/src/data/schema/schema.js @@ -0,0 +1,17 @@ +// TODO merging resolvers not actually working, resolve as part of #21 or #270 +const { makeExecutableSchema } = require('apollo-server-express'); +const { helloTypeDefs, helloResolvers } = require('./hello'); +const { graphTypeDefs, graphResolvers } = require('./graph'); + +const schema = makeExecutableSchema({ + typeDefs: [ + graphTypeDefs, + helloTypeDefs + ], + resolvers: Object.assign({}, + graphResolvers, + helloResolvers + ) +}); + +module.exports = schema; \ No newline at end of file diff --git a/packages/cli/src/data/server.js b/packages/cli/src/data/server.js new file mode 100644 index 000000000..1ab89820b --- /dev/null +++ b/packages/cli/src/data/server.js @@ -0,0 +1,31 @@ +const { ApolloServer } = require('apollo-server'); +const schema = require('./schema/schema'); +const createCache = require('./cache'); + +module.exports = (compilation) => { + const { graph, context } = compilation; + + // Create schema + const server = new ApolloServer({ + schema, + playground: { + endpoint: '/graphql', + settings: { + 'editor.theme': 'light' + } + }, + context: async (integrationContext) => { + const { req } = integrationContext; + + if (req.query.q !== 'internal') { + await createCache(req, context); + } + + return { + graph + }; + } + }); + + return server; +}; \ No newline at end of file diff --git a/packages/cli/src/lib/browser.js b/packages/cli/src/lib/browser.js index 1e54be9c5..362ea3637 100644 --- a/packages/cli/src/lib/browser.js +++ b/packages/cli/src/lib/browser.js @@ -42,7 +42,8 @@ class BrowserRunner { if ( interceptedRequestUrl.indexOf('bundle.js') >= 0 || // webpack bundles, webcomponents-bundle.js - interceptedRequestUrl === requestUrl // pages / routes + interceptedRequestUrl === requestUrl || // pages / routes + interceptedRequestUrl.indexOf('localhost:4000') >= 0 // Apollo GraphQL server ) { interceptedRequest.continue(); } else { @@ -50,21 +51,15 @@ class BrowserRunner { } }); - // Capture main frame response. This is used in the case that rendering - // times out, which results in puppeteer throwing an error. This allows us - // to return a partial response for what was able to be rendered in that - // time frame. - page.addListener('response', (r) => { - if (!response) { - response = r; - } - }); - try { // Navigate to page. Wait until there are no oustanding network requests. - response = await page.goto(requestUrl, { timeout: 10000 }); + // https://pptr.dev/#?product=Puppeteer&version=v1.8.0&show=api-pagegotourl-options + response = await page.goto(requestUrl, { + waitUntil: 'networkidle0', + timeout: 0 + }); } catch (e) { - console.error(e); + console.error('browser error', e); } if (!response) { diff --git a/packages/cli/src/lifecycles/serialize.js b/packages/cli/src/lifecycles/serialize.js index c8b199e87..30b5896c5 100644 --- a/packages/cli/src/lifecycles/serialize.js +++ b/packages/cli/src/lifecycles/serialize.js @@ -1,8 +1,35 @@ -const LocalWebServer = require('local-web-server'); const BrowserRunner = require('../lib/browser'); +const dataServer = require('../data/server'); +const deepmerge = require('deepmerge'); const fs = require('fs-extra'); +const glob = require('glob-promise'); +const LocalWebServer = require('local-web-server'); const path = require('path'); +const setDataForPages = async (context) => { + const { publicDir } = context; + const pages = await glob.promise(path.join(publicDir, '**/**/index.html')); + + pages.forEach((pagePath) => { + const contents = fs.readFileSync(pagePath, 'utf-8'); + // TODO hardcoded root / depth - #273 + const pageRoot = pagePath.replace(publicDir, '').split('/')[1]; + const cacheRoot = pageRoot === 'index.html' + ? '' + : `${pageRoot}`; + let cacheContents = {}; + + // TODO avoid having to do this per page / root, each time - #277 + glob.sync(`${publicDir}/${cacheRoot}/*-cache.json`).forEach((file) => { + cacheContents = deepmerge(cacheContents, require(file)); + }); + + // TODO could optimize this probably - #277 + fs.writeFileSync(`${publicDir}/${cacheRoot}/cache.json`, JSON.stringify(cacheContents)); + fs.writeFileSync(pagePath, contents.replace('___DATA___', JSON.stringify(cacheContents))); + }); +}; + module.exports = serializeBuild = async (compilation) => { const browserRunner = new BrowserRunner(); const localWebServer = new LocalWebServer(); @@ -14,14 +41,19 @@ module.exports = serializeBuild = async (compilation) => { const runBrowser = async (compilation) => { try { - return Promise.all(compilation.graph.map(async({ route }) => { + return Promise.all(compilation.graph.map(async(page) => { const { publicDir } = compilation.context; + const { route } = page; return await browserRunner.serialize(`http://127.0.0.1:${PORT}${route}`).then(async (content) => { const target = path.join(publicDir, route); const html = content .replace(polyfill, '') - .replace('', ''); + .replace('', ` + + `); await fs.mkdirs(target, { recursive: true }); await fs.writeFile(path.join(target, 'index.html'), html); @@ -29,7 +61,7 @@ module.exports = serializeBuild = async (compilation) => { })); } catch (err) { // eslint-disable-next-line no-console - console.log(err); + console.error(err); return false; } }; @@ -38,24 +70,32 @@ module.exports = serializeBuild = async (compilation) => { try { polyfill = await fs.readFile(polyfillPath, 'utf8'); + const { context } = compilation; const indexContentsPath = path.join(compilation.context.publicDir, compilation.context.indexPageTemplate); const indexContents = await fs.readFile(indexContentsPath, 'utf8'); const indexContentsPolyfilled = indexContents.replace('
', ``); await fs.writeFile(indexContentsPath, indexContentsPolyfilled); + await dataServer(compilation).listen().then((server) => { + console.log(`dataServer started at ${server.url}`); + }); + // "serialize" our SPA into a static site - const server = localWebServer.listen({ + const webServer = localWebServer.listen({ port: PORT, https: false, - directory: compilation.context.publicDir, - spa: compilation.context.indexPageTemplate + directory: context.publicDir, + spa: context.indexPageTemplate }); await runBrowser(compilation); browserRunner.close(); - server.close(); + webServer.close(); + + // loop through all index.html files and inject cache + await setDataForPages(compilation.context); resolve(); } catch (err) { diff --git a/packages/cli/src/tasks/develop.js b/packages/cli/src/tasks/develop.js index 39884855d..db35f5abf 100644 --- a/packages/cli/src/tasks/develop.js +++ b/packages/cli/src/tasks/develop.js @@ -1,3 +1,4 @@ +const dataServer = require('../data/server'); const path = require('path'); const webpack = require('webpack'); const WebpackDevServer = require('webpack-dev-server'); @@ -6,6 +7,10 @@ module.exports = runDevServer = async (compilation) => { return new Promise(async (resolve, reject) => { try { + await dataServer(compilation).listen().then((server) => { + console.log(`dataServer started at ${server.url}`); + }); + const webpackConfig = require(path.join(__dirname, '..', './config/webpack.config.develop.js'))(compilation); const devServerConfig = webpackConfig.devServer; diff --git a/packages/cli/src/templates/app-template.js b/packages/cli/src/templates/app-template.js index 275d6843d..eee98601c 100644 --- a/packages/cli/src/templates/app-template.js +++ b/packages/cli/src/templates/app-template.js @@ -7,12 +7,11 @@ import thunk from 'redux-thunk'; // eslint-disable-next-line no-underscore-dangle const compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || origCompose; -// eslint-disable-next-line -const store = createStore( - (state, action) => state, // eslint-disable-line - compose(lazyReducerEnhancer(combineReducers), applyMiddleware(thunk))); +const store = createStore((state) => state, + compose(lazyReducerEnhancer(combineReducers), applyMiddleware(thunk)) +); -import '../index/index.js'; +import '../index/index'; import './list'; connectRouter(store); @@ -26,4 +25,4 @@ class AppComponent extends LitElement { } } -customElements.define('eve-app', AppComponent); +customElements.define('eve-app', AppComponent); \ No newline at end of file diff --git a/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js b/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js new file mode 100644 index 000000000..c7ee32085 --- /dev/null +++ b/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js @@ -0,0 +1,189 @@ +/* + * Use Case + * Run Greenwood build command with GraphQL calls to get data about the projects graph. + * + * User Result + * Should generate a Greenwood build that dynamically serializes data from the graph from the header and in the page-template. + * + * User Command + * greenwood build + * + * Default Config + * + * Custom Workspace + * src/ + * components/ + * header.js + * pages/ + * blog/ + * first-post.md + * second-post.md + * index.md + * templates/ + * app-template.js + * blog-template.js + */ +const expect = require('chai').expect; +const fs = require('fs'); +const glob = require('glob-promise'); +const { JSDOM } = require('jsdom'); +const path = require('path'); +const TestBed = require('../../../../../test/test-bed'); + +describe('Build Greenwood With: ', function() { + const LABEL = 'Data from GraphQL'; + let setup; + + before(async function() { + setup = new TestBed(); + this.context = await setup.setupTestBed(__dirname); + }); + + describe(LABEL, function() { + + before(async function() { + await setup.runGreenwoodCommand('build'); + }); + + runSmokeTest(['public', 'not-found'], LABEL); + + describe('Home (Page Template) w/ Navigation Query', function() { + const expectedCache = {"ROOT_QUERY.navigation.0":{"label":"Blog","link":"/blog/","__typename":"Navigation"},"ROOT_QUERY":{"navigation":[{"type":"id","generated":true,"id":"ROOT_QUERY.navigation.0","typename":"Navigation"}]}}; // eslint-disable-line + + beforeEach(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'index.html')); + }); + + it('should create a public directory', function() { + expect(fs.existsSync(this.context.publicDir)).to.be.true; + }); + + it('should output an index.html file (home page)', function() { + expect(fs.existsSync(path.join(this.context.publicDir, './index.html'))).to.be.true; + }); + + it('should output a single 404.html file (not found page)', function() { + expect(fs.existsSync(path.join(this.context.publicDir, './404.html'))).to.be.true; + }); + + it('should output one JS bundle file', async function() { + expect(await glob.promise(path.join(this.context.publicDir, './index.*.bundle.js'))).to.have.lengthOf(1); + }); + + it('should output one cache.json file', async function() { + expect(await glob.promise(path.join(this.context.publicDir, './cache.json'))).to.have.lengthOf(1); + }); + + // TODO fixing the ordering issue would help make this test case more reliable - #271 + xit('should output one cache.json file with expected cache contents', async function() { + const cacheContents = require(path.join(this.context.publicDir, './cache.json')); + + expect(cacheContents).to.be.deep.equalInAnyOrder(expectedCache); + }); + + it('should have one window.__APOLLO_STATE__ diff --git a/packages/plugin-google-analytics/test/cases/default/default.spec.js b/packages/plugin-google-analytics/test/cases/default/default.spec.js index 8620727ea..cc92512eb 100644 --- a/packages/plugin-google-analytics/test/cases/default/default.spec.js +++ b/packages/plugin-google-analytics/test/cases/default/default.spec.js @@ -47,7 +47,7 @@ describe('Build Greenwood With: ', function() { runSmokeTest(['public', 'index', 'not-found', 'hello'], LABEL); - describe('Initialization script at the end of the tag', function() { + describe('Initialization script', function() { let inlineScript = []; let scriptSrcTags = []; @@ -56,7 +56,7 @@ describe('Build Greenwood With: ', function() { const scriptTags = dom.window.document.querySelectorAll('head script'); inlineScript = Array.prototype.slice.call(scriptTags).filter(script => { - return !script.src; + return !script.src && !script.getAttribute('data-state'); }); scriptSrcTags = Array.prototype.slice.call(scriptTags).filter(script => { @@ -96,7 +96,7 @@ describe('Build Greenwood With: ', function() { }); }); - describe('Tracking script at the end of the tag', function() { + describe('Tracking script', function() { let trackingScript; beforeEach(async function() { diff --git a/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js b/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js index c67842a66..186b671d5 100644 --- a/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js +++ b/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js @@ -48,7 +48,7 @@ describe('Build Greenwood With: ', function() { runSmokeTest(['public', 'index', 'not-found', 'hello'], LABEL); - describe('Initialization script at the end of the tag', function() { + describe('Initialization script', function() { let inlineScript; beforeEach(async function() { @@ -56,7 +56,7 @@ describe('Build Greenwood With: ', function() { const scriptTags = dom.window.document.querySelectorAll('head script'); inlineScript = Array.prototype.slice.call(scriptTags).filter(script => { - return !script.src; + return !script.src && !script.getAttribute('data-state'); }); }); @@ -79,7 +79,7 @@ describe('Build Greenwood With: ', function() { }); }); - describe('Tracking script at the end of the tag', function() { + describe('Tracking script', function() { let trackingScript; beforeEach(async function() { diff --git a/test/smoke-test.js b/test/smoke-test.js index 54f0c4dee..3b2d5f63a 100644 --- a/test/smoke-test.js +++ b/test/smoke-test.js @@ -99,6 +99,15 @@ function defaultIndex(label) { expect(bundledScript.length).to.be.equal(1); }); + it('should have one