├── .eslintrc ├── .github └── workflows │ ├── add-issue-to-triage-board.yml │ ├── main.yml │ └── triage-closed-issue-comment.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── circle.yml ├── cypress.config.ts ├── cypress ├── e2e │ └── app.cy.js ├── fixtures │ └── example.json └── support │ ├── commands.js │ ├── e2e.js │ └── index.d.ts ├── img └── cytype.png ├── index.html ├── js ├── app.tsx ├── constants.ts ├── footer.tsx ├── interfaces.d.ts ├── todoItem.tsx ├── todoModel.ts ├── tsconfig.json └── utils.ts ├── package-lock.json ├── package.json ├── renovate.json ├── serve.js └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:cypress-dev/general", 4 | "plugin:cypress-dev/tests" 5 | ], 6 | "globals": { 7 | "cy": true, 8 | "Cypress": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/add-issue-to-triage-board.yml: -------------------------------------------------------------------------------- 1 | name: 'Add issue/PR to Triage Board' 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | pull_request_target: 7 | types: 8 | - opened 9 | jobs: 10 | add-to-triage-project-board: 11 | # skip in fork 12 | if: github.repository == 'cypress-io/cypress-example-todomvc' 13 | uses: cypress-io/cypress/.github/workflows/triage_add_to_project.yml@develop 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: End-to-end tests 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | cypress-run: 5 | runs-on: ubuntu-22.04 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v4 9 | - name: Run E2E tests 10 | uses: cypress-io/github-action@v6 11 | with: 12 | start: npm run start 13 | wait-on: http://localhost:8888 14 | -------------------------------------------------------------------------------- /.github/workflows/triage-closed-issue-comment.yml: -------------------------------------------------------------------------------- 1 | name: 'Closed Issue Comment' 2 | on: 3 | issue_comment: 4 | types: 5 | - created 6 | jobs: 7 | closed-issue-comment: 8 | # skip in fork 9 | if: github.repository == 'cypress-io/cypress-example-todomvc' 10 | uses: cypress-io/cypress/.github/workflows/triage_closed_issue_comment.yml@develop 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | cypress/videos 29 | cypress/screenshots 30 | js/*.js -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.13.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "standard.enable": false, 3 | "eslint.enable": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Cypress.io, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TodoMVC [![Circle CI](https://circleci.com/gh/cypress-io/cypress-example-todomvc.svg?style=svg)](https://circleci.com/gh/cypress-io/cypress-example-todomvc) 2 | 3 | 4 | This repo contains an example React App, with the tests written in Cypress and running tests in Circle CI. 5 | 6 | The tests are written to be directly compared to the official TodoMVC tests. Each test covers the same functionality found in the official TodoMVC tests but utilizes the Cypress API. 7 | 8 | The [tests are heavily commented](cypress/e2e/app.cy.js) to ease you into the Cypress API. 9 | 10 | [You can find the official TodoMVC tests we are comparing to here.](https://github.com/tastejs/todomvc/blob/master/tests/test.js) [And here.](https://github.com/tastejs/todomvc/blob/master/tests/page.js) [And here.](https://github.com/tastejs/todomvc/blob/master/tests/testOperations.js) 11 | 12 | ## Help + Testing 13 | 14 | The steps below will take you all the way through Cypress. It is assumed you have nothing installed except for node + git. 15 | 16 | **If you get stuck, here is more help:** 17 | 18 | * [Gitter Channel](https://gitter.im/cypress-io/cypress) 19 | * [Cypress Docs](https://on.cypress.io) 20 | * [Cypress CLI Tool Docs](https://github.com/cypress-io/cypress-cli) 21 | 22 | ### 1. Install Cypress 23 | 24 | [Follow these instructions to install Cypress.](https://on.cypress.io/guides/installing-and-running#section-installing) 25 | 26 | ### 2. Fork this repo 27 | 28 | If you want to experiment with running this project in Continous Integration, you'll need to [fork](https://github.com/cypress-io/cypress-example-todomvc#fork-destination-box) it first. 29 | 30 | After forking this project in `Github`, run these commands: 31 | 32 | ```bash 33 | ## clone this repo to a local directory 34 | git clone https://github.com//cypress-example-todomvc.git 35 | 36 | ## cd into the cloned repo 37 | cd cypress-example-todomvc 38 | 39 | ## install the node_modules 40 | npm install 41 | 42 | ## start the local webserver 43 | npm start 44 | ``` 45 | 46 | The `npm start` script will spawn a webserver on port `8888` which hosts the TodoMVC app. 47 | 48 | You can verify this by opening your browser and navigating to: [`http://localhost:8888`](http://localhost:8888) 49 | 50 | You should see the TodoMVC app up and running. We are now ready to run Cypress tests. 51 | 52 | ### 3. Add the project to Cypress 53 | 54 | [Follow these instructions to add the project to Cypress.](https://on.cypress.io/guides/getting-started/installing-cypress#Installing) 55 | 56 | ### 4. Run in Continuous Integration 57 | 58 | [Follow these instructions to run the tests in CI.](https://on.cypress.io/guides/continuous-integration#section-running-in-ci) 59 | 60 | ## Cypress IntelliSense 61 | 62 | If you use modern IDE that supports TypeScript (like VSCode), you can benefit 63 | from Cypress type declarations included with the `cypress` NPM module. Just 64 | add `@ts-check` to the spec file and configure "dummy" 65 | [tsconfig.json](tsconfig.json) file and see IntelliSense over `cy.` 66 | commands. 67 | 68 | ![cy.type IntelliSense](img/cytype.png) 69 | 70 | ### Custom commands 71 | 72 | This project also adds several custom commands in [cypress/support/commands.js](cypress/support/commands.js). They are useful to create one or more default todos from the tests. 73 | 74 | ```js 75 | it('should append new items to the bottom of the list', function () { 76 | cy.createDefaultTodos().as('todos') 77 | // more test commands 78 | }) 79 | ``` 80 | 81 | To let TypeScript compiler know that we have added a custom command and have IntelliSense working, I have described the type signature of the custom command in file [cypress/support/index.d.ts](cypress/support/index.d.ts). Here is how this file looks; the type signatures should match the arguments custom commands expect. 82 | 83 | ```typescript 84 | /// 85 | 86 | declare namespace Cypress { 87 | interface Chainable { 88 | /** 89 | * Create several Todo items via UI 90 | * @example 91 | * cy.createDefaultTodos() 92 | */ 93 | createDefaultTodos(): Chainable 94 | /** 95 | * Creates one Todo using UI 96 | * @example 97 | * cy.createTodo('new item') 98 | */ 99 | createTodo(title: string): Chainable 100 | } 101 | } 102 | ``` 103 | 104 | To include the new ".d.ts" file into IntelliSense, I could update `tsconfig.json` or I could add another special comment to the JavaScript spec files - `/// 109 | 110 | // type definitions for custom commands like "createDefaultTodos" 111 | // will resolve to "cypress/support/index.d.ts" 112 | /// 113 | ``` 114 | 115 | **Related:** [IntelliSense for custom Chai assertions added to Cypress](https://github.com/cypress-io/cypress-example-recipes/tree/master/examples/extending-cypress__chai-assertions#code-completion) 116 | 117 | ## Support 118 | 119 | If you find errors in the type documentation, please 120 | [open an issue](https://github.com/cypress-io/cypress/issues) 121 | 122 | You can also ask questions in our [chat channel](https://on.cypress.io/chat) 123 | 124 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 125 | [renovate-app]: https://renovateapp.com/ 126 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # run Cypress tests using CircleCI Cypress orb 2 | # https://github.com/cypress-io/circleci-orb 3 | 4 | version: 2.1 5 | orbs: 6 | cypress: cypress-io/cypress@1 7 | # for testing on Windows 8 | win: circleci/windows@5 9 | 10 | executors: 11 | node22-lts: 12 | docker: 13 | - image: cypress/base:22.13.0 14 | mac: 15 | macos: 16 | xcode: "15.3.0" 17 | resource_class: macos.m1.medium.gen1 18 | 19 | jobs: 20 | lint: 21 | executor: node22-lts 22 | steps: 23 | - attach_workspace: 24 | at: ~/ 25 | - run: npm run types 26 | - run: npm run lint 27 | 28 | workflows: 29 | build: 30 | jobs: 31 | - cypress/run: 32 | executor: node22-lts 33 | name: Linux test 34 | record: true 35 | start: npm start 36 | - cypress/run: 37 | name: Mac test 38 | executor: mac 39 | record: true 40 | start: npm start 41 | # no need to save the workspace after this job 42 | no-workspace: true 43 | - lint: 44 | requires: 45 | - Linux test 46 | - cypress/run: 47 | name: Windows test 48 | executor: 49 | name: win/default 50 | shell: bash.exe 51 | record: true 52 | start: npm start 53 | # no need to save the workspace after this job 54 | no-workspace: true 55 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | fixturesFolder: false, 5 | e2e: { 6 | setupNodeEvents(on, config) {}, 7 | baseUrl: 'http://localhost:8888', 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /cypress/e2e/app.cy.js: -------------------------------------------------------------------------------- 1 | // type definitions for Cypress object "cy" 2 | /// 3 | 4 | // type definitions for custom commands like "createDefaultTodos" 5 | /// 6 | 7 | // check this file using TypeScript if available 8 | // @ts-check 9 | 10 | // *********************************************** 11 | // All of these tests are written to implement 12 | // the official TodoMVC tests written for Selenium. 13 | // 14 | // The Cypress tests cover the exact same functionality, 15 | // and match the same test names as TodoMVC. 16 | // Please read our getting started guide 17 | // https://on.cypress.io/introduction-to-cypress 18 | // 19 | // You can find the original TodoMVC tests here: 20 | // https://github.com/tastejs/todomvc/blob/master/tests/test.js 21 | // *********************************************** 22 | 23 | describe('TodoMVC - React', function () { 24 | // setup these constants to match what TodoMVC does 25 | let TODO_ITEM_ONE = 'buy some cheese' 26 | let TODO_ITEM_TWO = 'feed the cat' 27 | let TODO_ITEM_THREE = 'book a doctors appointment' 28 | 29 | beforeEach(function () { 30 | // By default Cypress will automatically 31 | // clear the Local Storage prior to each 32 | // test which ensures no todos carry over 33 | // between tests. 34 | // 35 | // Go out and visit our local web server 36 | // before each test, which serves us the 37 | // TodoMVC App we want to test against 38 | // 39 | // We've set our baseUrl to be http://localhost:8888 40 | // which is automatically prepended to cy.visit 41 | // 42 | // https://on.cypress.io/api/visit 43 | cy.visit('/') 44 | }) 45 | 46 | afterEach(() => { 47 | // In firefox, blur handlers will fire upon navigation if there is an activeElement. 48 | // Since todos are updated on blur after editing, 49 | // this is needed to blur activeElement after each test to prevent state leakage between tests. 50 | cy.window().then((win) => { 51 | // @ts-ignore 52 | win.document.activeElement.blur() 53 | }) 54 | }) 55 | 56 | // a very simple example helpful during presentations 57 | it('adds 2 todos', function () { 58 | cy.get('.new-todo') 59 | .type('learn testing{enter}') 60 | .type('be cool{enter}') 61 | 62 | cy.get('.todo-list li').should('have.length', 2) 63 | }) 64 | 65 | context('When page is initially opened', function () { 66 | it('should focus on the todo input field', function () { 67 | // get the currently focused element and assert 68 | // that it has class='new-todo' 69 | // 70 | // http://on.cypress.io/focused 71 | cy.focused().should('have.class', 'new-todo') 72 | }) 73 | }) 74 | 75 | context('No Todos', function () { 76 | it('should hide #main and #footer', function () { 77 | // Unlike the TodoMVC tests, we don't need to create 78 | // a gazillion helper functions which are difficult to 79 | // parse through. Instead we'll opt to use real selectors 80 | // so as to make our testing intentions as clear as possible. 81 | // 82 | // http://on.cypress.io/get 83 | cy.get('.todo-list li').should('not.exist') 84 | cy.get('.main').should('not.exist') 85 | cy.get('.footer').should('not.exist') 86 | }) 87 | }) 88 | 89 | context('New Todo', function () { 90 | // New commands used here: 91 | // https://on.cypress.io/type 92 | // https://on.cypress.io/eq 93 | // https://on.cypress.io/find 94 | // https://on.cypress.io/contains 95 | // https://on.cypress.io/should 96 | // https://on.cypress.io/as 97 | 98 | it('should allow me to add todo items', function () { 99 | // create 1st todo 100 | cy.get('.new-todo') 101 | .type(TODO_ITEM_ONE) 102 | .type('{enter}') 103 | 104 | // make sure the 1st label contains the 1st todo text 105 | cy.get('.todo-list li') 106 | .eq(0) 107 | .find('label') 108 | .should('contain', TODO_ITEM_ONE) 109 | 110 | // create 2nd todo 111 | cy.get('.new-todo') 112 | .type(TODO_ITEM_TWO) 113 | .type('{enter}') 114 | 115 | // make sure the 2nd label contains the 2nd todo text 116 | cy.get('.todo-list li') 117 | .eq(1) 118 | .find('label') 119 | .should('contain', TODO_ITEM_TWO) 120 | }) 121 | 122 | it('adds items', function () { 123 | // create several todos then check the number of items in the list 124 | cy.get('.new-todo') 125 | .type('todo A{enter}') 126 | .type('todo B{enter}') // we can continue working with same element 127 | .type('todo C{enter}') // and keep adding new items 128 | .type('todo D{enter}') 129 | 130 | cy.get('.todo-list li').should('have.length', 4) 131 | }) 132 | 133 | it('should clear text input field when an item is added', function () { 134 | cy.get('.new-todo') 135 | .type(TODO_ITEM_ONE) 136 | .type('{enter}') 137 | 138 | cy.get('.new-todo').should('have.text', '') 139 | }) 140 | 141 | it('should append new items to the bottom of the list', function () { 142 | // this is an example of a custom command 143 | // defined in cypress/support/commands.js 144 | cy.createDefaultTodos().as('todos') 145 | 146 | // even though the text content is split across 147 | // multiple and elements 148 | // `cy.contains` can verify this correctly 149 | cy.get('.todo-count').contains('3 items left') 150 | 151 | cy.get('@todos') 152 | .eq(0) 153 | .find('label') 154 | .should('contain', TODO_ITEM_ONE) 155 | 156 | cy.get('@todos') 157 | .eq(1) 158 | .find('label') 159 | .should('contain', TODO_ITEM_TWO) 160 | 161 | cy.get('@todos') 162 | .eq(2) 163 | .find('label') 164 | .should('contain', TODO_ITEM_THREE) 165 | }) 166 | 167 | it('should trim text input', function () { 168 | // this is an example of another custom command 169 | // since we repeat the todo creation over and over 170 | // again. It's up to you to decide when to abstract 171 | // repetitive behavior and roll that up into a custom 172 | // command vs explicitly writing the code. 173 | cy.createTodo(` ${TODO_ITEM_ONE} `) 174 | 175 | // we use as explicit assertion here about the text instead of 176 | // using 'contain' so we can specify the exact text of the element 177 | // does not have any whitespace around it 178 | cy.get('.todo-list li') 179 | .eq(0) 180 | .should('have.text', TODO_ITEM_ONE) 181 | }) 182 | 183 | it('should show #main and #footer when items added', function () { 184 | cy.createTodo(TODO_ITEM_ONE) 185 | cy.get('.main').should('be.visible') 186 | cy.get('.footer').should('be.visible') 187 | }) 188 | }) 189 | 190 | context('Mark all as completed', function () { 191 | // New commands used here: 192 | // - cy.check https://on.cypress.io/api/check 193 | // - cy.uncheck https://on.cypress.io/api/uncheck 194 | 195 | beforeEach(function () { 196 | // This is an example of aliasing 197 | // within a hook (beforeEach). 198 | // Aliases will automatically persist 199 | // between hooks and are available 200 | // in your tests below 201 | cy.createDefaultTodos().as('todos') 202 | }) 203 | 204 | it('should allow me to mark all items as completed', function () { 205 | // complete all todos 206 | // we use 'check' instead of 'click' 207 | // because that indicates our intention much clearer 208 | cy.get('.toggle-all').check() 209 | 210 | // get each todo li and ensure its class is 'completed' 211 | cy.get('@todos') 212 | .eq(0) 213 | .should('have.class', 'completed') 214 | 215 | cy.get('@todos') 216 | .eq(1) 217 | .should('have.class', 'completed') 218 | 219 | cy.get('@todos') 220 | .eq(2) 221 | .should('have.class', 'completed') 222 | }) 223 | 224 | it('should allow me to clear the complete state of all items', function () { 225 | // check and then immediately uncheck 226 | cy.get('.toggle-all') 227 | .check() 228 | .uncheck() 229 | 230 | cy.get('@todos') 231 | .eq(0) 232 | .should('not.have.class', 'completed') 233 | 234 | cy.get('@todos') 235 | .eq(1) 236 | .should('not.have.class', 'completed') 237 | 238 | cy.get('@todos') 239 | .eq(2) 240 | .should('not.have.class', 'completed') 241 | }) 242 | 243 | it('complete all checkbox should update state when items are completed / cleared', function () { 244 | // alias the .toggle-all for reuse later 245 | cy.get('.toggle-all') 246 | .as('toggleAll') 247 | .check() 248 | // this assertion is silly here IMO but 249 | // it is what TodoMVC does 250 | .should('be.checked') 251 | 252 | // alias the first todo and then click it 253 | cy.get('.todo-list li') 254 | .eq(0) 255 | .as('firstTodo') 256 | .find('.toggle') 257 | .uncheck() 258 | 259 | // reference the .toggle-all element again 260 | // and make sure its not checked 261 | cy.get('@toggleAll').should('not.be.checked') 262 | 263 | // reference the first todo again and now toggle it 264 | cy.get('@firstTodo') 265 | .find('.toggle') 266 | .check() 267 | 268 | // assert the toggle all is checked again 269 | cy.get('@toggleAll').should('be.checked') 270 | }) 271 | }) 272 | 273 | context('Item', function () { 274 | // New commands used here: 275 | // - cy.clear https://on.cypress.io/api/clear 276 | 277 | it('should allow me to mark items as complete', function () { 278 | // we are aliasing the return value of 279 | // our custom command 'createTodo' 280 | // 281 | // the return value is the
  • in the 282 | cy.createTodo(TODO_ITEM_ONE).as('firstTodo') 283 | cy.createTodo(TODO_ITEM_TWO).as('secondTodo') 284 | 285 | cy.get('@firstTodo') 286 | .find('.toggle') 287 | .check() 288 | 289 | cy.get('@firstTodo').should('have.class', 'completed') 290 | 291 | cy.get('@secondTodo').should('not.have.class', 'completed') 292 | cy.get('@secondTodo') 293 | .find('.toggle') 294 | .check() 295 | 296 | cy.get('@firstTodo').should('have.class', 'completed') 297 | cy.get('@secondTodo').should('have.class', 'completed') 298 | }) 299 | 300 | it('should allow me to un-mark items as complete', function () { 301 | cy.createTodo(TODO_ITEM_ONE).as('firstTodo') 302 | cy.createTodo(TODO_ITEM_TWO).as('secondTodo') 303 | 304 | cy.get('@firstTodo') 305 | .find('.toggle') 306 | .check() 307 | 308 | cy.get('@firstTodo').should('have.class', 'completed') 309 | cy.get('@secondTodo').should('not.have.class', 'completed') 310 | 311 | cy.get('@firstTodo') 312 | .find('.toggle') 313 | .uncheck() 314 | 315 | cy.get('@firstTodo').should('not.have.class', 'completed') 316 | cy.get('@secondTodo').should('not.have.class', 'completed') 317 | }) 318 | 319 | it('should allow me to edit an item', function () { 320 | cy.createDefaultTodos().as('todos') 321 | 322 | cy.get('@todos') 323 | .eq(1) 324 | .as('secondTodo') 325 | // TODO: fix this, dblclick should 326 | // have been issued to label 327 | .find('label') 328 | .dblclick() 329 | 330 | // clear out the inputs current value 331 | // and type a new value 332 | cy.get('@secondTodo') 333 | .find('.edit') 334 | .clear() 335 | .type('buy some sausages') 336 | .type('{enter}') 337 | 338 | // explicitly assert about the text value 339 | cy.get('@todos') 340 | .eq(0) 341 | .should('contain', TODO_ITEM_ONE) 342 | 343 | cy.get('@secondTodo').should('contain', 'buy some sausages') 344 | cy.get('@todos') 345 | .eq(2) 346 | .should('contain', TODO_ITEM_THREE) 347 | }) 348 | }) 349 | 350 | context('Editing', function () { 351 | // New commands used here: 352 | // - cy.blur https://on.cypress.io/api/blur 353 | 354 | beforeEach(function () { 355 | cy.createDefaultTodos().as('todos') 356 | }) 357 | 358 | it('should hide other controls when editing', function () { 359 | cy.get('@todos') 360 | .eq(1) 361 | .as('secondTodo') 362 | .find('label') 363 | .dblclick() 364 | 365 | cy.get('@secondTodo') 366 | .find('.toggle') 367 | .should('not.be.visible') 368 | 369 | cy.get('@secondTodo') 370 | .find('label') 371 | .should('not.be.visible') 372 | }) 373 | 374 | it('should save edits on blur', function () { 375 | cy.get('@todos') 376 | .eq(1) 377 | .as('secondTodo') 378 | .find('label') 379 | .dblclick() 380 | 381 | cy.get('@secondTodo') 382 | .find('.edit') 383 | .clear() 384 | .type('buy some sausages') 385 | // we can just send the blur event directly 386 | // to the input instead of having to click 387 | // on another button on the page. though you 388 | // could do that its just more mental work 389 | .blur() 390 | 391 | cy.get('@todos') 392 | .eq(0) 393 | .should('contain', TODO_ITEM_ONE) 394 | 395 | cy.get('@secondTodo').should('contain', 'buy some sausages') 396 | cy.get('@todos') 397 | .eq(2) 398 | .should('contain', TODO_ITEM_THREE) 399 | }) 400 | 401 | it('should trim entered text', function () { 402 | cy.get('@todos') 403 | .eq(1) 404 | .as('secondTodo') 405 | .find('label') 406 | .dblclick() 407 | 408 | cy.get('@secondTodo') 409 | .find('.edit') 410 | .clear() 411 | .type(' buy some sausages ') 412 | .type('{enter}') 413 | 414 | cy.get('@todos') 415 | .eq(0) 416 | .should('contain', TODO_ITEM_ONE) 417 | 418 | cy.get('@secondTodo').should('contain', 'buy some sausages') 419 | cy.get('@todos') 420 | .eq(2) 421 | .should('contain', TODO_ITEM_THREE) 422 | }) 423 | 424 | it('should remove the item if an empty text string was entered', function () { 425 | cy.get('@todos') 426 | .eq(1) 427 | .as('secondTodo') 428 | .find('label') 429 | .dblclick() 430 | 431 | cy.get('@secondTodo') 432 | .find('.edit') 433 | .clear() 434 | .type('{enter}') 435 | 436 | cy.get('@todos').should('have.length', 2) 437 | }) 438 | 439 | it('should cancel edits on escape', function () { 440 | cy.get('@todos') 441 | .eq(1) 442 | .as('secondTodo') 443 | .find('label') 444 | .dblclick() 445 | 446 | cy.get('@secondTodo') 447 | .find('.edit') 448 | .clear() 449 | .type('foo{esc}') 450 | 451 | cy.get('@todos') 452 | .eq(0) 453 | .should('contain', TODO_ITEM_ONE) 454 | 455 | cy.get('@todos') 456 | .eq(1) 457 | .should('contain', TODO_ITEM_TWO) 458 | 459 | cy.get('@todos') 460 | .eq(2) 461 | .should('contain', TODO_ITEM_THREE) 462 | }) 463 | }) 464 | 465 | context('Counter', function () { 466 | it('should display the current number of todo items', function () { 467 | cy.createTodo(TODO_ITEM_ONE) 468 | cy.get('.todo-count').contains('1 item left') 469 | cy.createTodo(TODO_ITEM_TWO) 470 | cy.get('.todo-count').contains('2 items left') 471 | }) 472 | }) 473 | 474 | context('Clear completed button', function () { 475 | beforeEach(function () { 476 | cy.createDefaultTodos().as('todos') 477 | }) 478 | 479 | it('should display the correct text', function () { 480 | cy.get('@todos') 481 | .eq(0) 482 | .find('.toggle') 483 | .check() 484 | 485 | cy.get('.clear-completed').contains('Clear completed') 486 | }) 487 | 488 | it('should remove completed items when clicked', function () { 489 | cy.get('@todos') 490 | .eq(1) 491 | .find('.toggle') 492 | .check() 493 | 494 | cy.get('.clear-completed').click() 495 | cy.get('@todos').should('have.length', 2) 496 | cy.get('@todos') 497 | .eq(0) 498 | .should('contain', TODO_ITEM_ONE) 499 | 500 | cy.get('@todos') 501 | .eq(1) 502 | .should('contain', TODO_ITEM_THREE) 503 | }) 504 | 505 | it('should be hidden when there are no items that are completed', function () { 506 | cy.get('@todos') 507 | .eq(1) 508 | .find('.toggle') 509 | .check() 510 | 511 | cy.get('.clear-completed') 512 | .should('be.visible') 513 | .click() 514 | 515 | cy.get('.clear-completed').should('not.exist') 516 | }) 517 | }) 518 | 519 | context('Persistence', function () { 520 | it('should persist its data', function () { 521 | // mimicking TodoMVC tests 522 | // by writing out this function 523 | function testState () { 524 | cy.get('@firstTodo') 525 | .should('contain', TODO_ITEM_ONE) 526 | .and('have.class', 'completed') 527 | 528 | cy.get('@secondTodo') 529 | .should('contain', TODO_ITEM_TWO) 530 | .and('not.have.class', 'completed') 531 | } 532 | 533 | cy.createTodo(TODO_ITEM_ONE).as('firstTodo') 534 | cy.createTodo(TODO_ITEM_TWO).as('secondTodo') 535 | cy.get('@firstTodo') 536 | .find('.toggle') 537 | .check() 538 | .then(testState) 539 | 540 | .reload() 541 | .then(testState) 542 | }) 543 | }) 544 | 545 | context('Routing', function () { 546 | // New commands used here: 547 | // https://on.cypress.io/window 548 | // https://on.cypress.io/its 549 | // https://on.cypress.io/invoke 550 | // https://on.cypress.io/within 551 | 552 | beforeEach(function () { 553 | cy.createDefaultTodos().as('todos') 554 | }) 555 | 556 | it('should allow me to display active items', function () { 557 | cy.get('@todos') 558 | .eq(1) 559 | .find('.toggle') 560 | .check() 561 | 562 | cy.get('.filters') 563 | .contains('Active') 564 | .click() 565 | 566 | cy.get('@todos') 567 | .eq(0) 568 | .should('contain', TODO_ITEM_ONE) 569 | 570 | cy.get('@todos') 571 | .eq(1) 572 | .should('contain', TODO_ITEM_THREE) 573 | }) 574 | 575 | it('should respect the back button', function () { 576 | cy.get('@todos') 577 | .eq(1) 578 | .find('.toggle') 579 | .check() 580 | 581 | cy.get('.filters') 582 | .contains('Active') 583 | .click() 584 | 585 | cy.get('.filters') 586 | .contains('Completed') 587 | .click() 588 | 589 | cy.get('@todos').should('have.length', 1) 590 | cy.go('back') 591 | cy.get('@todos').should('have.length', 2) 592 | cy.go('back') 593 | cy.get('@todos').should('have.length', 3) 594 | }) 595 | 596 | it('should allow me to display completed items', function () { 597 | cy.get('@todos') 598 | .eq(1) 599 | .find('.toggle') 600 | .check() 601 | 602 | cy.get('.filters') 603 | .contains('Completed') 604 | .click() 605 | 606 | cy.get('@todos').should('have.length', 1) 607 | }) 608 | 609 | it('should allow me to display all items', function () { 610 | cy.get('@todos') 611 | .eq(1) 612 | .find('.toggle') 613 | .check() 614 | 615 | cy.get('.filters') 616 | .contains('Active') 617 | .click() 618 | 619 | cy.get('.filters') 620 | .contains('Completed') 621 | .click() 622 | 623 | cy.get('.filters') 624 | .contains('All') 625 | .click() 626 | 627 | cy.get('@todos').should('have.length', 3) 628 | }) 629 | 630 | it('should highlight the currently applied filter', function () { 631 | // using a within here which will automatically scope 632 | // nested 'cy' queries to our parent element 633 | cy.get('.filters').within(function () { 634 | cy.contains('All').should('have.class', 'selected') 635 | cy.contains('Active') 636 | .click() 637 | .should('have.class', 'selected') 638 | 639 | cy.contains('Completed') 640 | .click() 641 | .should('have.class', 'selected') 642 | }) 643 | }) 644 | }) 645 | 646 | context('Contrast', () => { 647 | it('has good contrast when empty', () => { 648 | cy.addAxeCode() 649 | cy.checkA11y(null, { 650 | runOnly: ['cat.color'], 651 | }) 652 | }) 653 | 654 | it('has good contrast with several todos', () => { 655 | cy.addAxeCode() 656 | cy.get('.new-todo') 657 | .type('learn testing{enter}') 658 | .type('be cool{enter}') 659 | 660 | cy.get('.todo-list li').should('have.length', 2) 661 | cy.checkA11y(null, { 662 | runOnly: ['cat.color'], 663 | }) 664 | 665 | // and after marking an item completed 666 | cy.get('.todo-list li') 667 | .first() 668 | .find('.toggle') 669 | .check() 670 | 671 | cy.get('.todo-list li') 672 | .first() 673 | .should('have.class', 'completed') 674 | 675 | cy.checkA11y(null, { 676 | runOnly: ['cat.color'], 677 | }) 678 | }) 679 | }) 680 | }) 681 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "example": "fixture" 3 | } -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create the custom commands: 'createDefaultTodos' 4 | // and 'createTodo'. 5 | // 6 | // The commands.js file is a great place to 7 | // modify existing commands and create custom 8 | // commands for use throughout your tests. 9 | // 10 | // You can read more about custom commands here: 11 | // https://on.cypress.io/custom-commands 12 | // *********************************************** 13 | 14 | Cypress.Commands.add('createDefaultTodos', function () { 15 | 16 | let TODO_ITEM_ONE = 'buy some cheese' 17 | let TODO_ITEM_TWO = 'feed the cat' 18 | let TODO_ITEM_THREE = 'book a doctors appointment' 19 | 20 | // begin the command here, which by will display 21 | // as a 'spinning blue state' in the UI to indicate 22 | // the command is running 23 | let cmd = Cypress.log({ 24 | name: 'create default todos', 25 | message: [], 26 | consoleProps () { 27 | // we're creating our own custom message here 28 | // which will print out to our browsers console 29 | // whenever we click on this command 30 | return { 31 | 'Inserted Todos': [TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE], 32 | } 33 | }, 34 | }) 35 | 36 | // additionally we pass {log: false} to all of our 37 | // sub-commands so none of them will output to 38 | // our command log 39 | 40 | cy.get('.new-todo', { log: false }) 41 | .type(`${TODO_ITEM_ONE}{enter}`, { log: false }) 42 | .type(`${TODO_ITEM_TWO}{enter}`, { log: false }) 43 | .type(`${TODO_ITEM_THREE}{enter}`, { log: false }) 44 | 45 | cy.get('.todo-list li', { log: false }) 46 | .then(function ($listItems) { 47 | // once we're done inserting each of the todos 48 | // above we want to return the .todo-list li's 49 | // to allow for further chaining and then 50 | // we want to snapshot the state of the DOM 51 | // and end the command so it goes from that 52 | // 'spinning blue state' to the 'finished state' 53 | cmd.set({ $el: $listItems }).snapshot().end() 54 | }) 55 | 56 | // return a query for the todo items so that we can 57 | // alias the result of this command in our tests 58 | return cy.get('.todo-list li', { log: false }) 59 | }) 60 | 61 | Cypress.Commands.add('createTodo', function (todo) { 62 | 63 | let cmd = Cypress.log({ 64 | name: 'create todo', 65 | message: todo, 66 | consoleProps () { 67 | return { 68 | 'Inserted Todo': todo, 69 | } 70 | }, 71 | }) 72 | 73 | // create the todo 74 | cy.get('.new-todo', { log: false }).type(`${todo}{enter}`, { log: false }) 75 | 76 | // now go find the actual todo 77 | // in the todo list so we can 78 | // easily alias this in our tests 79 | // and set the $el so its highlighted 80 | cy.get('.todo-list', { log: false }) 81 | .contains('li', todo.trim(), { log: false }) 82 | .then(function ($li) { 83 | // set the $el for the command so 84 | // it highlights when we hover over 85 | // our command 86 | cmd.set({ $el: $li }).snapshot().end() 87 | }) 88 | 89 | // return a query for the todo items so that we can 90 | // alias the result of this command in our tests 91 | return cy.get('.todo-list', { log: false }) 92 | .contains('li', todo.trim(), { log: false }) 93 | }) 94 | 95 | Cypress.Commands.add('addAxeCode', () => { 96 | cy.window({ log: false }).then((win) => { 97 | return new Promise((resolve) => { 98 | const script = win.document.createElement('script') 99 | 100 | script.src = '/node_modules/axe-core/axe.min.js' 101 | script.addEventListener('load', resolve) 102 | 103 | win.document.head.appendChild(script) 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your other test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/guides/configuration#section-global 14 | // *********************************************************** 15 | 16 | // Import commands.js and defaults.js 17 | // using ES2015 syntax: 18 | // import "./commands" 19 | // import "./defaults" 20 | 21 | // Alternatively you can use CommonJS syntax: 22 | require('./commands') 23 | require('cypress-axe') 24 | -------------------------------------------------------------------------------- /cypress/support/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | interface Chainable { 5 | /** 6 | * Create several Todo items via UI 7 | * @example 8 | * cy.createDefaultTodos() 9 | */ 10 | createDefaultTodos(): Chainable 11 | /** 12 | * Creates one Todo using UI 13 | * @example 14 | * cy.createTodo('new item') 15 | */ 16 | createTodo(title: string): Chainable 17 | 18 | /** 19 | * Command that injects Axe core library into app html. 20 | * @example 21 | * cy.visit('/') 22 | * cy.v() 23 | */ 24 | addAxeCode(): Chainable 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /img/cytype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/cypress-example-todomvc/6772943c95c7c428db11f752e944d2910ddd0f6e/img/cytype.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React • TodoMVC 6 | 7 | 8 | 9 | 10 |
    11 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /js/app.tsx: -------------------------------------------------------------------------------- 1 | /*jshint quotmark:false */ 2 | /*jshint white:false */ 3 | /*jshint trailing:false */ 4 | /*jshint newcap:false */ 5 | /*global React, Router*/ 6 | 7 | /// 8 | 9 | declare var Router; 10 | import * as React from "react"; 11 | import * as ReactDOM from "react-dom"; 12 | import { TodoModel } from "./todoModel"; 13 | import { TodoFooter } from "./footer"; 14 | import { TodoItem } from "./todoItem"; 15 | import { ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS, ENTER_KEY } from "./constants"; 16 | 17 | class TodoApp extends React.Component { 18 | 19 | public state : IAppState; 20 | 21 | constructor(props : IAppProps) { 22 | super(props); 23 | this.state = { 24 | nowShowing: ALL_TODOS, 25 | editing: null 26 | }; 27 | } 28 | 29 | public componentDidMount() { 30 | var setState = this.setState; 31 | var router = Router({ 32 | '/': setState.bind(this, {nowShowing: ALL_TODOS}), 33 | '/active': setState.bind(this, {nowShowing: ACTIVE_TODOS}), 34 | '/completed': setState.bind(this, {nowShowing: COMPLETED_TODOS}) 35 | }); 36 | router.init('/'); 37 | } 38 | 39 | public handleNewTodoKeyDown(event : React.KeyboardEvent) { 40 | if (event.keyCode !== ENTER_KEY) { 41 | return; 42 | } 43 | 44 | event.preventDefault(); 45 | 46 | var val = (ReactDOM.findDOMNode(this.refs["newField"]) as HTMLInputElement).value.trim(); 47 | 48 | if (val) { 49 | this.props.model.addTodo(val); 50 | (ReactDOM.findDOMNode(this.refs["newField"]) as HTMLInputElement).value = ''; 51 | } 52 | } 53 | 54 | public toggleAll(event : React.FormEvent) { 55 | var target : any = event.target; 56 | var checked = target.checked; 57 | this.props.model.toggleAll(checked); 58 | } 59 | 60 | public toggle(todoToToggle : ITodo) { 61 | this.props.model.toggle(todoToToggle); 62 | } 63 | 64 | public destroy(todo : ITodo) { 65 | this.props.model.destroy(todo); 66 | } 67 | 68 | public edit(todo : ITodo) { 69 | this.setState({editing: todo.id}); 70 | } 71 | 72 | public save(todoToSave : ITodo, text : String) { 73 | this.props.model.save(todoToSave, text); 74 | this.setState({editing: null}); 75 | } 76 | 77 | public cancel() { 78 | this.setState({editing: null}); 79 | } 80 | 81 | public clearCompleted() { 82 | this.props.model.clearCompleted(); 83 | } 84 | 85 | public render() { 86 | var footer; 87 | var main; 88 | const todos = this.props.model.todos; 89 | 90 | var shownTodos = todos.filter((todo) => { 91 | switch (this.state.nowShowing) { 92 | case ACTIVE_TODOS: 93 | return !todo.completed; 94 | case COMPLETED_TODOS: 95 | return todo.completed; 96 | default: 97 | return true; 98 | } 99 | }); 100 | 101 | var todoItems = shownTodos.map((todo) => { 102 | return ( 103 | this.cancel() } 112 | /> 113 | ); 114 | }); 115 | 116 | // Note: It's usually better to use immutable data structures since they're 117 | // easier to reason about and React works very well with them. That's why 118 | // we use map(), filter() and reduce() everywhere instead of mutating the 119 | // array or todo items themselves. 120 | var activeTodoCount = todos.reduce(function (accum, todo) { 121 | return todo.completed ? accum : accum + 1; 122 | }, 0); 123 | 124 | var completedCount = todos.length - activeTodoCount; 125 | 126 | if (activeTodoCount || completedCount) { 127 | footer = 128 | this.clearCompleted() } 133 | />; 134 | } 135 | 136 | if (todos.length) { 137 | main = ( 138 |
    139 | this.toggleAll(e) } 144 | checked={activeTodoCount === 0} 145 | /> 146 | 151 |
      152 | {todoItems} 153 |
    154 |
    155 | ); 156 | } 157 | 158 | return ( 159 |
    160 |
    161 |

    todos

    162 | this.handleNewTodoKeyDown(e) } 167 | autoFocus={true} 168 | /> 169 |
    170 | {main} 171 | {footer} 172 |
    173 | ); 174 | } 175 | } 176 | 177 | var model = new TodoModel('react-todos'); 178 | 179 | function render() { 180 | ReactDOM.render( 181 | , 182 | document.getElementsByClassName('todoapp')[0] 183 | ); 184 | } 185 | 186 | model.subscribe(render); 187 | render(); 188 | -------------------------------------------------------------------------------- /js/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | const ALL_TODOS = 'all'; 3 | const ACTIVE_TODOS = 'active'; 4 | const COMPLETED_TODOS = 'completed'; 5 | const ENTER_KEY = 13; 6 | const ESCAPE_KEY = 27; 7 | 8 | export { ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS, ENTER_KEY, ESCAPE_KEY }; 9 | -------------------------------------------------------------------------------- /js/footer.tsx: -------------------------------------------------------------------------------- 1 | /*jshint quotmark:false */ 2 | /*jshint white:false */ 3 | /*jshint trailing:false */ 4 | /*jshint newcap:false */ 5 | /*global React */ 6 | 7 | /// 8 | 9 | import * as classNames from "classnames"; 10 | import * as React from "react"; 11 | import { ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS } from "./constants"; 12 | import { Utils } from "./utils"; 13 | 14 | class TodoFooter extends React.Component { 15 | 16 | public render() { 17 | var activeTodoWord = Utils.pluralize(this.props.count, 'item'); 18 | var clearButton = null; 19 | 20 | if (this.props.completedCount > 0) { 21 | clearButton = ( 22 | 27 | ); 28 | } 29 | 30 | const nowShowing = this.props.nowShowing; 31 | return ( 32 | 63 | ); 64 | } 65 | } 66 | 67 | export { TodoFooter }; 68 | -------------------------------------------------------------------------------- /js/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | interface ITodo { 2 | id: string, 3 | title: string, 4 | completed: boolean 5 | } 6 | 7 | interface ITodoItemProps { 8 | key : string, 9 | todo : ITodo; 10 | editing? : boolean; 11 | onSave: (val: any) => void; 12 | onDestroy: () => void; 13 | onEdit: () => void; 14 | onCancel: (event : any) => void; 15 | onToggle: () => void; 16 | } 17 | 18 | interface ITodoItemState { 19 | editText : string 20 | } 21 | 22 | interface ITodoFooterProps { 23 | completedCount : number; 24 | onClearCompleted : any; 25 | nowShowing : string; 26 | count : number; 27 | } 28 | 29 | 30 | interface ITodoModel { 31 | key : any; 32 | todos : Array; 33 | onChanges : Array; 34 | subscribe(onChange); 35 | inform(); 36 | addTodo(title : string); 37 | toggleAll(checked); 38 | toggle(todoToToggle); 39 | destroy(todo); 40 | save(todoToSave, text); 41 | clearCompleted(); 42 | } 43 | 44 | interface IAppProps { 45 | model : ITodoModel; 46 | } 47 | 48 | interface IAppState { 49 | editing? : string; 50 | nowShowing? : string 51 | } 52 | -------------------------------------------------------------------------------- /js/todoItem.tsx: -------------------------------------------------------------------------------- 1 | /*jshint quotmark: false */ 2 | /*jshint white: false */ 3 | /*jshint trailing: false */ 4 | /*jshint newcap: false */ 5 | /*global React */ 6 | 7 | /// 8 | 9 | import * as classNames from "classnames"; 10 | import * as React from "react"; 11 | import * as ReactDOM from "react-dom"; 12 | import { ENTER_KEY, ESCAPE_KEY } from "./constants"; 13 | 14 | class TodoItem extends React.Component { 15 | 16 | public state : ITodoItemState; 17 | 18 | constructor(props : ITodoItemProps){ 19 | super(props); 20 | this.state = { editText: this.props.todo.title }; 21 | } 22 | 23 | public handleSubmit(event : React.FormEvent) { 24 | var val = this.state.editText.trim(); 25 | if (val) { 26 | this.props.onSave(val); 27 | this.setState({editText: val}); 28 | } else { 29 | this.props.onDestroy(); 30 | } 31 | } 32 | 33 | public handleEdit() { 34 | this.props.onEdit(); 35 | this.setState({editText: this.props.todo.title}); 36 | } 37 | 38 | public handleKeyDown(event : React.KeyboardEvent) { 39 | if (event.keyCode === ESCAPE_KEY) { 40 | this.setState({editText: this.props.todo.title}); 41 | this.props.onCancel(event); 42 | } else if (event.keyCode === ENTER_KEY) { 43 | this.handleSubmit(event); 44 | } 45 | } 46 | 47 | public handleChange(event : React.FormEvent) { 48 | var input : any = event.target; 49 | this.setState({ editText : input.value }); 50 | } 51 | 52 | /** 53 | * This is a completely optional performance enhancement that you can 54 | * implement on any React component. If you were to delete this method 55 | * the app would still work correctly (and still be very performant!), we 56 | * just use it as an example of how little code it takes to get an order 57 | * of magnitude performance improvement. 58 | */ 59 | public shouldComponentUpdate(nextProps : ITodoItemProps, nextState : ITodoItemState) { 60 | return ( 61 | nextProps.todo !== this.props.todo || 62 | nextProps.editing !== this.props.editing || 63 | nextState.editText !== this.state.editText 64 | ); 65 | } 66 | 67 | /** 68 | * Safely manipulate the DOM after updating the state when invoking 69 | * `this.props.onEdit()` in the `handleEdit` method above. 70 | * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate 71 | * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate 72 | */ 73 | public componentDidUpdate(prevProps : ITodoItemProps) { 74 | if (!prevProps.editing && this.props.editing) { 75 | var node = (ReactDOM.findDOMNode(this.refs["editField"]) as HTMLInputElement); 76 | node.focus(); 77 | node.setSelectionRange(node.value.length, node.value.length); 78 | } 79 | } 80 | 81 | public render() { 82 | return ( 83 |
  • 87 |
    88 | 94 | 97 |
    99 | this.handleSubmit(e) } 104 | onChange={ e => this.handleChange(e) } 105 | onKeyDown={ e => this.handleKeyDown(e) } 106 | /> 107 |
  • 108 | ); 109 | } 110 | } 111 | 112 | export { TodoItem }; 113 | -------------------------------------------------------------------------------- /js/todoModel.ts: -------------------------------------------------------------------------------- 1 | /*jshint quotmark:false */ 2 | /*jshint white:false */ 3 | /*jshint trailing:false */ 4 | /*jshint newcap:false */ 5 | 6 | /// 7 | 8 | import { Utils } from "./utils"; 9 | 10 | // Generic "model" object. You can use whatever 11 | // framework you want. For this application it 12 | // may not even be worth separating this logic 13 | // out, but we do this to demonstrate one way to 14 | // separate out parts of your application. 15 | class TodoModel implements ITodoModel { 16 | 17 | public key : string; 18 | public todos : Array; 19 | public onChanges : Array; 20 | 21 | constructor(key) { 22 | this.key = key; 23 | this.todos = Utils.store(key); 24 | this.onChanges = []; 25 | } 26 | 27 | public subscribe(onChange) { 28 | this.onChanges.push(onChange); 29 | } 30 | 31 | public inform() { 32 | Utils.store(this.key, this.todos); 33 | this.onChanges.forEach(function (cb) { cb(); }); 34 | } 35 | 36 | public addTodo(title : string) { 37 | this.todos = this.todos.concat({ 38 | id: Utils.uuid(), 39 | title: title, 40 | completed: false 41 | }); 42 | 43 | this.inform(); 44 | } 45 | 46 | public toggleAll(checked : Boolean) { 47 | // Note: It's usually better to use immutable data structures since they're 48 | // easier to reason about and React works very well with them. That's why 49 | // we use map(), filter() and reduce() everywhere instead of mutating the 50 | // array or todo items themselves. 51 | this.todos = this.todos.map((todo : ITodo) => { 52 | return Utils.extend({}, todo, {completed: checked}); 53 | }); 54 | 55 | this.inform(); 56 | } 57 | 58 | public toggle(todoToToggle : ITodo) { 59 | this.todos = this.todos.map((todo : ITodo) => { 60 | return todo !== todoToToggle ? 61 | todo : 62 | Utils.extend({}, todo, {completed: !todo.completed}); 63 | }); 64 | 65 | this.inform(); 66 | } 67 | 68 | public destroy(todo : ITodo) { 69 | this.todos = this.todos.filter(function (candidate) { 70 | return candidate !== todo; 71 | }); 72 | 73 | this.inform(); 74 | } 75 | 76 | public save(todoToSave : ITodo, text : string) { 77 | this.todos = this.todos.map(function (todo) { 78 | return todo !== todoToSave ? todo : Utils.extend({}, todo, {title: text}); 79 | }); 80 | 81 | this.inform(); 82 | } 83 | 84 | public clearCompleted() { 85 | this.todos = this.todos.filter(function (todo) { 86 | return !todo.completed; 87 | }); 88 | 89 | this.inform(); 90 | } 91 | } 92 | 93 | export { TodoModel }; 94 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "isolatedModules": false, 7 | "jsx": "react", 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true, 10 | "declaration": false, 11 | "noImplicitAny": false, 12 | "removeComments": true, 13 | "noLib": false, 14 | "preserveConstEnums": true 15 | }, 16 | "filesGlob": [ 17 | "**/*.ts", 18 | "**/*.tsx", 19 | "!node_modules/**" 20 | ], 21 | "files": [ 22 | "constants.ts", 23 | "interfaces.d.ts", 24 | "todoModel.ts", 25 | "utils.ts", 26 | "app.tsx", 27 | "footer.tsx", 28 | "todoItem.tsx" 29 | ], 30 | "exclude": [], 31 | "baseUrl": "types", 32 | "typeRoots": ["types"] 33 | } 34 | -------------------------------------------------------------------------------- /js/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | class Utils { 3 | 4 | public static uuid() : string { 5 | /*jshint bitwise:false */ 6 | var i, random; 7 | var uuid = ''; 8 | 9 | for (i = 0; i < 32; i++) { 10 | random = Math.random() * 16 | 0; 11 | if (i === 8 || i === 12 || i === 16 || i === 20) { 12 | uuid += '-'; 13 | } 14 | uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)) 15 | .toString(16); 16 | } 17 | 18 | return uuid; 19 | } 20 | 21 | public static pluralize(count: number, word: string) { 22 | return count === 1 ? word : word + 's'; 23 | } 24 | 25 | public static store(namespace : string, data? : any) { 26 | if (data) { 27 | return localStorage.setItem(namespace, JSON.stringify(data)); 28 | } 29 | 30 | var store = localStorage.getItem(namespace); 31 | return (store && JSON.parse(store)) || []; 32 | } 33 | 34 | public static extend(...objs : any[]) : any { 35 | var newObj = {}; 36 | for (var i = 0; i < objs.length; i++) { 37 | var obj = objs[i]; 38 | for (var key in obj) { 39 | if (obj.hasOwnProperty(key)) { 40 | newObj[key] = obj[key]; 41 | } 42 | } 43 | } 44 | return newObj; 45 | } 46 | } 47 | 48 | export { Utils }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "cypress-example-todomvc", 4 | "version": "0.0.0-development", 5 | "description": "This repo contains an example React App, with the tests written in Cypress", 6 | "scripts": { 7 | "build": "cross-env ./node_modules/typescript/bin/tsc -p ./js/ && node ./node_modules/browserify/bin/cmd ./js/app.js -o ./js/bundle.js", 8 | "cypress:version": "cypress version", 9 | "cypress:verify": "cypress verify", 10 | "cypress:open": "cypress open", 11 | "cypress:run": "cypress run", 12 | "cypress:run:record": "cypress run --record", 13 | "cypress:run:chrome": "cypress run --browser chrome", 14 | "cypress:run:headed": "cypress run --headed", 15 | "dev": "run-p --race start cypress:open", 16 | "start": "npm run build & npm run serve", 17 | "serve": "node ./serve.js", 18 | "test": "npm run start & cypress run", 19 | "test:ci": "run-p --race start cypress:run", 20 | "test:ci:record": "run-p --race start cypress:run:record", 21 | "test:ci:chrome": "run-p --race start cypress:run:chrome", 22 | "test:ci:headed": "run-p --race start cypress:run:headed", 23 | "lint": "eslint --fix cypress/e2e cypress/support", 24 | "effective:circle:config": "circleci config process circle.yml | sed /^#/d", 25 | "types": "tsc --noEmit", 26 | "e2e": "cypress run" 27 | }, 28 | "dependencies": { 29 | "@types/classnames": "^2.2.6", 30 | "@types/react": "^16.7.0", 31 | "@types/react-dom": "^16.0.11", 32 | "classnames": "^2.2.6", 33 | "director": "^1.2.0", 34 | "react": "^16.7.0", 35 | "react-dom": "^16.7.0", 36 | "todomvc-app-css": "^2.0.0", 37 | "todomvc-common": "cypress-io/todomvc-common#88b7c6359ad4a5097312d8b2a21dd539ce9f4446" 38 | }, 39 | "devDependencies": { 40 | "axe-core": "4.10.3", 41 | "browserify": "^16.2.3", 42 | "cross-env": "7.0.3", 43 | "cypress": "14.4.1", 44 | "cypress-axe": "1.6.0", 45 | "eslint": "7.32.0", 46 | "eslint-plugin-cypress-dev": "3.0.0", 47 | "eslint-plugin-mocha": "5.3.0", 48 | "node-static": "^0.7.11", 49 | "npm-run-all": "4.1.5", 50 | "typescript": "5.5.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "commitMessage": "{{semanticPrefix}}Update {{depName}} to {{newVersion}} 🌟", 7 | "prTitle": "{{semanticPrefix}}{{#if isPin}}Pin{{else}}Update{{/if}} dependency {{depName}} to version {{#if isRange}}{{newVersion}}{{else}}{{#if isMajor}}{{newVersionMajor}}.x{{else}}{{newVersion}}{{/if}}{{/if}} 🌟", 8 | "major": { 9 | "automerge": false 10 | }, 11 | "minor": { 12 | "automerge": true 13 | }, 14 | "labels": [ 15 | "type: dependencies", 16 | "renovate" 17 | ], 18 | "masterIssue": true, 19 | "prConcurrentLimit": 3, 20 | "prHourlyLimit": 2, 21 | "timezone": "America/New_York", 22 | "schedule": [ 23 | "after 11pm and before 1am on every weekday", 24 | "every weekend" 25 | ], 26 | "packageRules": [ 27 | { 28 | "packagePatterns": [ 29 | "*" 30 | ], 31 | "excludePackagePatterns": [ 32 | "cypress", 33 | "director", 34 | "http-server", 35 | "todomvc-app-css", 36 | "axe-core", 37 | "cypress-axe", 38 | "eslint", 39 | "eslint-plugin-cypress-dev", 40 | "eslint-plugin-mocha", 41 | "typescript" 42 | ], 43 | "enabled": false 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /serve.js: -------------------------------------------------------------------------------- 1 | let static = require('node-static') 2 | let file = new static.Server('./') 3 | 4 | require('http').createServer(function (request, response) { 5 | request.addListener('end', function () { 6 | file.serve(request, response) 7 | }).resume() 8 | }).listen(8888) 9 | 10 | 11 | // eslint-disable-next-line no-console, no-undef 12 | console.log('Serving on http://localhost:8888/') 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015", "dom"], 4 | "allowJs": true, 5 | "noEmit": true, 6 | "types": [ 7 | "cypress" 8 | ] 9 | }, 10 | "include": [ 11 | "cypress/**/*.js" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------