Repository: subsecondtdd/todo-subsecond Branch: master Commit: 746a6a40a202 Files: 53 Total size: 30.8 KB Directory structure: gitextract_shujpsec/ ├── .circleci/ │ └── config.yml ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── cucumber ├── cucumber-electron ├── cucumber.js ├── features/ │ ├── run/ │ │ ├── all │ │ ├── all.cmd │ │ ├── browserstack-memory │ │ ├── browserstack-memory.cmd │ │ ├── database │ │ ├── database.cmd │ │ ├── dom-http-memory │ │ ├── dom-http-memory.cmd │ │ ├── dom-memory │ │ ├── dom-memory.cmd │ │ ├── http-memory │ │ ├── http-memory.cmd │ │ ├── memory │ │ ├── memory.cmd │ │ ├── webdriver-http-database │ │ ├── webdriver-http-database.cmd │ │ ├── webdriver-memory │ │ └── webdriver-memory.cmd │ ├── step_definitions/ │ │ └── todo_steps.js │ ├── support/ │ │ ├── assemblies/ │ │ │ ├── browserstack-memory.js │ │ │ ├── database.js │ │ │ ├── dom-http-memory.js │ │ │ ├── dom-memory.js │ │ │ ├── http-memory.js │ │ │ ├── memory.js │ │ │ ├── webdriver-http-database.js │ │ │ └── webdriver-memory.js │ │ ├── parameter_types.js │ │ └── world.js │ └── todo.feature ├── lib/ │ ├── client/ │ │ ├── BrowserApp.js │ │ ├── HttpTodoList.js │ │ └── client.js │ └── server/ │ ├── DatabaseTodoList.js │ ├── WebApp.js │ ├── asyncExpress.js │ └── start.js ├── package.json ├── public/ │ ├── index.html │ └── todo.css ├── test/ │ └── MemoryTodoListTest.js └── test_support/ ├── BrowserStackTodoList.js ├── DomTodoList.js ├── MemoryTodoList.js └── WebDriverTodoList.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ version: 2 jobs: build: docker: - image: circleci/node:8-browsers environment: DATABASE_URL: sqlite:./todo-subsecond.sqlite working_directory: ~/repo steps: - run: name: check version command: | node --version yarn --version - checkout - restore_cache: keys: - todo-subsecond-{{ arch }}-{{ .Branch }}-{{ checksum "package.json" }} - run: name: install dependencies command: yarn install - save_cache: paths: - node_modules key: todo-subsecond-{{ arch }}-{{ .Branch }}-{{ checksum "package.json" }} - run: yarn test ================================================ FILE: .gitignore ================================================ node_modules local.log browserstack.err .DS_Store todo-subsecond.sqlite ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) Sub-Second TDD Organization Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Procfile ================================================ web: node lib/server/start.js ================================================ FILE: README.md ================================================ # TODO Subsecond [![CircleCI](https://circleci.com/gh/subsecondtdd/todo-subsecond.svg?style=svg)](https://circleci.com/gh/subsecondtdd/todo-subsecond) This is a tiny application with full-stack acceptance tests that can run in milliseconds. The purpose is to illustrate the essential techniques to achieve this in any system. Through a series of exercises you'll get familiar with a particular way of doing TDD/BDD: 1. Small increments. Work at a single layer at a time. 2. Fast (sub-second) feedback loop. 3. UI-agnostic scenarios. Describe system behaviour rather than implemention. 4. Testing through the UI without the typical drawbacks (slow, brittle). Fast and stable full-stack tests that go through the UI are possible when the following criteria are satisfied: * No I/O whatsoever * No I/O between your test and a browser automation tool like Selenium WebDriver * No I/O between Selenium WebDriver and a browser * No I/O between the browser application and the server * No I/O between the server and a database * Clearly defined interfaces/contracts that allow swapping out the slow I/O bound components above * UI and server run in the same process * This is possible thanks to [cucumber-electron](https://github.com/cucumber/cucumber-electron) * Tests/Scenarios are decoupled from the UI, allowing the UI to change with minimal impact on tests ## Credits and inspiration The techniques illustrated by this application are inspired by: * [Nat Pryce's "Having Our Cake and Eating it](https://youtu.be/Fk4rCn4YLLU) - slides [here](https://speakerdeck.com/npryce/having-our-cake-and-eating-it-1) * [Jan Molak's "Testing modern web apps. At scale."](http://agileonthebeach.com/testing-modern-web-apps-scale-jan-molak-software-delivery-2017/) * Josh's colleagues at [Featurist](https://www.featurist.co.uk/) * Aslak's colleagues at [Cucumber Ltd](https://cucumber.io) ## Install Software * [Node.js 8.x or higher](https://nodejs.org/en/download/current/) ## Install dependencies npm install ## Create a database Although this repository is intended as a demonstration of a design pattern, we want it to be as realistic as possible. So ideally you should use a database that you might use in production, like the [instance we deployed with a postgres database](https://todo-subsecond.herokuapp.com). If you have Postgres installed you can use that. Otherwise you can use SQlite (this is simpler as it does not require a separate installation): ### Postgres createdb todo-subsecond ### SQlite # Linux/OSX: export DATABASE_URL=sqlite:./todo-subsecond.sqlite # Windows: set DATABASE_URL=sqlite:./todo-subsecond.sqlite ## Run tests Run all the tests in a single command: # Linux/OSX: features/run/all # Windows: features\run\all Or just one assembly at a time (we'll explain assemblies further down): # Linux/OSX: features/run/memory # Windows: features\run\memory ### Change browser The `webdriver-*` assemblies will use Chrome by default. You can change to another browser if you want: # Linux/OSX: brew install geckodriver export SELENIUM_BROWSER=firefox # Windows: set SELENIUM_BROWSER=firefox ## Start the server The application is a small web application that renders a simple single-page application. Fire it up: npm start Open `http://localhost:8666` in your browser and add a couple of todos. ![screenshot](docs/screenshot.png) Check one of them and refresh the page. As you'll see, the application doesn't remember that a todo is marked as done. ## Implement a new feature We're going to improve the application to make it remember the state of todos. Uncomment the three steps in `features/todo.feature` and run `features/run/all` again. Cucumber will tell you that two steps are undefined. Copy the first snippet and paste it into `features/step_definitions/todo_steps.js`. Modify it so it looks like this: ```javascript When('I mark the {ordinal} todo as done', async function (index) { const todoList = await this.actionTodoList() await todoList.markAsDone(index) }) ``` Run Cucumber again. This time you'll get an error. Open `test_support/MemoryTodoList.js` and implement a new `markAsDone` method. Run `features/run/all` again until you've managed to get the 2nd step to pass. Now, paste the snippet for the last undefined step and modify it: ```javascript Then('the {ordinal} todo should be marked as done', async function (index) { const todoList = await this.outcomeTodoList() const todos = await todoList.getTodos() assert.equal(todos[index].done, true) }) ``` Run `features/run/all` again until all steps pass. When they do, Cucumber will run the same scenario in a new configuration, `dom-memory`. This will fail. Now you need to make changes to `DomTodoList.js`. Continue until everything is green. Every time you have a failing step you can make small increments in a single layer of the application. Eventually all scenarios should pass. When they do, you can do a final manual test just to give yourself confidence that everything works as expected. That's it - subsecond TDD with acceptance tests that run in milliseconds while you're developing. You shouldn't have to rely on the slow acceptance tests (`webdriver-memory` and `webdriver-http-database`) while you're implementing the new functionality. You should only run them at the end to give you full confidence, and they should just pass without requiring you to make any changes. ## Assemblies When you look at `features/todo.feature` and `features/step_definitions/todo_steps.js` you'll notice that the scenarios and step definitions make no assumptions about how to interact with the application. We're interacting with the application *through a contract*, or an *interface* - `addTodo` and `getTodos`. Think of your scenarios as a lego-like block that can be connected to another block that satisfies this contract: ![lego](docs/test.png) In a statically typed language such as Java, C# or TypeScript, this would be an interface. With JavaScript it's just *implicitly* defined. Several parts of the application as well as additional test code can implement this interface/contract: ![lego](docs/lego.png) There are three kinds of blocks: * Green blocks represent *test code* * Blue blocks represent *application code* * Pink blocks represent *infrastructure* Tests can be connected to the top of any stack of blocks and test different assemblies of the application. A tall stack of blocks gives more confidence at the cost of speed. The speed doesn't depend directly on the number of blocks, but rather on the amount of I/O happening *between* each block and the amount of CPU processing happening *inside* each block. ================================================ FILE: cucumber ================================================ #!/usr/bin/env bash node node_modules/.bin/cucumber-js "$@" ================================================ FILE: cucumber-electron ================================================ #!/usr/bin/env bash node node_modules/.bin/cucumber-electron "$@" ================================================ FILE: cucumber.js ================================================ module.exports = { default: '--format-options \'{"snippetInterface": "promise"}\'' } ================================================ FILE: features/run/all ================================================ #!/usr/bin/env bash features/run/memory && \ features/run/dom-memory && \ features/run/http-memory && \ features/run/dom-http-memory && \ features/run/database && \ features/run/webdriver-memory && \ features/run/webdriver-http-database && \ features/run/browserstack-memory ================================================ FILE: features/run/all.cmd ================================================ @echo off call features\run\memory if %errorlevel% neq 0 exit /b %errorlevel% call features\run\dom-memory if %errorlevel% neq 0 exit /b %errorlevel% call features\run\http-memory if %errorlevel% neq 0 exit /b %errorlevel% call features\run\dom-http-memory if %errorlevel% neq 0 exit /b %errorlevel% call features\run\database if %errorlevel% neq 0 exit /b %errorlevel% call features\run\webdriver-memory if %errorlevel% neq 0 exit /b %errorlevel% call features\run\webdriver-http-database if %errorlevel% neq 0 exit /b %errorlevel% call features\run\browserstack-memory if %errorlevel% neq 0 exit /b %errorlevel% ================================================ FILE: features/run/browserstack-memory ================================================ #!/usr/bin/env bash if [[ -z "${BROWSERSTACK_USER}" ]]; then echo "set BROWSERSTACK_USER and BROWSERSTACK_KEY to run features on browserstack" else CUCUMBER_ASSEMBLY=browserstack-memory node node_modules/.bin/cucumber-js "$@" fi ================================================ FILE: features/run/browserstack-memory.cmd ================================================ @echo off IF "%BROWSERSTACK_USER%"=="" ( echo set BROWSERSTACK_USER and BROWSERSTACK_KEY to run features on browserstack ) ELSE ( set CUCUMBER_ASSEMBLY=browserstack-memory node_modules\.bin\cucumber-js %* ) ================================================ FILE: features/run/database ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=database node node_modules/.bin/cucumber-js "$@" ================================================ FILE: features/run/database.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=database node_modules\.bin\cucumber-js %* ================================================ FILE: features/run/dom-http-memory ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=dom-http-memory node node_modules/.bin/cucumber-electron "$@" ================================================ FILE: features/run/dom-http-memory.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=dom-http-memory node_modules\.bin\cucumber-electron %* ================================================ FILE: features/run/dom-memory ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=dom-memory node node_modules/.bin/cucumber-electron "$@" ================================================ FILE: features/run/dom-memory.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=dom-memory node_modules\.bin\cucumber-electron %* ================================================ FILE: features/run/http-memory ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=http-memory node node_modules/.bin/cucumber-js "$@" ================================================ FILE: features/run/http-memory.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=http-memory node_modules\.bin\cucumber-js %* ================================================ FILE: features/run/memory ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=memory node node_modules/.bin/cucumber-js "$@" ================================================ FILE: features/run/memory.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=memory node_modules\.bin\cucumber-js %* ================================================ FILE: features/run/webdriver-http-database ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=webdriver-http-database node node_modules/.bin/cucumber-js "$@" ================================================ FILE: features/run/webdriver-http-database.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=webdriver-http-database node_modules\.bin\cucumber-js %* ================================================ FILE: features/run/webdriver-memory ================================================ #!/usr/bin/env bash CUCUMBER_ASSEMBLY=webdriver-memory node node_modules/.bin/cucumber-js "$@" ================================================ FILE: features/run/webdriver-memory.cmd ================================================ @echo off set CUCUMBER_ASSEMBLY=webdriver-memory node_modules\.bin\cucumber-js %* ================================================ FILE: features/step_definitions/todo_steps.js ================================================ const assert = require('assert') const { Given, When, Then, setDefaultTimeout } = require('cucumber') setDefaultTimeout(60000) Given('there is/are already {int} todo(s)', async function(count) { const todoList = await this.contextTodoList() for (let i = 0; i < count; i++) await todoList.addTodo({ text: `Todo ${i}` }) }) When('I add {string}', async function(text) { const todoList = await this.actionTodoList() await todoList.addTodo({ text }) }) Then('the text of the {ordinal} todo should be {string}', async function(index, text) { const todoList = await this.outcomeTodoList() const todos = await todoList.getTodos() assert.equal(todos[index].text, text) }) ================================================ FILE: features/support/assemblies/browserstack-memory.js ================================================ const BrowserStackTodoList = require('../../../test_support/BrowserStackTodoList') const MemoryTodoList = require('../../../test_support/MemoryTodoList') const WebApp = require('../../../lib/server/WebApp') module.exports = class BrowserStackMemoryAssembly { async start () { this._memoryTodoList = new MemoryTodoList() this._webApp = new WebApp({ todoList: this._memoryTodoList, serveClient: true }) const port = await this._webApp.listen(0) this._browserStackTodoList = new BrowserStackTodoList(`http://localhost:${port}`) await this._browserStackTodoList.start() } async stop () { await this._browserStackTodoList.stop() await this._webApp.stop() } async contextTodoList() { return this._memoryTodoList } async actionTodoList() { return this._browserStackTodoList } async outcomeTodoList() { return this._browserStackTodoList } } ================================================ FILE: features/support/assemblies/database.js ================================================ const DatabaseTodoList = require('../../../lib/server/DatabaseTodoList') module.exports = class DatabaseAssembly { async start () { this._databaseTodoList = new DatabaseTodoList() await this._databaseTodoList.start(true) } async stop() { await this._databaseTodoList.stop() } async contextTodoList() { return this._databaseTodoList } async actionTodoList() { return this._databaseTodoList } async outcomeTodoList() { return this._databaseTodoList } } ================================================ FILE: features/support/assemblies/dom-http-memory.js ================================================ const fs = require('fs') const path = require('path') const DomTodoList = require('../../../test_support/DomTodoList') const MemoryTodoList = require('../../../test_support/MemoryTodoList') const HttpTodoList = require('../../../lib/client/HttpTodoList') const BrowserApp = require('../../../lib/client/BrowserApp') const WebApp = require('../../../lib/server/WebApp') module.exports = class DomHttpMemoryAssembly { async start () { this._memoryTodoList = new MemoryTodoList() this.webApp = new WebApp({ todoList: this._memoryTodoList, serveClient: false }) } async stop() {} async contextTodoList() { return this._memoryTodoList } async actionTodoList() { return await this._makeDomTodoList() } async outcomeTodoList() { return await this._makeDomTodoList() } async _makeDomTodoList() { const publicIndexHtmlPath = path.join(__dirname, '..', '..', '..', 'public', 'index.html') const html = fs.readFileSync(publicIndexHtmlPath, 'utf-8') const domNode = document.createElement('div') domNode.innerHTML = html document.body.appendChild(domNode) await new BrowserApp({ domNode, todoList: await this._makeHttpTodoList() }).mount() return new DomTodoList(domNode) } async _makeHttpTodoList() { const port = await this.webApp.listen(0) return new HttpTodoList(`http://localhost:${port}`) } } ================================================ FILE: features/support/assemblies/dom-memory.js ================================================ const fs = require('fs') const path = require('path') const DomTodoList = require('../../../test_support/DomTodoList') const MemoryTodoList = require('../../../test_support/MemoryTodoList') const BrowserApp = require('../../../lib/client/BrowserApp') module.exports = class DomMemoryAssembly { async start () { this._memoryTodoList = new MemoryTodoList() } async stop() {} async contextTodoList() { return this._memoryTodoList } async actionTodoList() { return await this._makeDomTodoList() } async outcomeTodoList() { return await this._makeDomTodoList() } async _makeDomTodoList() { const publicIndexHtmlPath = path.join(__dirname, '..', '..', '..', 'public', 'index.html') const html = fs.readFileSync(publicIndexHtmlPath, 'utf-8') const domNode = document.createElement('div') domNode.innerHTML = html document.body.appendChild(domNode) await new BrowserApp({ domNode, todoList: this._memoryTodoList }).mount() return new DomTodoList(domNode) } } ================================================ FILE: features/support/assemblies/http-memory.js ================================================ const MemoryTodoList = require('../../../test_support/MemoryTodoList') const WebApp = require('../../../lib/server/WebApp') const HttpTodoList = require('../../../lib/client/HttpTodoList') module.exports = class HttpMemoryAssembly { async start () { this._memoryTodoList = new MemoryTodoList() this._webApp = new WebApp({ todoList: this._memoryTodoList, serveClient: false }) const port = await this._webApp.listen(0) this._httpTodoList = new HttpTodoList(`http://localhost:${port}`) } async stop () { await this._webApp.stop() } async contextTodoList() { return this._memoryTodoList } async actionTodoList() { return this._httpTodoList } async outcomeTodoList() { return this._httpTodoList } } ================================================ FILE: features/support/assemblies/memory.js ================================================ const MemoryTodoList = require('../../../test_support/MemoryTodoList') module.exports = class MemoryAssembly { async start () { this._memoryTodoList = new MemoryTodoList() } async stop() {} async contextTodoList() { return this._memoryTodoList } async actionTodoList() { return this._memoryTodoList } async outcomeTodoList() { return this._memoryTodoList } } ================================================ FILE: features/support/assemblies/webdriver-http-database.js ================================================ const WebDriverTodoList = require('../../../test_support/WebDriverTodoList') const DatabaseTodoList = require('../../../lib/server/DatabaseTodoList') const WebApp = require('../../../lib/server/WebApp') module.exports = class WebDriverDatabaseAssembly { async start () { this._databaseTodoList = new DatabaseTodoList() await this._databaseTodoList.start(true) this._webApp = new WebApp({ todoList: this._databaseTodoList, serveClient: true }) const port = await this._webApp.listen(0) this._webDriverTodoList = new WebDriverTodoList(`http://localhost:${port}`) } async stop () { await this._webDriverTodoList.stop() await this._webApp.stop() } async contextTodoList() { return this._databaseTodoList } async actionTodoList() { await this._webDriverTodoList.start() return this._webDriverTodoList } async outcomeTodoList() { return this._webDriverTodoList } } ================================================ FILE: features/support/assemblies/webdriver-memory.js ================================================ const WebDriverTodoList = require('../../../test_support/WebDriverTodoList') const MemoryTodoList = require('../../../test_support/MemoryTodoList') const WebApp = require('../../../lib/server/WebApp') module.exports = class WebDriverMemoryAssembly { async start () { this._memoryTodoList = new MemoryTodoList() this._webApp = new WebApp({ todoList: this._memoryTodoList, serveClient: true }) const port = await this._webApp.listen(0) this._webDriverTodoList = new WebDriverTodoList(`http://localhost:${port}`) } async stop () { await this._webDriverTodoList.stop() await this._webApp.stop() } async contextTodoList() { return this._memoryTodoList } async actionTodoList() { await this._webDriverTodoList.start() return this._webDriverTodoList } async outcomeTodoList() { return this._webDriverTodoList } } ================================================ FILE: features/support/parameter_types.js ================================================ const { defineParameterType } = require('cucumber') defineParameterType({ name: 'ordinal', regexp: /(\d+)(?:st|nd|rd|th)/, transformer(s) { return parseInt(s) - 1 } }) ================================================ FILE: features/support/world.js ================================================ const { setWorldConstructor, Before, After } = require('cucumber') const assemblyName = process.env.CUCUMBER_ASSEMBLY || 'memory' console.log(`🥒 ${assemblyName}`) const AssemblyModule = require(`./assemblies/${assemblyName}`) const assembly = new AssemblyModule() class TodoWorld { constructor() { this.contextTodoList = () => assembly.contextTodoList() this.actionTodoList = () => assembly.actionTodoList() this.outcomeTodoList = () => assembly.outcomeTodoList() } } setWorldConstructor(TodoWorld) Before(async function() { await assembly.start() }) After(async function() { await assembly.stop() }) ================================================ FILE: features/todo.feature ================================================ Feature: Todo Scenario: Create Todo Given there is already 1 todo When I add "get milk" Then the text of the 2nd todo should be "get milk" Scenario: Mark Todo Done # Given there are already 2 todos # When I mark the 2nd todo as done # Then the 2nd todo should be marked as done Scenario: Mark Todo Undone Scenario: Delete Todo ================================================ FILE: lib/client/BrowserApp.js ================================================ module.exports = class BrowserApp { constructor({ domNode, todoList }) { this._domNode = domNode this._todoList = todoList this._addTodoButton = this._domNode.querySelector('[aria-label="Add Todo"]') this._todoTextField = this._domNode.querySelector('[aria-label="New Todo Text"]') this._todos = this._domNode.querySelector('[aria-label="Todos"]') this._addTodoButton.addEventListener('click', event => { event.preventDefault() const text = this._todoTextField.value this.addTodo({ text }) .then(() => { this._todoTextField.value = '' }) .catch(err => console.error(err)) }) } async mount() { this.renderTodos(await this._todoList.getTodos()) } async addTodo({ text }) { await this._todoList.addTodo({ text }) const todos = await this._todoList.getTodos() await this.renderTodos(todos) this._domNode.dispatchEvent( new window.CustomEvent('todos:todo:added', { bubbles: true, }) ) } renderTodos(todos) { // Remove old todos while (this._todos.hasChildNodes()) { this._todos.removeChild(this._todos.lastChild) } const ol = document.createElement('ol') this._todos.appendChild(ol) // Render new ones for (let i = 0; i < todos.length; i++) { const todo = todos[i] const li = document.createElement('li') const label = document.createElement('label') const checkbox = document.createElement('input') checkbox.type = 'checkbox' const textNode = document.createTextNode(todo.text) label.appendChild(checkbox) label.appendChild(textNode) li.appendChild(label) ol.appendChild(li) } } } ================================================ FILE: lib/client/HttpTodoList.js ================================================ module.exports = class HttpTodoList { constructor(baseUrl, fetch) { this._baseUrl = baseUrl this._fetch = fetch || require('node-fetch') } async addTodo({ text }) { const res = await this._fetch(`${this._baseUrl}/todos`, { method: 'post', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }) if (res.status !== 201) throw new Error('Failed to create TODO') } async getTodos() { const res = await this._fetch(`${this._baseUrl}/todos`) if (res.status !== 200) throw new Error('Failed to get TODOs') return res.json() } } ================================================ FILE: lib/client/client.js ================================================ const BrowserApp = require('./BrowserApp') const HttpTodoList = require('./HttpTodoList') new BrowserApp({ domNode: document.body, todoList: new HttpTodoList('', window.fetch.bind(window)) }).mount() ================================================ FILE: lib/server/DatabaseTodoList.js ================================================ const Sequelize = require('sequelize'); module.exports = class DatabaseTodoList { constructor() { const databaseUrl = process.env.DATABASE_URL || 'postgres://localhost/todo-subsecond' this._sequelize = new Sequelize(databaseUrl, { logging: false }) this.Todo = this._sequelize.define('todo', { text: Sequelize.STRING, // Use Sequelize.BOOLEAN for the done field }) } async start(truncate) { if (truncate) { await this._sequelize.sync({ force: true }) } } async stop(truncate) { await this._sequelize.close() } async addTodo({ text }) { await this.Todo.create({ text }) } async getTodos() { return this.Todo.findAll().map(record => ({ text: record.text })) } // async markAsDone(index) { // const todo = ..... // await todo.update({ done: true }) // } } ================================================ FILE: lib/server/WebApp.js ================================================ const http = require('http') const express = require('express') const bodyParser = require('body-parser') const asyncExpress = require('./asyncExpress') module.exports = class WebApp { constructor({ todoList, serveClient }) { this._todoList = todoList this._serveClient = serveClient } async buildApp() { const app = express() app.use(bodyParser.json()) app.use(express.static('./public')) if (this._serveClient) { const browserify = require('browserify-middleware') app.get('/client.js', browserify(__dirname + '/../client/client.js')) } const asyncApp = asyncExpress(app) asyncApp.post('/todos', async (req, res) => { const { text } = req.body await this._todoList.addTodo({ text }) res.status(201).end() }) asyncApp.get('/todos', async (req, res) => { res.setHeader('Content-Type', 'application/json') const todos = await this._todoList.getTodos() res.status(200).end(JSON.stringify(todos)) }) return app } async listen(port) { const app = await this.buildApp() this._server = http.createServer(app) return new Promise((resolve, reject) => { this._server.listen(port, err => { if (err) return reject(err) resolve(this._server.address().port) }) }) } async stop() { await new Promise((resolve, reject) => { this._server.close(err => { if (err) return reject(err) resolve() }) }) } } ================================================ FILE: lib/server/asyncExpress.js ================================================ const wrap = asyncFn => (req, res, next) => { asyncFn(req, res, next).catch(err => next(err)) } module.exports = function(app) { return { delete: (path, fn) => app.delete(path, wrap(fn)), get: (path, fn) => app.get(path, wrap(fn)), post: (path, fn) => app.post(path, wrap(fn)), put: (path, fn) => app.put(path, wrap(fn)), } } ================================================ FILE: lib/server/start.js ================================================ const WebApp = require('./WebApp') const DatabaseTodoList = require('./DatabaseTodoList') const port = process.env.PORT || 8666 const todoList = new DatabaseTodoList() todoList.start() .then(() => new WebApp({ todoList, serveClient: true }).listen(port)) .then(() => console.log(`http://localhost:${port}`)) ================================================ FILE: package.json ================================================ { "name": "todo-subsecond", "version": "1.0.0", "main": "index.js", "contributors": [ "Josh Chisholm ", "Aslak Hellesøy " ], "license": "MIT", "scripts": { "start": "node lib/server/start.js", "mocha": "mocha", "test": "features/run/all", "time": "time features/run/memory && time features/run/webdriver-http-database" }, "dependencies": { "body-parser": "^1.19.0", "browserify": "^16.5.0", "browserify-middleware": "^8.1.1", "express": "^4.17.1", "pg": "^7.18.2", "pg-hstore": "^2.3.3", "sequelize": "^5.21.5", "sqlite3": "^4.1.1" }, "devDependencies": { "browserstack-local": "^1.4.5", "cucumber": "^6.0.5", "cucumber-electron": "^2.7.0", "electron": "^8.1.1", "mocha": "^7.1.0", "node-fetch": "^2.6.0", "selenium-webdriver": "^4.0.0-alpha.7" } } ================================================ FILE: public/index.html ================================================ todos
================================================ FILE: public/todo.css ================================================ * { box-sizing: border-box; } body { font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: rgb(245, 245, 245); color: #333; } body::before { content: 'todos'; display: block; text-align: center; padding: 20px; font-size: 100px; color: rgba(175, 47, 47, 0.3); font-weight: 100; } form { text-align: center; width: 600px; margin: auto; padding: 20px; background-color: #fff; border: 3px solid #efefef; border-bottom: none; position: relative; } input[type="checkbox"] { transform:scale(2, 2); position: absolute; left: 32px; top: 28px; } form input, form button, ol li label { font-size: 30px; font-weight: 400; } form input[type="text"], form input[type="submit"] { border: 2px solid #efefef; padding: 10px; } form input[type="text"] { width: 400px; text-indent: 20px; } form input[type="submit"] { width: 120px; margin-left: 5px; } ol { margin: 0; width: 600px; margin: auto; padding: 0; border: 3px solid #efefef; border-top: none; } ol li { list-style-type: none; border-top: 2px solid #efefef; position: relative; } ol li label { width: 100%; display: block; padding: 20px; background-color: #fff; color: #333; padding-left: 80px; } ol li label:hover { background-color: rgba(250, 250, 250, 0.15); } ol li input { font-size: 30px; } ================================================ FILE: test/MemoryTodoListTest.js ================================================ const assert = require('assert') const MemoryTodoList = require('../test_support/MemoryTodoList') describe('MemoryTodoList', () => { it('stores and retrieves TODOs', async () => { const todoList = new MemoryTodoList() await todoList.addTodo({ text: 'Get milk' }) const todos = await todoList.getTodos() assert.deepEqual(todos, [{text: 'Get milk'}]) }) }) ================================================ FILE: test_support/BrowserStackTodoList.js ================================================ const WebDriverTodoList = require('./WebDriverTodoList') const browserstack = require('browserstack-local') module.exports = class BrowserStackTodoList extends WebDriverTodoList { buildDriver(builder) { return builder .usingServer('http://hub-cloud.browserstack.com/wd/hub') .withCapabilities({ 'browserName': 'firefox', 'browserstack.user': process.env.BROWSERSTACK_USER, 'browserstack.key': process.env.BROWSERSTACK_KEY, 'browserstack.local': 'true' }) } async start() { this.browserstackLocal = new browserstack.Local() await new Promise((resolve, reject) => { this.browserstackLocal.start({ key: process.env.BROWSERSTACK_KEY }, () => { resolve() }) }) await super.start() } async stop() { await super.stop() await new Promise((resolve, reject) => { this.browserstackLocal.stop(() => { resolve() }) }) } } ================================================ FILE: test_support/DomTodoList.js ================================================ module.exports = class DomTodoList { constructor(domNode) { this._domNode = domNode } async addTodo({ text }) { return new Promise(resolve => { this._domNode.addEventListener('todos:todo:added', () => { resolve() }) this._domNode.querySelector('[aria-label="New Todo Text"]').value = text this._domNode.querySelector('[aria-label="Add Todo"]').click() }) } async getTodos() { return [...this._domNode.querySelectorAll('[aria-label="Todos"] li label')].map(label => ({ text: label.innerText })) } } ================================================ FILE: test_support/MemoryTodoList.js ================================================ module.exports = class MemoryTodoList { constructor() { this._todos = [] } async addTodo({ text }) { this._todos.push({ text }) } async getTodos() { return this._todos } } ================================================ FILE: test_support/WebDriverTodoList.js ================================================ const webdriver = require('selenium-webdriver') const { By, until } = webdriver module.exports = class WebDriverTodoList { constructor(baseUrl) { this._baseUrl = baseUrl this._driver = this.buildDriver(new webdriver.Builder()).build() } buildDriver(builder) { return builder.forBrowser('firefox') } async start() { await this._driver.get(this._baseUrl + '/') const todoListLocator = By.css('[aria-label="Todos"] ol') await this._driver.wait(until.elementLocated(todoListLocator), 10000) } async addTodo({ text }) { const textField = this._driver.findElement(By.css('[aria-label="New Todo Text"]')) await textField.sendKeys(text) const count = (await this.getTodos()).length await this._driver.findElement(By.css('[aria-label="Add Todo"]')).click() await this._driver.wait(async () => (await this.getTodos()).length > count, 10000) } async getTodos() { return this._driver.executeScript(() => [...document.querySelectorAll('[aria-label="Todos"] li label')].map(label => ({ text: label.innerText })) ) } async stop() { await this._driver.quit() } }