├── .editorconfig ├── .gitignore ├── README.md ├── babel.config.js ├── e2e ├── nightwatch.conf.js ├── reports │ ├── CHROME_69.0.3497.100_Mac OS X_journeys.xml │ └── FIREFOX_62.0.3_16.7.0_journeys.xml ├── runner.js └── specs │ └── journeys.js ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── scripts └── start.js ├── server.js ├── server.spec.js ├── src ├── App.vue ├── api │ ├── __mocks__ │ │ └── api.js │ ├── api.js │ ├── create-api-client.js │ └── create-api-server.js ├── app.js ├── components │ ├── Comment.vue │ ├── Item.vue │ ├── ProgressBar.vue │ ├── Spinner.vue │ └── __tests__ │ │ ├── Comment.spec.js │ │ ├── Item.spec.js │ │ ├── ProgressBar.spec.js │ │ ├── Spinner.spec.js │ │ └── __snapshots__ │ │ ├── Item.spec.js.snap │ │ └── Spinner.spec.js.snap ├── entry-client.js ├── entry-server.js ├── index.template.html ├── router │ ├── router-config.js │ └── routes.js ├── store │ ├── __tests__ │ │ ├── actions.spec.js │ │ ├── getters.spec.js │ │ ├── mutations.spec.js │ │ └── store-config.spec.js │ ├── actions.js │ ├── getters.js │ ├── mutations.js │ └── store-config.js ├── util │ ├── __tests__ │ │ ├── filters.spec.js │ │ └── mixins.spec.js │ ├── filters.js │ └── mixins.js └── views │ ├── ItemList.vue │ ├── ItemView.vue │ ├── NotFound.vue │ ├── UserView.vue │ └── __tests__ │ ├── ItemList.spec.js │ ├── ItemView.spec.js │ ├── NotFound.server.spec.js │ ├── UserView.spec.js │ └── __snapshots__ │ └── NotFound.server.spec.js.snap ├── test-setup.js └── vue.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue HackerNews 2 | 3 | A Hacker News clone with tests, written for the book [Testing Vue.js Applications](https://www.manning.com/books/testing-vuejs-applications) 4 | 5 | ## Test 6 | 7 | Run full suite: 8 | 9 | ``` 10 | npm run test 11 | ``` 12 | 13 | Unit tests: 14 | ``` 15 | npm run test:unit 16 | ``` 17 | 18 | Integration tests: 19 | ``` 20 | npm run test:integration 21 | ``` 22 | 23 | E2E tests: 24 | ``` 25 | npm run test:e2e 26 | ``` 27 | 28 | ## Development 29 | 30 | Run the development server: 31 | 32 | ``` 33 | npm run serve 34 | ``` 35 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | process.env.VUE_CLI_BABEL_TRANSPILE_MODULES = true 2 | process.env.VUE_CLI_BABEL_TARGET_NODE = 'node' 3 | 4 | module.exports = { 5 | presets: [ 6 | '@vue/app' 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | src_folders: ['e2e/specs'], 3 | output_folder: 'e2e/reports', 4 | 5 | selenium: { 6 | start_process: true, 7 | server_path: require('selenium-server').path, 8 | host: '127.0.0.1', 9 | port: 4444, 10 | cli_args: { 11 | 'WebDriver.chrome.driver': require('chromedriver').path, 12 | 'WebDriver.gecko.driver': require('geckodriver').path 13 | } 14 | }, 15 | 16 | test_settings: { 17 | chrome: { 18 | desiredCapabilities: { 19 | browserName: 'chrome' 20 | } 21 | }, 22 | firefox: { 23 | desiredCapabilities: { 24 | browserName: 'firefox' 25 | } 26 | } 27 | 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /e2e/reports/CHROME_69.0.3497.100_Mac OS X_journeys.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /e2e/reports/FIREFOX_62.0.3_16.7.0_journeys.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /e2e/runner.js: -------------------------------------------------------------------------------- 1 | const app = require('../server') 2 | const spawn = require('cross-spawn') 3 | 4 | const PORT = process.env.PORT || 8080 5 | 6 | const server = app.listen(PORT, () => { 7 | const opts = ['--config', 'e2e/nightwatch.conf.js', '--env', 'chrome,firefox'] 8 | const runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 9 | 10 | runner.on('exit', function (code) { 11 | server.close() 12 | process.exit(code) 13 | }) 14 | 15 | runner.on('error', function (err) { 16 | server.close() 17 | throw err 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /e2e/specs/journeys.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'takes user to the item page': function (browser) { 3 | browser 4 | .url('http://localhost:8080') // #A 5 | .waitForElementVisible('.news-item', 15000) 6 | .click('.comments-link') // #C 7 | .assert.urlContains(`/item`) // #D 8 | .waitForElementVisible('.item-view', 15000) // #E 9 | .end() 10 | }, 11 | 'clicking on a user redirects to the user page': function (browser) { 12 | browser 13 | .url('http://localhost:8080') 14 | .waitForElementVisible('.news-item', 15000) 15 | .click('.by a') // #A 16 | .assert.urlContains(`/user`) // #B 17 | .waitForElementVisible('.user-view', 30000) // #C 18 | .end() 19 | }, 20 | 'paginates items correctly': function (browser) { 21 | let originalItemListText 22 | browser 23 | .url('http://localhost:8080') 24 | .waitForElementVisible('.news-item', 15000) // #A 25 | .getText('.item-list', function (result) { // #B 26 | originalItemListText = result.value.slice(0, 100) 27 | }) 28 | .click('.item-list-nav a:nth-of-type(2 )') // #C 29 | .waitForElementNotPresent('.progress', 15000) // #D 30 | .perform(() => { // #E 31 | browser.expect.element('.item-list').text.to.not.equal(originalItemListText) 32 | }) 33 | .getText('.item-list', function (result) { // #F 34 | originalItemListText = result.value.slice(0, 100) 35 | }) 36 | .click('.item-list-nav a') // #G 37 | .waitForElementNotPresent('.progress', 15000) 38 | .perform(() => { // #H 39 | browser.expect.element('.item-list').text.to.not.equal(originalItemListText) 40 | }) 41 | }, 42 | 'changes list by clicking through nav': function (browser) { 43 | let originalItemListText 44 | browser 45 | .url('http://localhost:8080') 46 | .waitForElementVisible('.news-item', 15000) // #A 47 | .getText('.item-list', function (result) { // #B 48 | originalItemListText = result.value.slice(0, 100) 49 | }) 50 | .click('.inner a:nth-of-type(2)') // #C 51 | .waitForElementNotPresent('.progress', 15000) 52 | .perform(() => { 53 | browser.expect.element('.item-list').text.to.not.equal(originalItemListText) // #D 54 | }) 55 | .getText('.item-list', function (result) { // #E 56 | originalItemListText = result.value.slice(0, 100) 57 | }) 58 | .click('.inner a:nth-of-type(4)') // #F 59 | .waitForElementNotPresent('.progress', 15000) 60 | .perform(() => { 61 | browser.expect.element('.item-list').text.to.not.equal(originalItemListText) // #G 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-hackernews-2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "npm run build && npm-run-all start serve:client", 7 | "serve:client": "vue-cli-service serve", 8 | "build": "npm run build:server -- --silent && npm run build:client -- --no-clean --silent", 9 | "build:client": "vue-cli-service build", 10 | "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build", 11 | "start": "cross-env NODE_ENV=production node scripts/start", 12 | "lint": "vue-cli-service lint", 13 | "test": "npm run lint && npm run test:unit && npm run test:integration && npm run test:e2e", 14 | "test:e2e": "node e2e/runner.js", 15 | "test:integration": "npm run build && jest --testEnvironment node --forceExit server.spec.js", 16 | "test:unit": "jest src --no-cache", 17 | "test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand" 18 | }, 19 | "dependencies": { 20 | "firebase": "^5.0.4", 21 | "vue": "^2.5.16", 22 | "vue-router": "^3.0.1", 23 | "vuex": "^3.0.1", 24 | "vuex-router-sync": "^5.0.0" 25 | }, 26 | "devDependencies": { 27 | "@vue/cli": "^3.0.0-rc.3", 28 | "@vue/cli-plugin-babel": "^3.0.0-rc.3", 29 | "@vue/cli-plugin-eslint": "^3.0.0-rc.3", 30 | "@vue/cli-service": "^3.0.0-rc.3", 31 | "@vue/eslint-config-standard": "^3.0.0-beta.16", 32 | "@vue/server-test-utils": "^1.0.0-beta.25", 33 | "@vue/test-utils": "^1.0.0-beta.20", 34 | "babel-core": "^7.0.0-bridge.0", 35 | "babel-jest": "^23.2.0", 36 | "chromedriver": "^2.43.0", 37 | "cross-env": "^5.2.0", 38 | "flush-promises": "^1.0.0", 39 | "geckodriver": "^1.12.2", 40 | "jest": "^22.4.2", 41 | "lodash.clonedeep": "^4.5.0", 42 | "lodash.merge": "^4.6.1", 43 | "nightwatch": "^0.9.21", 44 | "npm-run-all": "^4.1.3", 45 | "selenium-server": "^3.14.0", 46 | "supertest": "^3.3.0", 47 | "vue-jest": "^2.6.0", 48 | "vue-server-renderer": "^2.5.16", 49 | "vue-template-compiler": "^2.5.16", 50 | "webpack-node-externals": "^1.7.2" 51 | }, 52 | "eslintConfig": { 53 | "root": true, 54 | "env": { 55 | "node": true, 56 | "jest": true 57 | }, 58 | "extends": [ 59 | "plugin:vue/essential", 60 | "@vue/standard" 61 | ], 62 | "rules": { 63 | "prefer-promise-reject-errors": 0, 64 | "no-new": 0 65 | }, 66 | "parserOptions": { 67 | "parser": "babel-eslint" 68 | } 69 | }, 70 | "postcss": { 71 | "plugins": { 72 | "autoprefixer": {} 73 | } 74 | }, 75 | "browserslist": [ 76 | "> 1%", 77 | "last 2 versions", 78 | "not ie <= 8" 79 | ], 80 | "jest": { 81 | "transform": { 82 | "^.+\\.js$": "babel-jest", 83 | "^.+\\.vue$": "vue-jest" 84 | }, 85 | "setupFiles": [ 86 | "./test-setup.js" 87 | ], 88 | "moduleFileExtensions": [ 89 | "js", 90 | "json", 91 | "vue" 92 | ], 93 | "transformIgnorePatterns": [ 94 | "/node_modules/" 95 | ] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eddyerburgh/vue-hackernews/c86a6bbbb306ba2e9edea48ab27cdb10ae41346e/public/favicon.ico -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | const app = require('../server') 2 | 3 | const port = process.env.PORT || 8080 4 | 5 | app.listen(port, () => { 6 | console.log(`server started at localhost:${port}`) 7 | }) 8 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const express = require('express') 6 | var proxy = require('http-proxy-middleware') 7 | const { createBundleRenderer } = require('vue-server-renderer') 8 | 9 | const devServerBaseURL = process.env.DEV_SERVER_BASE_URL || 'http://localhost' 10 | const devServerPort = process.env.DEV_SERVER_PORT || 8080 11 | 12 | const app = express() 13 | 14 | function createRenderer (bundle, options) { 15 | return createBundleRenderer(bundle, Object.assign(options, { 16 | runInNewContext: false 17 | })) 18 | } 19 | const templatePath = path.resolve(__dirname, './src/index.template.html') 20 | 21 | const bundle = require('./dist/vue-ssr-server-bundle.json') 22 | const template = fs.readFileSync(templatePath, 'utf-8') 23 | const clientManifest = require('./dist/vue-ssr-client-manifest.json') 24 | 25 | const renderer = createRenderer(bundle, { 26 | template, 27 | clientManifest 28 | }) 29 | 30 | if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { 31 | app.use('/js/main*', proxy({ 32 | target: `${devServerBaseURL}/${devServerPort}`, 33 | changeOrigin: true, 34 | pathRewrite: function (path) { 35 | return path.includes('main') 36 | ? '/main.js' 37 | : path 38 | }, 39 | prependPath: false 40 | })) 41 | 42 | app.use('/*hot-update*', proxy({ 43 | target: `${devServerBaseURL}/${devServerPort}`, 44 | changeOrigin: true 45 | })) 46 | 47 | app.use('/sockjs-node', proxy({ 48 | target: `${devServerBaseURL}/${devServerPort}`, 49 | changeOrigin: true, 50 | ws: true 51 | })) 52 | } 53 | 54 | app.use('/js', express.static(path.resolve(__dirname, './dist/js'))) 55 | app.use('/css', express.static(path.resolve(__dirname, './dist/css'))) 56 | 57 | app.get('*', (req, res) => { 58 | res.setHeader('Content-Type', 'text/html') 59 | 60 | const context = { 61 | title: 'Vue HN', 62 | url: req.url 63 | } 64 | 65 | renderer.renderToString(context, (err, html) => { 66 | if (err) { 67 | if (err.url) { 68 | res.redirect(err.url) 69 | } else { 70 | // Render Error Page or Redirect 71 | res.status(500).end('500 | Internal Server Error') 72 | console.error(`error during render : ${req.url}`) 73 | console.error(err.stack) 74 | } 75 | } 76 | if (context.renderState) { 77 | context.renderState() 78 | } 79 | res.status(context.HTTPStatus || 200) 80 | res.send(html) 81 | }) 82 | }) 83 | 84 | module.exports = app 85 | -------------------------------------------------------------------------------- /server.spec.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import app from './server' 3 | 4 | describe('server', () => { 5 | test('/top returns 200', () => { 6 | return request(app) 7 | .get('/top') 8 | .expect(200) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | 28 | 113 | -------------------------------------------------------------------------------- /src/api/__mocks__/api.js: -------------------------------------------------------------------------------- 1 | export const fetchListData = jest.fn(() => Promise.resolve([])) 2 | -------------------------------------------------------------------------------- /src/api/api.js: -------------------------------------------------------------------------------- 1 | import { createAPI } from 'create-api' 2 | 3 | const api = createAPI({ 4 | version: '/v0', 5 | config: { 6 | databaseURL: 'https://hacker-news.firebaseio.com' 7 | } 8 | }) 9 | 10 | function fetch (child) { 11 | const cache = api.cachedItems 12 | if (cache && cache.has(child)) { 13 | return Promise.resolve(cache.get(child)) 14 | } else { 15 | return new Promise((resolve, reject) => { 16 | api.child(child).once('value', snapshot => { 17 | const val = snapshot.val() 18 | // mark the timestamp when this item is cached 19 | if (val) val.__lastUpdated = Date.now() 20 | cache && cache.set(child, val) 21 | resolve(val) 22 | }, reject) 23 | }) 24 | } 25 | } 26 | 27 | export function fetchListData (type) { 28 | return fetchIdsByType(type) 29 | .then((ids) => fetchItems(ids)) 30 | } 31 | 32 | export function fetchIdsByType (type) { 33 | return api.cachedIds && api.cachedIds[type] 34 | ? Promise.resolve(api.cachedIds[type]) 35 | : fetch(`${type}stories`) 36 | } 37 | 38 | export function fetchItem (id) { 39 | return fetch(`item/${id}`) 40 | } 41 | 42 | export function fetchItems (ids) { 43 | return Promise.all(ids.map(id => fetchItem(id))) 44 | } 45 | 46 | export function fetchUser (id) { 47 | return fetch(`user/${id}`) 48 | } 49 | -------------------------------------------------------------------------------- /src/api/create-api-client.js: -------------------------------------------------------------------------------- 1 | import Firebase from 'firebase/app' 2 | import 'firebase/database' 3 | 4 | export function createAPI ({ config, version }) { 5 | Firebase.initializeApp(config) 6 | return Firebase.database().ref(version) 7 | } 8 | -------------------------------------------------------------------------------- /src/api/create-api-server.js: -------------------------------------------------------------------------------- 1 | import Firebase from 'firebase' 2 | 3 | export function createAPI ({ config, version }) { 4 | let api 5 | // this piece of code may run multiple times in development mode, 6 | // so we attach the instantiated API to `process` to avoid duplications 7 | if (process.__API__) { 8 | api = process.__API__ 9 | } else { 10 | Firebase.initializeApp(config) 11 | api = process.__API__ = Firebase.database().ref(version) 12 | } 13 | return api 14 | } 15 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import Router from 'vue-router' 4 | import { sync } from 'vuex-router-sync' 5 | import App from './App.vue' 6 | import storeConfig from './store/store-config' 7 | import routerConfig from './router/router-config' 8 | import { 9 | titleMixin, 10 | HTTPStatusMixin 11 | } from './util/mixins' 12 | import { 13 | timeAgo, 14 | host 15 | } from './util/filters' 16 | 17 | Vue.mixin(titleMixin) 18 | Vue.mixin(HTTPStatusMixin) 19 | 20 | Vue.filter('timeAgo', timeAgo) 21 | Vue.filter('host', host) 22 | 23 | // Expose a factory function that creates a fresh set of store, router, 24 | // app instances on each call (which is called for each SSR request) 25 | export function createApp () { 26 | Vue.use(Vuex) 27 | Vue.use(Router) 28 | 29 | const router = new Router(routerConfig) 30 | const store = new Vuex.Store(storeConfig) 31 | 32 | sync(store, router) 33 | 34 | const app = new Vue({ 35 | router, 36 | store, 37 | render: h => h(App) 38 | }) 39 | 40 | return { app, router, store } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Comment.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 40 | 41 | 86 | -------------------------------------------------------------------------------- /src/components/Item.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 28 | 29 | 63 | -------------------------------------------------------------------------------- /src/components/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 43 | 44 | 64 | -------------------------------------------------------------------------------- /src/components/Spinner.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 53 | -------------------------------------------------------------------------------- /src/components/__tests__/Comment.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | shallowMount, 3 | createLocalVue, 4 | RouterLinkStub 5 | } from '@vue/test-utils' 6 | import Vuex from 'vuex' 7 | import Comment from '../Comment.vue' 8 | import merge from 'lodash.merge' 9 | 10 | const localVue = createLocalVue() 11 | 12 | localVue.use(Vuex) 13 | 14 | function createStore (overrides) { 15 | const defaultStoreConfig = { 16 | state: { 17 | comments: { 18 | a1: { 19 | kids: [] 20 | } 21 | } 22 | } 23 | } 24 | return new Vuex.Store( 25 | merge(defaultStoreConfig, overrides) 26 | ) 27 | } 28 | 29 | function createWrapper (overrides) { 30 | const defaultMountingOptions = { 31 | stubs: { 32 | RouterLink: RouterLinkStub 33 | }, 34 | localVue, 35 | propsData: { 36 | id: 'a1' 37 | }, 38 | store: createStore() 39 | } 40 | return shallowMount(Comment, merge(defaultMountingOptions, overrides)) 41 | } 42 | 43 | describe('Comment.vue', () => { 44 | test('sets router-link to prop using comment.by', () => { 45 | const store = createStore({ 46 | state: { 47 | comments: { 48 | a1: { 49 | by: 'edd' 50 | } 51 | } 52 | } 53 | }) 54 | const wrapper = createWrapper({ store }) 55 | expect(wrapper.findAll('.by')).toHaveLength(1) 56 | expect(wrapper.find(RouterLinkStub).props().to).toBe('/user/edd') 57 | }) 58 | 59 | test('does not render li if comment does not exist in store', () => { 60 | const store = createStore({ 61 | state: { 62 | comments: { 63 | a1: null 64 | } 65 | } 66 | }) 67 | const wrapper = createWrapper({ store }) 68 | expect(wrapper.find('li').exists()).toBe(false) 69 | }) 70 | 71 | test('initially renders open toggle if comment has kids', () => { 72 | const store = createStore({ 73 | state: { 74 | comments: { 75 | a1: { 76 | kids: ['b1', 'b2'] 77 | } 78 | } 79 | } 80 | }) 81 | const wrapper = createWrapper({ store }) 82 | expect(wrapper.find('.toggle').text()).toContain('[-]') 83 | }) 84 | 85 | test('renders closed toggle after click if comment has kids', () => { 86 | const store = createStore({ 87 | state: { 88 | comments: { 89 | a1: { 90 | kids: ['b1', 'b2'] 91 | } 92 | } 93 | } 94 | }) 95 | const wrapper = createWrapper({ 96 | store, 97 | stubs: { 98 | RouterLink: true 99 | } 100 | }) 101 | wrapper.find('a').trigger('click') 102 | expect(wrapper.find('.toggle').text()).toContain('[+] 2 replies collapsed') 103 | }) 104 | 105 | test('does not render toggle if comment has no kids', () => { 106 | const wrapper = createWrapper() 107 | expect(wrapper.find('.toggle').exists()).toEqual(false) 108 | }) 109 | 110 | test('renders a comment for each kid', () => { 111 | const store = createStore({ 112 | state: { 113 | comments: { 114 | a1: { 115 | kids: ['b1', 'b2'] 116 | } 117 | } 118 | } 119 | }) 120 | const wrapper = createWrapper({ store }) 121 | expect(wrapper.findAll(Comment)).toHaveLength(3) 122 | }) 123 | 124 | test('hides comments when toggle is clicked', () => { 125 | const store = createStore({ 126 | state: { 127 | comments: { 128 | a1: { 129 | kids: ['b1', 'b2'] 130 | } 131 | } 132 | } 133 | }) 134 | const wrapper = createWrapper({ 135 | store, 136 | stubs: { 137 | RouterLink: true 138 | } 139 | }) 140 | wrapper.find('a').trigger('click') 141 | expect(wrapper.findAll(Comment)).toHaveLength(3) 142 | expect(wrapper.findAll(Comment).isVisible()).toBeFalsy() 143 | }) 144 | }) 145 | -------------------------------------------------------------------------------- /src/components/__tests__/Item.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | shallowMount, 3 | RouterLinkStub 4 | } from '@vue/test-utils' 5 | import Item from '../Item.vue' 6 | import merge from 'lodash.merge' 7 | 8 | function createWrapper (overrides) { 9 | const defaultMountingOptions = { 10 | stubs: { 11 | RouterLink: RouterLinkStub 12 | }, 13 | propsData: { 14 | item: {} 15 | } 16 | } 17 | return shallowMount(Item, merge(defaultMountingOptions, overrides)) 18 | } 19 | 20 | describe('Item.vue', () => { 21 | test('renders the hostname', () => { 22 | const item = { 23 | url: 'https://some-url.com/with-paths' 24 | } 25 | 26 | const wrapper = createWrapper({ 27 | propsData: { 28 | item 29 | } 30 | }) 31 | expect(wrapper.text()).toContain('(some-url.com)') 32 | }) 33 | 34 | test('renders item.score', () => { 35 | const item = { 36 | score: 10 37 | } 38 | const wrapper = createWrapper({ 39 | propsData: { 40 | item 41 | } 42 | }) 43 | expect(wrapper.text()).toContain(item.score) 44 | }) 45 | 46 | test('renders item.by', () => { 47 | const item = { 48 | by: 'some author' 49 | } 50 | const wrapper = createWrapper({ 51 | propsData: { 52 | item 53 | } 54 | }) 55 | expect(wrapper.text()).toContain(item.by) 56 | }) 57 | 58 | test('renders a link to the item.url with item.title as text', () => { 59 | const item = { 60 | url: 'http://some-url.com', 61 | title: 'some-title' 62 | } 63 | const wrapper = createWrapper({ 64 | propsData: { 65 | item 66 | } 67 | }) 68 | const a = wrapper.find('a') 69 | expect(a.text()).toBe(item.title) 70 | expect(a.attributes().href).toBe(item.url) 71 | }) 72 | 73 | test('renders the time since the last post', () => { 74 | const dateNow = jest.spyOn(Date, 'now') 75 | const dateNowTime = new Date('2018') 76 | 77 | dateNow.mockImplementation(() => dateNowTime) 78 | 79 | const item = { 80 | time: (dateNowTime / 1000) - 600 81 | } 82 | const wrapper = createWrapper({ 83 | propsData: { 84 | item 85 | } 86 | }) 87 | dateNow.mockRestore() 88 | expect(wrapper.text()).toContain('10 minutes ago') 89 | }) 90 | 91 | test('renders correctly', () => { 92 | const dateNow = jest.spyOn(Date, 'now') 93 | const dateNowTime = new Date('2018') 94 | 95 | dateNow.mockImplementation(() => dateNowTime) 96 | 97 | const item = { 98 | by: 'eddyerburgh', 99 | id: 11122233, 100 | score: 10, 101 | time: (dateNowTime / 1000) - 600, 102 | title: 'vue-test-utils is released', 103 | type: 'story', 104 | url: 'https://vue-test-utils.vuejs.org/' 105 | } 106 | const wrapper = createWrapper({ 107 | propsData: { 108 | item 109 | } 110 | }) 111 | dateNow.mockRestore() 112 | expect(wrapper.element).toMatchSnapshot() 113 | }) 114 | 115 | test('renders correctly when item has no url', () => { 116 | const dateNow = jest.spyOn(Date, 'now') 117 | const dateNowTime = new Date('2018') 118 | 119 | dateNow.mockImplementation(() => dateNowTime) 120 | 121 | const item = { 122 | by: 'eddyerburgh', 123 | id: 11122233, 124 | score: 10, 125 | time: (dateNowTime / 1000) - 600, 126 | title: 'vue-test-utils is released' 127 | } 128 | const wrapper = createWrapper({ 129 | propsData: { 130 | item 131 | } 132 | }) 133 | expect(wrapper.element).toMatchSnapshot() 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /src/components/__tests__/ProgressBar.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import ProgressBar from '../ProgressBar.vue' 3 | 4 | describe('ProgressBar.vue', () => { 5 | beforeEach(() => { 6 | jest.useFakeTimers() 7 | }) 8 | 9 | test('initializes with 0% width', () => { 10 | const wrapper = shallowMount(ProgressBar) 11 | expect(wrapper.element.style.width).toBe('0%') 12 | }) 13 | 14 | test('displays the bar when start is called', () => { 15 | const wrapper = shallowMount(ProgressBar) 16 | expect(wrapper.classes()).toContain('hidden') 17 | wrapper.vm.start() 18 | expect(wrapper.classes()).not.toContain('hidden') 19 | }) 20 | 21 | test('sets the bar to 100% width when finish is called', () => { 22 | const wrapper = shallowMount(ProgressBar) 23 | wrapper.vm.start() 24 | wrapper.vm.finish() 25 | expect(wrapper.element.style.width).toBe('100%') 26 | }) 27 | 28 | test('hides the bar when finish is called', () => { 29 | const wrapper = shallowMount(ProgressBar) 30 | wrapper.vm.start() 31 | wrapper.vm.finish() 32 | expect(wrapper.classes()).toContain('hidden') 33 | }) 34 | 35 | test('resets to 0% width when start is called', () => { 36 | const wrapper = shallowMount(ProgressBar) 37 | wrapper.vm.finish() 38 | wrapper.vm.start() 39 | expect(wrapper.element.style.width).toBe('0%') 40 | }) 41 | 42 | test('removes error class when start is called', () => { 43 | const wrapper = shallowMount(ProgressBar) 44 | wrapper.vm.fail() 45 | wrapper.vm.start() 46 | expect(wrapper.classes()).not.toContain('error') 47 | }) 48 | 49 | test('sets the bar to 100% width when fail is called', () => { 50 | const wrapper = shallowMount(ProgressBar) 51 | wrapper.vm.fail() 52 | expect(wrapper.classes()).toContain('error') 53 | }) 54 | 55 | test('styles the bar correctly when fail is called', () => { 56 | const wrapper = shallowMount(ProgressBar) 57 | wrapper.vm.fail() 58 | expect(wrapper.element.style.width).toBe('100%') 59 | }) 60 | 61 | test('increases width by 1% every 100ms after start call', () => { 62 | const wrapper = shallowMount(ProgressBar) 63 | wrapper.vm.start() 64 | jest.runTimersToTime(100) 65 | expect(wrapper.element.style.width).toBe('1%') 66 | jest.runTimersToTime(900) 67 | expect(wrapper.element.style.width).toBe('10%') 68 | jest.runTimersToTime(4000) 69 | expect(wrapper.element.style.width).toBe('50%') 70 | }) 71 | 72 | test('clears timer when finish is called', () => { 73 | jest.spyOn(window, 'clearInterval') 74 | setInterval.mockReturnValue(123) 75 | const wrapper = shallowMount(ProgressBar) 76 | wrapper.vm.start() 77 | wrapper.vm.finish() 78 | expect(window.clearInterval).toHaveBeenCalledWith(123) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /src/components/__tests__/Spinner.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import Spinner from '../Spinner.vue' 3 | 4 | describe('Spinner.vue', () => { 5 | test('renders correctly', () => { 6 | expect(shallowMount(Spinner).element).toMatchSnapshot() 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/Item.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Item.vue renders correctly 1`] = ` 4 |
  • 7 | 10 | 10 11 | 12 | 13 | 16 | 21 | vue-test-utils is released 22 | 23 | 24 | 27 | (vue-test-utils.vuejs.org) 28 | 29 | 30 | 31 |
    32 | 33 | 36 | 39 | 40 | by 41 | 42 | eddyerburgh 43 | 44 | 45 | 46 | 49 | 50 | | 51 | 52 | comments 53 | 54 | 55 | 56 | 57 | 58 | 10 minutes ago 59 | 60 | 61 | 62 |
  • 63 | `; 64 | 65 | exports[`Item.vue renders correctly when item has no url 1`] = ` 66 |
  • 69 | 72 | 10 73 | 74 | 75 | 78 | 82 | vue-test-utils is released 83 | 84 | 85 | 88 | () 89 | 90 | 91 | 92 |
    93 | 94 | 97 | 100 | 101 | by 102 | 103 | eddyerburgh 104 | 105 | 106 | 107 | 110 | 111 | | 112 | 113 | comments 114 | 115 | 116 | 117 | 118 | 119 | 10 minutes ago 120 | 121 | 122 | 123 |
  • 124 | `; 125 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/Spinner.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Spinner.vue renders correctly 1`] = ` 4 | 10 | 19 | 20 | `; 21 | -------------------------------------------------------------------------------- /src/entry-client.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { createApp } from './app' 3 | import ProgressBar from './components/ProgressBar.vue' 4 | 5 | // global progress bar 6 | const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount() 7 | document.body.appendChild(bar.$el) 8 | 9 | const { app, store } = createApp() 10 | 11 | if (window.__INITIAL_STATE__) { 12 | store.replaceState(window.__INITIAL_STATE__) 13 | } 14 | 15 | // actually mount to DOM 16 | app.$mount('#app') 17 | -------------------------------------------------------------------------------- /src/entry-server.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app' 2 | 3 | export default context => { 4 | return new Promise((resolve, reject) => { 5 | const { app, router, store } = createApp() 6 | 7 | const { url } = context 8 | const { fullPath } = router.resolve(url).route 9 | 10 | if (fullPath !== url) { 11 | return reject({ url: fullPath }) 12 | } 13 | 14 | router.push(url) 15 | const matchedComponents = router.getMatchedComponents() 16 | Promise.all([ 17 | ...matchedComponents.map(Component => { 18 | if (Component.asyncData) { 19 | return Component.asyncData({ 20 | store, 21 | route: router.currentRoute 22 | }) 23 | } 24 | }) 25 | ]).then(() => { 26 | context.state = store.state 27 | resolve(app) 28 | }) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/index.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Vue HN 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/router/router-config.js: -------------------------------------------------------------------------------- 1 | import routes from './routes' 2 | 3 | export default { 4 | mode: 'history', 5 | routes 6 | } 7 | -------------------------------------------------------------------------------- /src/router/routes.js: -------------------------------------------------------------------------------- 1 | import ItemList from '../views/ItemList.vue' 2 | import ItemView from '../views/ItemView.vue' 3 | import UserView from '../views/UserView.vue' 4 | import NotFound from '../views/NotFound.vue' 5 | 6 | export default [ 7 | { path: '/:type(top|new|show|ask|job)/:page?', component: ItemList }, 8 | { path: '/', redirect: '/top' }, 9 | { path: '/item/:id(\\d+)', component: ItemView }, 10 | { path: '/user/:id', component: UserView }, 11 | { path: '/*', component: NotFound } 12 | ] 13 | -------------------------------------------------------------------------------- /src/store/__tests__/actions.spec.js: -------------------------------------------------------------------------------- 1 | import actions from '../actions' 2 | import { fetchListData } from '../../api/api' 3 | import flushPromises from 'flush-promises' 4 | 5 | jest.mock('../../api/api') 6 | 7 | describe('actions', () => { 8 | test('fetchListData calls commit with the result of fetchListData', async () => { 9 | expect.assertions(1) 10 | const items = [{}, {}] 11 | const type = 'top' 12 | fetchListData.mockImplementation(calledWith => { 13 | return calledWith === type 14 | ? Promise.resolve(items) 15 | : Promise.resolve() 16 | }) 17 | const context = { 18 | commit: jest.fn() 19 | } 20 | actions.fetchListData(context, { type }) 21 | await flushPromises() 22 | expect(context.commit).toHaveBeenCalledWith('setItems', { items }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/store/__tests__/getters.spec.js: -------------------------------------------------------------------------------- 1 | import getters from '../getters' 2 | 3 | describe('getters', () => { 4 | test('displayItems returns the first 20 items from state.items', () => { 5 | const items = Array(21).fill().map((v, i) => i) 6 | const state = { 7 | items, 8 | route: { 9 | params: {} 10 | } 11 | } 12 | const result = getters.displayItems(state) 13 | const expectedResult = items.slice(0, 20) 14 | expect(result).toEqual(expectedResult) 15 | }) 16 | 17 | test('displayItems returns items 20-40 if page is 2', () => { 18 | const items = Array(40).fill().map((v, i) => i) 19 | const result = getters.displayItems({ 20 | items, 21 | route: { 22 | params: { 23 | page: '2' 24 | } 25 | } 26 | }) 27 | const expectedResult = items.slice(20, 40) 28 | expect(result).toEqual(expectedResult) 29 | }) 30 | 31 | test('displayItems returns remaining items if there are not enough remaining items', () => { 32 | const numberArray = Array(21).fill().map((v, i) => i) 33 | const store = { 34 | items: numberArray, 35 | route: { 36 | params: { 37 | page: '2' 38 | } 39 | } 40 | } 41 | const result = getters.displayItems(store) 42 | expect(result).toHaveLength(1) 43 | expect(result[0]).toEqual(numberArray[20]) 44 | }) 45 | 46 | test('maxPage returns a rounded number using the current items', () => { 47 | const items = Array(49).fill().map((v, i) => i) 48 | const result = getters.maxPage({ 49 | items 50 | }) 51 | expect(result).toBe(3) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/store/__tests__/mutations.spec.js: -------------------------------------------------------------------------------- 1 | import mutations from '../mutations' 2 | 3 | describe('mutations', () => { 4 | test('setItems sets state.items to items', () => { 5 | const items = [{ id: 1 }, { id: 2 }] // 6 | const state = { 7 | items: [] 8 | } 9 | mutations.setItems(state, { items }) 10 | expect(state.items).toBe(items) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/store/__tests__/store-config.spec.js: -------------------------------------------------------------------------------- 1 | 2 | import Vuex from 'vuex' 3 | import { createLocalVue } from '@vue/test-utils' 4 | import cloneDeep from 'lodash.clonedeep' 5 | import flushPromises from 'flush-promises' 6 | import Router from 'vue-router' 7 | import { sync } from 'vuex-router-sync' 8 | import storeConfig from '../store-config' 9 | import routerConfig from '../../router/router-config' 10 | import { fetchListData } from '../../api/api' 11 | 12 | jest.mock('../../api/api') 13 | 14 | const localVue = createLocalVue() 15 | localVue.use(Vuex) 16 | localVue.use(Router) 17 | const store = new Vuex.Store(storeConfig) 18 | const router = new Router(routerConfig) 19 | sync(store, router) 20 | 21 | function createItems () { 22 | const arr = new Array(22) 23 | return arr.fill().map((item, i) => ({ id: `a${i}`, name: 'item' })) 24 | } 25 | 26 | describe('store-config', () => { 27 | test('calling fetchListData with the type returns top 20 displayItems from displayItems getter', async () => { 28 | expect.assertions(1) 29 | const items = createItems() 30 | const clonedStoreConfig = cloneDeep(storeConfig) 31 | const store = new Vuex.Store(clonedStoreConfig) 32 | const type = 'top' 33 | fetchListData.mockImplementation((calledType) => { 34 | return calledType === type 35 | ? Promise.resolve(items) 36 | : Promise.resolve() 37 | }) 38 | store.dispatch('fetchListData', { type }) 39 | 40 | await flushPromises() 41 | 42 | expect(store.getters.displayItems).toEqual(items.slice(0, 20)) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | fetchListData, 3 | fetchItems, 4 | fetchItem, 5 | fetchUser 6 | } from '../api/api' 7 | 8 | export default { 9 | fetchListData: ({ commit }, { type }) => { 10 | return fetchListData(type) 11 | .then(items => commit('setItems', { items })) 12 | }, 13 | fetchComments: ({ commit, dispatch }, { item }) => { 14 | if (!item) { 15 | return 16 | } 17 | return fetchItems(item.kids || []) 18 | .then(comments => { 19 | commit('setComments', { comments }) 20 | return Promise.all( 21 | comments.map(item => 22 | dispatch('fetchComments', { item }) 23 | ) 24 | ) 25 | }) 26 | }, 27 | fetchItem: ({ commit }, { id }) => { 28 | return fetchItem(id) 29 | .then(item => commit('setItem', { item })) 30 | }, 31 | fetchUser: ({ commit, state }, { id }) => { 32 | return fetchUser(id) 33 | .then(user => commit('setUser', { user })) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | export default { 2 | displayItems (state) { 3 | const page = Number(state.route.params.page) || 1 4 | const start = (page - 1) * 20 5 | const end = page * 20 6 | return state.items.slice(start, end) 7 | }, 8 | maxPage (state) { 9 | return Math.ceil(state.items.length / 20) 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default { 4 | setItem: (state, { item }) => { 5 | state.item = item 6 | }, 7 | setItems: (state, { items }) => { 8 | state.items = items 9 | }, 10 | setComments: (state, { comments }) => { 11 | comments.forEach(comment => { 12 | console.log(comment, 'comment') 13 | if (comment) { 14 | Vue.set(state.comments, comment.id, comment) 15 | } 16 | }) 17 | }, 18 | setUser: (state, { user }) => { 19 | state.user = user 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/store/store-config.js: -------------------------------------------------------------------------------- 1 | import actions from './actions' 2 | import mutations from './mutations' 3 | import getters from './getters' 4 | 5 | const state = { 6 | item: null, 7 | items: [], 8 | comments: {}, 9 | user: null 10 | } 11 | 12 | export default { 13 | state, 14 | getters, 15 | actions, 16 | mutations 17 | } 18 | -------------------------------------------------------------------------------- /src/util/__tests__/filters.spec.js: -------------------------------------------------------------------------------- 1 | import { host, timeAgo } from '../filters' 2 | 3 | describe('host', () => { 4 | test('returns empty string if url is undefined', () => { 5 | expect(host(undefined)).toBe('') 6 | }) 7 | 8 | test('returns the host from a URL beginning with http://', () => { 9 | const url = 'http://google.com' 10 | expect(host(url)).toBe('google.com') 11 | }) 12 | 13 | test('returns the host from a URL beginning with https://', () => { 14 | const url = 'https://google.com' 15 | expect(host(url)).toBe('google.com') 16 | }) 17 | 18 | test('removes path from URL', () => { 19 | const url = 'google.com/long/path/ ' 20 | expect(host(url)).toBe('google.com') 21 | }) 22 | 23 | test('removes www from URL', () => { 24 | const url = 'www.blogs.google.com/' 25 | expect(host(url)).toBe('blogs.google.com') 26 | }) 27 | 28 | test('keep the subdomain', () => { 29 | const url = 'https://blogs.google.com/long/path/ ' 30 | expect(host(url)).toBe('blogs.google.com') 31 | }) 32 | 33 | test('returns one subdomain and removes others', () => { 34 | const url = 'personal.blogs.google.com/long/path/ ' 35 | expect(host(url)).toBe('blogs.google.com') 36 | }) 37 | }) 38 | 39 | describe('timeAgo', () => { 40 | Date.now = () => new Date('2018') 41 | const unixTime = Date.now() / 1000 42 | 43 | const seconds = (second) => second * 1 44 | const minutes = (minute) => minute * seconds(60) 45 | const hours = (hour) => hour * minutes(60) 46 | const days = (day) => day * hours(24) 47 | 48 | test('returns singular minute', () => { 49 | expect(timeAgo(unixTime - minutes(1))).toBe('1 minute') 50 | }) 51 | 52 | test('returns plural minutes', () => { 53 | expect(timeAgo(unixTime - minutes(5))).toBe('5 minutes') 54 | }) 55 | 56 | test('returns singular hour', () => { 57 | expect(timeAgo(unixTime - hours(1))).toBe('1 hour') 58 | }) 59 | 60 | test('returns plural hours', () => { 61 | expect(timeAgo(unixTime - hours(5))).toBe('5 hours') 62 | }) 63 | 64 | test('returns singular day', () => { 65 | expect(timeAgo(unixTime - days(1))).toBe('1 day') 66 | }) 67 | 68 | test('returns plural days', () => { 69 | expect(timeAgo(unixTime - days(5))).toBe('5 days') 70 | }) 71 | 72 | test('returns day rounded to nearest value', () => { 73 | expect(timeAgo(unixTime - (days(2) + hours(10)))).toBe('2 days') 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/util/__tests__/mixins.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { titleMixin } from '../mixins' 3 | 4 | describe('titleMixin', () => { 5 | test('set document.title using component title property', () => { 6 | const Component = { 7 | render () {}, 8 | title: 'dummy title', 9 | mixins: [titleMixin] 10 | } 11 | mount(Component) 12 | expect(document.title).toBe('Vue HN | dummy title') 13 | }) 14 | 15 | test('does not set document.title if title property does not exist', () => { 16 | document.title = 'some title' 17 | const Component = { 18 | render () {}, 19 | mixins: [titleMixin] 20 | } 21 | mount(Component) 22 | expect(document.title).toBe('some title') 23 | }) 24 | 25 | test(' sets document.title using result of title if it is a function ', () => { 26 | const Component = { 27 | render () {}, 28 | data () { 29 | return { 30 | titleValue: 'another dummy title' 31 | } 32 | }, 33 | title () { 34 | return this.titleValue 35 | }, 36 | mixins: [titleMixin] 37 | } 38 | mount(Component) 39 | expect(document.title).toBe('Vue HN | another dummy title') 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/util/filters.js: -------------------------------------------------------------------------------- 1 | export function host (url) { 2 | if (!url) { 3 | return '' 4 | } 5 | const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') 6 | const parts = host.split('.').slice(-3) 7 | if (parts[0] === 'www') { 8 | parts.shift() 9 | } 10 | return parts.join('.') 11 | } 12 | 13 | export function timeAgo (time) { 14 | const between = Date.now() / 1000 - Number(time) 15 | if (between < 3600) { 16 | return pluralize((between / 60), ' minute') 17 | } else if (between < 86400) { 18 | return pluralize((between / 3600), ' hour') 19 | } else { 20 | return pluralize((between / 86400), ' day') 21 | } 22 | } 23 | 24 | function pluralize (time, label) { 25 | const roundedTime = Math.round(time) 26 | if (roundedTime === 1) { 27 | return roundedTime + label 28 | } 29 | return roundedTime + label + 's' 30 | } 31 | -------------------------------------------------------------------------------- /src/util/mixins.js: -------------------------------------------------------------------------------- 1 | function getTitle (vm) { 2 | const { title } = vm.$options 3 | if (title) { 4 | return typeof title === 'function' 5 | ? title.call(vm) 6 | : title 7 | } 8 | } 9 | 10 | export const titleMixin = { 11 | mounted () { 12 | const title = getTitle(this) 13 | if (title) { 14 | document.title = `Vue HN | ${title}` 15 | } 16 | } 17 | } 18 | 19 | export const HTTPStatusMixin = { 20 | created () { 21 | if (this.$ssrContext && this.$options.HTTPStatus) { 22 | this.$ssrContext.HTTPStatus = this.$options.HTTPStatus 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/views/ItemList.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 82 | 83 | 120 | -------------------------------------------------------------------------------- /src/views/ItemView.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 67 | 68 | 114 | -------------------------------------------------------------------------------- /src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 | 25 | -------------------------------------------------------------------------------- /src/views/UserView.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 41 | 42 | 67 | -------------------------------------------------------------------------------- /src/views/__tests__/ItemList.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | shallowMount, 3 | createLocalVue, 4 | RouterLinkStub 5 | } from '@vue/test-utils' 6 | import Vuex from 'vuex' 7 | import flushPromises from 'flush-promises' 8 | import merge from 'lodash.merge' 9 | import ItemList from '../ItemList.vue' 10 | import Item from '../../components/Item.vue' 11 | 12 | const localVue = createLocalVue() 13 | localVue.use(Vuex) 14 | 15 | describe('ItemList.vue', () => { 16 | function createStore (overrides) { 17 | const defaultStoreConfig = { 18 | getters: { 19 | displayItems: jest.fn() 20 | }, 21 | actions: { 22 | fetchListData: jest.fn(() => Promise.resolve()) 23 | } 24 | } 25 | return new Vuex.Store( 26 | merge(defaultStoreConfig, overrides) 27 | ) 28 | } 29 | 30 | function createWrapper (overrides) { 31 | const defaultMountingOptions = { 32 | mocks: { 33 | $bar: { 34 | start: jest.fn(), 35 | finish: jest.fn(), 36 | fail: jest.fn() 37 | }, 38 | $route: { 39 | params: { type: 'top' } 40 | } 41 | }, 42 | stubs: { 43 | RouterLink: RouterLinkStub 44 | }, 45 | localVue, 46 | store: createStore() 47 | } 48 | return shallowMount(ItemList, merge(defaultMountingOptions, overrides)) 49 | } 50 | 51 | test('renders an Item with data for each item in displayItems', () => { 52 | const items = [{}, {}, {}] 53 | const store = createStore({ 54 | getters: { 55 | displayItems: () => items 56 | } 57 | }) 58 | 59 | const wrapper = createWrapper({ store }) 60 | const Items = wrapper.findAll(Item) 61 | expect(Items).toHaveLength(items.length) 62 | Items.wrappers.forEach((wrapper, i) => { 63 | expect(wrapper.vm.item).toBe(items[i]) 64 | }) 65 | }) 66 | 67 | test('calls $bar start on load', () => { 68 | const mocks = { 69 | $bar: { 70 | start: jest.fn() 71 | } 72 | } 73 | createWrapper({ mocks }) 74 | expect(mocks.$bar.start).toHaveBeenCalled() 75 | }) 76 | 77 | test('calls $bar finish when load successful', async () => { 78 | expect.assertions(1) 79 | const mocks = { 80 | $bar: { 81 | finish: jest.fn() 82 | } 83 | } 84 | createWrapper({ mocks }) 85 | await flushPromises() 86 | expect(mocks.$bar.finish).toHaveBeenCalled() 87 | }) 88 | 89 | test('dispatches fetchListData with $route.params.type', async () => { 90 | expect.assertions(1) 91 | const store = createStore() 92 | store.dispatch = jest.fn(() => Promise.resolve()) 93 | const type = 'a type' 94 | const mocks = { 95 | $route: { 96 | params: { 97 | type 98 | } 99 | } 100 | } 101 | createWrapper({ store, mocks }) 102 | await flushPromises() 103 | expect(store.dispatch).toHaveBeenCalledWith('fetchListData', { type }) 104 | }) 105 | 106 | test('calls $bar fail when fetchListData throws', async () => { 107 | const store = createStore({ 108 | actions: { fetchListData: jest.fn(() => Promise.reject()) } 109 | }) 110 | const mocks = { 111 | $bar: { 112 | fail: jest.fn() 113 | } 114 | } 115 | createWrapper({ mocks, store }) 116 | await flushPromises() 117 | expect(mocks.$bar.fail).toHaveBeenCalled() 118 | }) 119 | 120 | test('renders 1/5 when on page 1 of 5', () => { 121 | const store = createStore({ 122 | getters: { 123 | maxPage: () => 5 124 | } 125 | }) 126 | const wrapper = createWrapper({ store }) 127 | expect(wrapper.text()).toContain('1/5') 128 | }) 129 | 130 | test('renders 2/5 when on page 2 of 5', () => { 131 | const store = createStore({ 132 | getters: { 133 | maxPage: () => 5 134 | } 135 | }) 136 | const mocks = { 137 | $route: { 138 | params: { 139 | page: '2' 140 | } 141 | } 142 | } 143 | const wrapper = createWrapper({ mocks, store }) 144 | expect(wrapper.text()).toContain('2/5') 145 | }) 146 | 147 | test('calls $router.replace when the page parameter is greater than the max page count', async () => { 148 | expect.assertions(1) 149 | const store = createStore({ 150 | getters: { 151 | maxPage: () => 5 152 | } 153 | }) 154 | const mocks = { 155 | $route: { 156 | params: { 157 | page: '1000' 158 | } 159 | }, 160 | $router: { 161 | replace: jest.fn() 162 | } 163 | } 164 | createWrapper({ mocks, store }) 165 | await flushPromises() 166 | expect(mocks.$router.replace).toHaveBeenCalledWith('/top/1') 167 | }) 168 | 169 | test('calls $router.replace when the page parameter is 0', async () => { 170 | expect.assertions(1) 171 | const mocks = { 172 | $route: { 173 | params: { 174 | page: '0' 175 | } 176 | }, 177 | $router: { 178 | replace: jest.fn() 179 | } 180 | } 181 | createWrapper({ mocks }) 182 | await flushPromises() 183 | expect(mocks.$router.replace).toHaveBeenCalledWith('/top/1') 184 | }) 185 | 186 | test('calls $router.replace when the page parameter is not a number', async () => { 187 | expect.assertions(1) 188 | const mocks = { 189 | $route: { 190 | params: { 191 | page: 'abc' 192 | } 193 | }, 194 | $router: { 195 | replace: jest.fn() 196 | } 197 | } 198 | createWrapper({ mocks }) 199 | await flushPromises() 200 | expect(mocks.$router.replace).toHaveBeenCalledWith('/top/1') 201 | }) 202 | 203 | test('renders a RouterLink with the previous page if one exists', () => { 204 | const mocks = { 205 | $route: { 206 | params: { page: '2' } 207 | } 208 | } 209 | const wrapper = createWrapper({ mocks }) 210 | 211 | expect(wrapper.find(RouterLinkStub).props().to).toBe('/top/1') 212 | expect(wrapper.find(RouterLinkStub).text()).toBe('< prev') 213 | }) 214 | 215 | test('renders a RouterLink with the next page if one exists', () => { 216 | const store = createStore({ 217 | getters: { 218 | maxPage: () => 3 219 | } 220 | }) 221 | const mocks = { 222 | $route: { 223 | params: { page: '1' } 224 | } 225 | } 226 | const wrapper = createWrapper({ store, mocks }) 227 | expect(wrapper.find(RouterLinkStub).props().to).toBe('/top/2') 228 | expect(wrapper.find(RouterLinkStub).text()).toBe('more >') 229 | }) 230 | 231 | test('renders a RouterLink with the next page when no page param exists', () => { 232 | const store = createStore({ 233 | getters: { 234 | maxPage: () => 3 235 | } 236 | }) 237 | const wrapper = createWrapper({ store 238 | }) 239 | expect(wrapper.find(RouterLinkStub).props().to).toBe('/top/2') 240 | expect(wrapper.find(RouterLinkStub).text()).toBe('more >') 241 | }) 242 | 243 | test('renders an element without an href if there are no previous pages', () => { 244 | const wrapper = createWrapper() 245 | 246 | expect(wrapper.find('a').attributes().href).toBe(undefined) 247 | expect(wrapper.find('a').text()).toBe('< prev') 248 | }) 249 | 250 | test('renders an element without an href if there are no next pages', () => { 251 | const store = createStore({ 252 | getters: { 253 | maxPage: () => 1 254 | } 255 | }) 256 | const wrapper = createWrapper({ store }) 257 | 258 | expect(wrapper.findAll('a').at(1).attributes().href).toBe(undefined) 259 | expect(wrapper.findAll('a').at(1).text()).toBe('more >') 260 | }) 261 | 262 | test('sets document.title with the capitalized type prop', () => { 263 | createWrapper({ 264 | mocks: { 265 | $route: { params: { type: 'top' } } 266 | } 267 | }) 268 | expect(document.title).toBe('Vue HN | Top') 269 | }) 270 | }) 271 | -------------------------------------------------------------------------------- /src/views/__tests__/ItemView.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | shallowMount, 3 | createLocalVue, 4 | RouterLinkStub 5 | } from '@vue/test-utils' 6 | import Vuex from 'vuex' 7 | import flushPromises from 'flush-promises' 8 | import merge from 'lodash.merge' 9 | import ItemView from '../ItemView.vue' 10 | import Spinner from '../../components/Spinner.vue' 11 | import Comment from '../../components/Comment.vue' 12 | 13 | const localVue = createLocalVue() 14 | localVue.use(Vuex) 15 | 16 | function createStore (overrides) { 17 | const defaultStoreConfig = { 18 | actions: { 19 | fetchComments: jest.fn(() => Promise.resolve()), 20 | fetchItem: jest.fn(() => Promise.resolve()) 21 | }, 22 | state: { 23 | comments: {}, 24 | item: {} 25 | } 26 | } 27 | return new Vuex.Store( 28 | merge(defaultStoreConfig, overrides) 29 | ) 30 | } 31 | 32 | function createWrapper (overrides) { 33 | const defaultMountingOptions = { 34 | mocks: { 35 | $route: { 36 | params: {} 37 | } 38 | }, 39 | stubs: { 40 | RouterLink: RouterLinkStub 41 | }, 42 | localVue, 43 | store: createStore() 44 | } 45 | return shallowMount(ItemView, merge(defaultMountingOptions, overrides)) 46 | } 47 | 48 | describe('ItemView.vue', () => { 49 | test('dispatches fetchItem with id', () => { 50 | const store = createStore() 51 | jest.spyOn(store, 'dispatch') 52 | const mocks = { 53 | $route: { params: { id: 'abc' } } 54 | } 55 | createWrapper({ store, mocks }) 56 | expect(store.dispatch).toHaveBeenCalledWith('fetchItem', { 57 | id: 'abc' 58 | }) 59 | }) 60 | 61 | test('renders a comment for each comment', async () => { 62 | expect.assertions(1) 63 | const store = createStore() 64 | const wrapper = createWrapper({ store }) 65 | store.state.item = { 66 | kids: ['a1', 'a2'] 67 | } 68 | await flushPromises() 69 | expect(wrapper.findAll(Comment)).toHaveLength(2) 70 | }) 71 | 72 | test('renders spinner if item has comments', () => { 73 | const store = createStore({ 74 | state: { 75 | item: { 76 | kids: ['a1'] 77 | } 78 | } 79 | }) 80 | const wrapper = createWrapper({ store }) 81 | expect(wrapper.find(Spinner).exists()).toBe(true) 82 | }) 83 | 84 | test('hides spinner when comments are loaded', async () => { 85 | expect.assertions(2) 86 | const store = createStore({ 87 | state: { 88 | item: { 89 | kids: ['a1'] 90 | } 91 | } 92 | }) 93 | const wrapper = createWrapper({ store }) 94 | expect(wrapper.find(Spinner).exists()).toBe(true) 95 | store.state.item = {} 96 | await flushPromises() 97 | expect(wrapper.find(Spinner).exists()).toBe(false) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/views/__tests__/NotFound.server.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { renderToString, render } from '@vue/server-test-utils' 6 | import NotFound from '../NotFound.vue' 7 | 8 | describe('NotFound', () => { 9 | test('renders correctly on server ', () => { 10 | const str = renderToString(NotFound) 11 | expect(str).toMatchSnapshot() 12 | }) 13 | test('renders 404 inside

    tag', () => { 14 | const wrapper = render(NotFound) 15 | expect(wrapper.find('h1').text()).toBe('404') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/views/__tests__/UserView.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | shallowMount, 3 | createLocalVue 4 | } from '@vue/test-utils' 5 | import Vuex from 'vuex' 6 | import merge from 'lodash.merge' 7 | import UserView from '../UserView.vue' 8 | 9 | const localVue = createLocalVue() 10 | 11 | localVue.use(Vuex) 12 | 13 | function createStore (overrides) { 14 | const defaultStoreConfig = { 15 | actions: { 16 | fetchUser: jest.fn(() => Promise.resolve()) 17 | }, 18 | state: { 19 | user: false 20 | } 21 | } 22 | return new Vuex.Store( 23 | merge(defaultStoreConfig, overrides) 24 | ) 25 | } 26 | 27 | function createWrapper (overrides) { 28 | const defaultMountingOptions = { 29 | mocks: { 30 | $route: { 31 | params: { id: '123' } 32 | } 33 | }, 34 | localVue, 35 | store: createStore() 36 | } 37 | return shallowMount(UserView, merge(defaultMountingOptions, overrides)) 38 | } 39 | 40 | describe('UserView.vue', () => { 41 | test('renders user not found if no user with id exists in store', () => { 42 | const wrapper = createWrapper() 43 | expect(wrapper.text()).toContain('User not found.') 44 | }) 45 | 46 | test('renders user.about as HTML if user exists in store', () => { 47 | const store = createStore({ 48 | state: { 49 | user: { 50 | about: '

    Example HTML

    ' 51 | } 52 | } 53 | }) 54 | const wrapper = createWrapper({ store }) 55 | expect(wrapper.find('.about').text()).toContain('Example HTML') 56 | }) 57 | 58 | test('dispatches fetchUser with id', () => { 59 | const store = createStore() 60 | const mocks = { 61 | $route: { 62 | params: { 63 | id: '123' 64 | } 65 | } 66 | } 67 | jest.spyOn(store, 'dispatch') 68 | createWrapper({ store, mocks }) 69 | expect(store.dispatch).toHaveBeenCalledWith( 70 | 'fetchUser', 71 | { id: '123' } 72 | ) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/views/__tests__/__snapshots__/NotFound.server.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`NotFound renders correctly on server 1`] = `"

    404

    The page you're looking for could not be found. Try selecting one of the lists in the nav bar.

    "`; 4 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { 3 | titleMixin 4 | } from './src/util/mixins' 5 | import { 6 | timeAgo, 7 | host 8 | } from './src/util/filters' 9 | 10 | Vue.config.productionTip = false 11 | 12 | Vue.mixin(titleMixin) 13 | Vue.filter('timeAgo', timeAgo) 14 | Vue.filter('host', host) 15 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') 2 | const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') 3 | const nodeExternals = require('webpack-node-externals') 4 | const merge = require('lodash.merge') 5 | 6 | const TARGET_NODE = process.env.WEBPACK_TARGET === 'node' 7 | 8 | const createApiFile = TARGET_NODE 9 | ? './create-api-server.js' 10 | : './create-api-client.js' 11 | 12 | const target = TARGET_NODE 13 | ? 'server' 14 | : 'client' 15 | 16 | module.exports = { 17 | lintOnSave: false, 18 | configureWebpack: () => ({ 19 | entry: `./src/entry-${target}`, 20 | target: TARGET_NODE ? 'node' : 'web', 21 | node: TARGET_NODE ? undefined : false, 22 | plugins: [ 23 | TARGET_NODE 24 | ? new VueSSRServerPlugin() 25 | : new VueSSRClientPlugin() 26 | ], 27 | externals: TARGET_NODE ? nodeExternals({ 28 | whitelist: /\.css$/ 29 | }) : undefined, 30 | output: { 31 | libraryTarget: TARGET_NODE 32 | ? 'commonjs2' 33 | : undefined 34 | }, 35 | optimization: { 36 | splitChunks: undefined 37 | }, 38 | performance: { 39 | hints: false 40 | }, 41 | resolve: { 42 | alias: { 43 | 'create-api': createApiFile 44 | } 45 | } 46 | }), 47 | chainWebpack: config => { 48 | config.module 49 | .rule('vue') 50 | .use('vue-loader') 51 | .tap(options => 52 | merge(options, { 53 | optimizeSSR: false 54 | }) 55 | ) 56 | } 57 | } 58 | --------------------------------------------------------------------------------