├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── README.md ├── build ├── build.js ├── css-loaders.js ├── dev-client.js ├── dev-server.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config.js ├── docker-compose.yml ├── index.html ├── package.json ├── src ├── App.vue ├── Chat.vue ├── Login.vue ├── assets │ └── logo.png ├── components │ ├── InputBar.vue │ ├── Message.vue │ └── Messages.vue ├── main.js ├── routes.js ├── services │ └── kuzzle.js ├── store │ ├── messages.js │ └── user.js └── style │ ├── global.css │ └── login.css ├── static └── .gitkeep └── test ├── e2e ├── custom-assertions │ └── elementCount.js ├── nightwatch.conf.js ├── runner.js └── specs │ └── test.js └── unit ├── .eslintrc ├── index.js ├── karma.conf.js └── specs └── Hello.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-runtime"], 4 | "comments": false 5 | } 6 | -------------------------------------------------------------------------------- /.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 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 4 | extends: 'standard', 5 | // required to lint *.vue files 6 | plugins: [ 7 | 'html' 8 | ], 9 | // add your custom rules here 10 | 'rules': { 11 | // allow paren-less arrow functions 12 | 'arrow-parens': 0, 13 | // allow debugger during development 14 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | selenium-debug.log 6 | test/unit/coverage 7 | test/e2e/reports 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a Slack-clone with Vue.js and Kuzzle.io 2 | 3 | This repository contains the code you can build by following the tutorial at https://medium.com/@xbill82/d0c56ef9e6cb 4 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | // https://github.com/shelljs/shelljs 2 | require('shelljs/global') 3 | env.NODE_ENV = 'production' 4 | 5 | var path = require('path') 6 | var config = require('../config') 7 | var ora = require('ora') 8 | var webpack = require('webpack') 9 | var webpackConfig = require('./webpack.prod.conf') 10 | 11 | console.log( 12 | ' Tip:\n' + 13 | ' Built files are meant to be served over an HTTP server.\n' + 14 | ' Opening index.html over file:// won\'t work.\n' 15 | ) 16 | 17 | var spinner = ora('building for production...') 18 | spinner.start() 19 | 20 | var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) 21 | rm('-rf', assetsPath) 22 | mkdir('-p', assetsPath) 23 | cp('-R', 'static/', assetsPath) 24 | 25 | webpack(webpackConfig, function (err, stats) { 26 | spinner.stop() 27 | if (err) throw err 28 | process.stdout.write(stats.toString({ 29 | colors: true, 30 | modules: false, 31 | children: false, 32 | chunks: false, 33 | chunkModules: false 34 | }) + '\n') 35 | }) 36 | -------------------------------------------------------------------------------- /build/css-loaders.js: -------------------------------------------------------------------------------- 1 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 2 | 3 | module.exports = function (options) { 4 | options = options || {} 5 | // generate loader string to be used with extract text plugin 6 | function generateLoaders (loaders) { 7 | var sourceLoader = loaders.map(function (loader) { 8 | var extraParamChar 9 | if (/\?/.test(loader)) { 10 | loader = loader.replace(/\?/, '-loader?') 11 | extraParamChar = '&' 12 | } else { 13 | loader = loader + '-loader' 14 | extraParamChar = '?' 15 | } 16 | return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '') 17 | }).join('!') 18 | 19 | if (options.extract) { 20 | return ExtractTextPlugin.extract('vue-style-loader', sourceLoader) 21 | } else { 22 | return ['vue-style-loader', sourceLoader].join('!') 23 | } 24 | } 25 | 26 | // http://vuejs.github.io/vue-loader/configurations/extract-css.html 27 | return { 28 | css: generateLoaders(['css']), 29 | postcss: generateLoaders(['css']), 30 | less: generateLoaders(['css', 'less']), 31 | sass: generateLoaders(['css', 'sass?indentedSyntax']), 32 | scss: generateLoaders(['css', 'sass']), 33 | stylus: generateLoaders(['css', 'stylus']), 34 | styl: generateLoaders(['css', 'stylus']) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /build/dev-client.js: -------------------------------------------------------------------------------- 1 | require('eventsource-polyfill') 2 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 3 | 4 | hotClient.subscribe(function (event) { 5 | if (event.action === 'reload') { 6 | window.location.reload() 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /build/dev-server.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var express = require('express') 3 | var webpack = require('webpack') 4 | var config = require('../config') 5 | var proxyMiddleware = require('http-proxy-middleware') 6 | var webpackConfig = process.env.NODE_ENV === 'testing' 7 | ? require('./webpack.prod.conf') 8 | : require('./webpack.dev.conf') 9 | 10 | // default port where dev server listens for incoming traffic 11 | var port = process.env.PORT || config.dev.port 12 | // Define HTTP proxies to your custom API backend 13 | // https://github.com/chimurai/http-proxy-middleware 14 | var proxyTable = config.dev.proxyTable 15 | 16 | var app = express() 17 | var compiler = webpack(webpackConfig) 18 | 19 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 20 | publicPath: webpackConfig.output.publicPath, 21 | stats: { 22 | colors: true, 23 | chunks: false 24 | } 25 | }) 26 | 27 | var hotMiddleware = require('webpack-hot-middleware')(compiler) 28 | // force page reload when html-webpack-plugin template changes 29 | compiler.plugin('compilation', function (compilation) { 30 | compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { 31 | hotMiddleware.publish({ action: 'reload' }) 32 | cb() 33 | }) 34 | }) 35 | 36 | // proxy api requests 37 | Object.keys(proxyTable).forEach(function (context) { 38 | var options = proxyTable[context] 39 | if (typeof options === 'string') { 40 | options = { target: options } 41 | } 42 | app.use(proxyMiddleware(context, options)) 43 | }) 44 | 45 | // handle fallback for HTML5 history API 46 | app.use(require('connect-history-api-fallback')()) 47 | 48 | // serve webpack bundle output 49 | app.use(devMiddleware) 50 | 51 | // enable hot-reload and state-preserving 52 | // compilation error display 53 | app.use(hotMiddleware) 54 | 55 | // serve pure static assets 56 | var staticPath = path.join(config.build.assetsPublicPath, config.build.assetsSubDirectory) 57 | app.use(staticPath, express.static('./static')) 58 | 59 | module.exports = app.listen(port, function (err) { 60 | if (err) { 61 | console.log(err) 62 | return 63 | } 64 | console.log('Listening at http://localhost:' + port + '\n') 65 | }) 66 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var cssLoaders = require('./css-loaders') 4 | var projectRoot = path.resolve(__dirname, '../') 5 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 6 | 7 | module.exports = { 8 | entry: { 9 | app: './src/main.js' 10 | }, 11 | output: { 12 | path: config.build.assetsRoot, 13 | publicPath: config.build.assetsPublicPath, 14 | filename: '[name].js' 15 | }, 16 | resolve: { 17 | extensions: ['', '.js', '.vue'], 18 | fallback: [path.join(__dirname, '../node_modules')], 19 | alias: { 20 | 'src': path.resolve(__dirname, '../src') 21 | } 22 | }, 23 | resolveLoader: { 24 | fallback: [path.join(__dirname, '../node_modules')] 25 | }, 26 | module: { 27 | preLoaders: [ 28 | { 29 | test: /\.vue$/, 30 | loader: 'eslint', 31 | include: projectRoot, 32 | exclude: /node_modules/ 33 | }, 34 | { 35 | test: /\.js$/, 36 | loader: 'eslint', 37 | include: projectRoot, 38 | exclude: /node_modules/ 39 | } 40 | ], 41 | loaders: [ 42 | { 43 | test: /\.vue$/, 44 | loader: 'vue' 45 | }, 46 | { 47 | test: /\.js$/, 48 | loader: 'babel', 49 | include: projectRoot, 50 | exclude: /node_modules/ 51 | }, 52 | { 53 | test: /\.json$/, 54 | loader: 'json' 55 | }, 56 | { 57 | test: /\.html$/, 58 | loader: 'vue-html' 59 | }, 60 | { 61 | test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf|otf)(\?.*)?$/, 62 | loader: 'url', 63 | query: { 64 | limit: 10000, 65 | name: path.join(config.build.assetsSubDirectory, '[name].[ext]?[hash:7]') 66 | } 67 | }, 68 | { 69 | test: /\.css$/, 70 | loader: ExtractTextPlugin.extract('style-loader', 'css-loader') 71 | } 72 | ] 73 | }, 74 | vue: { 75 | loaders: cssLoaders() 76 | }, 77 | eslint: { 78 | formatter: require('eslint-friendly-formatter') 79 | }, 80 | plugins: [ 81 | new ExtractTextPlugin('[name].css') 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var merge = require('webpack-merge') 3 | var baseWebpackConfig = require('./webpack.base.conf') 4 | var HtmlWebpackPlugin = require('html-webpack-plugin') 5 | 6 | // add hot-reload related code to entry chunks 7 | Object.keys(baseWebpackConfig.entry).forEach(function (name) { 8 | baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) 9 | }) 10 | 11 | module.exports = merge(baseWebpackConfig, { 12 | // eval-source-map is faster for development 13 | devtool: '#eval-source-map', 14 | plugins: [ 15 | // https://github.com/glenjamin/webpack-hot-middleware#installation--usage 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin(), 19 | // https://github.com/ampedandwired/html-webpack-plugin 20 | new HtmlWebpackPlugin({ 21 | filename: 'index.html', 22 | template: 'index.html', 23 | inject: true 24 | }) 25 | ] 26 | }) 27 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var webpack = require('webpack') 4 | var merge = require('webpack-merge') 5 | var baseWebpackConfig = require('./webpack.base.conf') 6 | var cssLoaders = require('./css-loaders') 7 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 8 | var HtmlWebpackPlugin = require('html-webpack-plugin') 9 | 10 | module.exports = merge(baseWebpackConfig, { 11 | devtool: config.build.productionSourceMap ? '#source-map' : false, 12 | output: { 13 | path: config.build.assetsRoot, 14 | filename: path.join(config.build.assetsSubDirectory, '[name].[chunkhash].js'), 15 | chunkFilename: path.join(config.build.assetsSubDirectory, '[id].[chunkhash].js') 16 | }, 17 | vue: { 18 | loaders: cssLoaders({ 19 | sourceMap: config.build.productionSourceMap, 20 | extract: true 21 | }) 22 | }, 23 | plugins: [ 24 | // http://vuejs.github.io/vue-loader/workflow/production.html 25 | new webpack.DefinePlugin({ 26 | 'process.env': { 27 | NODE_ENV: '"production"' 28 | } 29 | }), 30 | new webpack.optimize.UglifyJsPlugin({ 31 | compress: { 32 | warnings: false 33 | } 34 | }), 35 | new webpack.optimize.OccurenceOrderPlugin(), 36 | // extract css into its own file 37 | new ExtractTextPlugin(path.join(config.build.assetsSubDirectory, '[name].[contenthash].css')), 38 | // generate dist index.html with correct asset hash for caching. 39 | // you can customize output by editing /index.html 40 | // see https://github.com/ampedandwired/html-webpack-plugin 41 | new HtmlWebpackPlugin({ 42 | filename: process.env.NODE_ENV === 'testing' 43 | ? 'index.html' 44 | : config.build.index, 45 | template: 'index.html', 46 | inject: true, 47 | minify: { 48 | removeComments: true, 49 | collapseWhitespace: true, 50 | removeAttributeQuotes: true 51 | // more options: 52 | // https://github.com/kangax/html-minifier#options-quick-reference 53 | } 54 | }) 55 | ] 56 | }) 57 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | index: path.resolve(__dirname, 'dist/index.html'), 7 | assetsRoot: path.resolve(__dirname, 'dist'), 8 | assetsSubDirectory: 'static', 9 | assetsPublicPath: '/', 10 | productionSourceMap: true 11 | }, 12 | dev: { 13 | port: 8080, 14 | proxyTable: {} 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | kuzzle: 2 | image: kuzzleio/kuzzle:alpine 3 | ports: 4 | - "7511:7511" 5 | - "7512:7512" 6 | links: 7 | - elasticsearch 8 | - redis 9 | environment: 10 | - LIKE_A_VIRGIN 11 | - FIXTURES 12 | - DEFAULT_MAPPING 13 | - FEATURE_COVERAGE 14 | 15 | redis: 16 | image: redis:3.0-alpine 17 | 18 | elasticsearch: 19 | image: kuzzleio/elasticsearch:2.2 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | medium-klack 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medium-klack", 3 | "version": "0.1.0", 4 | "description": "A Vue.js project", 5 | "author": "Luca Marchesini ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node build/dev-server.js", 9 | "build": "node build/build.js", 10 | "unit": "karma start test/unit/karma.conf.js --single-run", 11 | "e2e": "node test/e2e/runner.js", 12 | "test": "npm run unit && npm run e2e" 13 | }, 14 | "dependencies": { 15 | "babel-runtime": "^5.8.0", 16 | "kuzzle-sdk": "^1.7.4", 17 | "vue": "^1.0.18", 18 | "vue-router": "^0.7.13" 19 | }, 20 | "devDependencies": { 21 | "babel-core": "^6.0.0", 22 | "babel-loader": "^6.0.0", 23 | "babel-plugin-transform-runtime": "^6.0.0", 24 | "babel-preset-es2015": "^6.0.0", 25 | "babel-preset-stage-2": "^6.0.0", 26 | "chai": "^3.5.0", 27 | "chromedriver": "^2.21.2", 28 | "connect-history-api-fallback": "^1.1.0", 29 | "cross-spawn": "^2.1.5", 30 | "css-loader": "^0.23.1", 31 | "eslint": "^2.0.0", 32 | "eslint-config-standard": "^5.1.0", 33 | "eslint-friendly-formatter": "^1.2.2", 34 | "eslint-loader": "^1.3.0", 35 | "eslint-plugin-html": "^1.3.0", 36 | "eslint-plugin-promise": "^1.0.8", 37 | "eslint-plugin-standard": "^1.3.2", 38 | "eventsource-polyfill": "^0.9.6", 39 | "express": "^4.13.3", 40 | "extract-text-webpack-plugin": "^1.0.1", 41 | "file-loader": "^0.8.4", 42 | "function-bind": "^1.0.2", 43 | "html-webpack-plugin": "^2.8.1", 44 | "http-proxy-middleware": "^0.12.0", 45 | "inject-loader": "^2.0.1", 46 | "isparta-loader": "^2.0.0", 47 | "json-loader": "^0.5.4", 48 | "karma": "^0.13.15", 49 | "karma-coverage": "^0.5.5", 50 | "karma-mocha": "^0.2.2", 51 | "karma-phantomjs-launcher": "^1.0.0", 52 | "karma-sinon-chai": "^1.2.0", 53 | "karma-sourcemap-loader": "^0.3.7", 54 | "karma-spec-reporter": "0.0.24", 55 | "karma-webpack": "^1.7.0", 56 | "lolex": "^1.4.0", 57 | "mocha": "^2.4.5", 58 | "nightwatch": "^0.8.18", 59 | "ora": "^0.2.0", 60 | "phantomjs-prebuilt": "^2.1.3", 61 | "selenium-server": "2.53.0", 62 | "shelljs": "^0.6.0", 63 | "sinon": "^1.17.3", 64 | "sinon-chai": "^2.8.0", 65 | "style-loader": "^0.13.1", 66 | "url-loader": "^0.5.7", 67 | "vue-hot-reload-api": "^1.2.0", 68 | "vue-html-loader": "^1.0.0", 69 | "vue-loader": "^8.2.1", 70 | "vue-style-loader": "^1.0.0", 71 | "webpack": "^1.12.2", 72 | "webpack-dev-middleware": "^1.4.0", 73 | "webpack-hot-middleware": "^2.6.0", 74 | "webpack-merge": "^0.8.3" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 13 | -------------------------------------------------------------------------------- /src/Chat.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 46 | 47 | 50 | -------------------------------------------------------------------------------- /src/Login.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xbill82/slack-vuejs-kuzzleio/ed9d612e817baabe46c635b2d89da37f0eb71c11/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/InputBar.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | -------------------------------------------------------------------------------- /src/components/Message.vue: -------------------------------------------------------------------------------- 1 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/Messages.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import {} from './style/global.css' 2 | import Vue from 'vue' 3 | import App from './App' 4 | import VueRouter from 'vue-router' 5 | import configRouter from './routes' 6 | import userStore from './store/user' 7 | 8 | Vue.use(VueRouter) 9 | export var router = new VueRouter() 10 | 11 | configRouter(router) 12 | userStore.getCurrentUser(() => { 13 | console.log('Starting...') 14 | router.start(App, 'body') 15 | }) 16 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import Chat from './Chat' 2 | import Login from './Login' 3 | import UserStore from './store/user' 4 | 5 | export default function (router) { 6 | router.map({ 7 | '/': { 8 | name: 'chat', 9 | component: Chat, 10 | auth: true 11 | }, 12 | '/login': { 13 | name: 'login', 14 | component: Login 15 | } 16 | }) 17 | 18 | router.beforeEach(function (transition) { 19 | if (transition.to.auth && !UserStore.isAuthenticated()) { 20 | transition.redirect('/login') 21 | } else { 22 | transition.next() 23 | } 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/services/kuzzle.js: -------------------------------------------------------------------------------- 1 | import Kuzzle from 'kuzzle-sdk' 2 | 3 | export default new Kuzzle('http://localhost:7512', {defaultIndex: 'klack'}) 4 | -------------------------------------------------------------------------------- /src/store/messages.js: -------------------------------------------------------------------------------- 1 | import kuzzle from '../services/kuzzle' 2 | 3 | export default { 4 | state: { 5 | messages: [] 6 | }, 7 | 8 | sendMessage (content, user) { 9 | let message = {content, user, date: Date.now()} 10 | kuzzle 11 | .dataCollectionFactory('messages') 12 | .createDocument(message) 13 | }, 14 | 15 | subscribeMessages () { 16 | kuzzle 17 | .dataCollectionFactory('messages') 18 | .subscribe({}, null, (error, response) => { 19 | if (error) { 20 | console.log(error.message) 21 | return 22 | } 23 | this.state.messages.push({ 24 | ...response.result._source, 25 | id: response.result._id 26 | }) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/store/user.js: -------------------------------------------------------------------------------- 1 | import {router} from '../main' 2 | import kuzzle from '../services/kuzzle' 3 | 4 | export default { 5 | state: { 6 | errorMessage: null, 7 | username: null 8 | }, 9 | 10 | login (username, password) { 11 | this.state.errorMessage = null 12 | kuzzle.login('local', {username, password}, '1h', (error, response) => { 13 | if (error) { 14 | this.state.errorMessage = error.message 15 | return 16 | } 17 | if (response.jwt) { 18 | window.sessionStorage.setItem('jwt', response.jwt) 19 | this.getCurrentUser((error, user) => { 20 | if (error) { 21 | console.error(error.message) 22 | return 23 | } 24 | router.go({name: 'chat'}) 25 | }) 26 | } 27 | }) 28 | }, 29 | 30 | isAuthenticated () { 31 | return Boolean(this.state.username) 32 | }, 33 | 34 | getCurrentUser (cb) { 35 | var jwt = window.sessionStorage.getItem('jwt') 36 | if (!jwt) { 37 | cb('No current user.') 38 | kuzzle.setJwtToken(undefined) 39 | return false 40 | } 41 | kuzzle.setJwtToken(jwt) 42 | 43 | kuzzle 44 | .whoAmI((error, kuzzleUser) => { 45 | if (error) { 46 | window.sessionStorage.removeItem('jwt') 47 | kuzzle.setJwtToken(undefined) 48 | cb(error) 49 | 50 | return false 51 | } 52 | 53 | this.state.username = kuzzleUser.id 54 | 55 | cb(null, kuzzleUser) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/style/global.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | a, abbr, acronym, address, big, cite, code, 4 | del, dfn, em, img, ins, kbd, q, s, samp, 5 | small, strike, strong, sub, sup, tt, var, 6 | b, u, i, center, 7 | dl, dt, dd, ol, ul, li, 8 | fieldset, form, label, legend, 9 | table, caption, tbody, tfoot, thead, tr, th, td, 10 | article, aside, canvas, details, embed, 11 | figure, figcaption, footer, header, 12 | menu, nav, output, ruby, section, summary, 13 | time, mark, audio, video { 14 | margin: 0; 15 | padding: 0; 16 | border: 0; 17 | font: inherit; 18 | font-size: 100%; 19 | vertical-align: baseline; 20 | } 21 | 22 | html { 23 | line-height: 1; 24 | } 25 | 26 | ol, ul { 27 | list-style: none; 28 | } 29 | 30 | table { 31 | border-collapse: collapse; 32 | border-spacing: 0; 33 | } 34 | 35 | caption, th, td { 36 | text-align: left; 37 | font-weight: normal; 38 | vertical-align: middle; 39 | } 40 | 41 | q, blockquote { 42 | quotes: none; 43 | } 44 | 45 | q:before, q:after, blockquote:before, blockquote:after { 46 | content: ""; 47 | content: none; 48 | } 49 | 50 | a img { 51 | border: none; 52 | } 53 | 54 | article, aside, details, figcaption, figure, footer, header, main, menu, nav, section, summary { 55 | display: block; 56 | } 57 | 58 | * { 59 | box-sizing: border-box; 60 | } 61 | 62 | html { 63 | position: relative; 64 | height: 100%; 65 | font-size: 16px; 66 | font-family: 'Lato', sans-serif; 67 | } 68 | 69 | body { 70 | height: 100%; 71 | width: 100%; 72 | margin: 0; 73 | padding-top: 53px; 74 | padding-bottom: 64px; 75 | background-color: #F3F3F3; 76 | } 77 | 78 | .header { 79 | position: fixed; 80 | top: 0; 81 | left: 0; 82 | height: 53px; 83 | width: 100%; 84 | } 85 | 86 | .main { 87 | height: 100%; 88 | width: 80%; 89 | } 90 | 91 | .footer { 92 | position: absolute; 93 | left: 0; 94 | bottom: 0; 95 | height: 64px; 96 | } 97 | 98 | .team-menu { 99 | position: relative; 100 | width: 220px; 101 | height: 53px; 102 | line-height: 53px; 103 | font-weight: 900; 104 | padding: 0 1rem; 105 | color: #ffffff; 106 | background: #3e313c; 107 | border-bottom: 2px solid #372c36; 108 | float: left; 109 | } 110 | 111 | .team-menu .status { 112 | color: red; 113 | } 114 | 115 | .team-menu .status.connected { 116 | color: green; 117 | } 118 | 119 | .team-menu .logout { 120 | font-weight: normal; 121 | float: right; 122 | color: #B7AFB7; 123 | text-decoration: underline; 124 | } 125 | 126 | .channel-menu-name { 127 | display: inline-block; 128 | padding: 0 .5rem 0 1.5rem; 129 | color: #555459; 130 | font-size: 1.4rem; 131 | font-weight: 900; 132 | line-height: 53px; 133 | } 134 | 135 | .channel-menu-prefix { 136 | color: #9e9ea6; 137 | padding-right: .1rem; 138 | font-weight: 500; 139 | } 140 | 141 | .listings { 142 | height: 100%; 143 | width: 220px; 144 | float: left; 145 | color: #ab9ba9; 146 | background-color: #4d394b; 147 | overflow-y: auto; 148 | overflow-x: hidden; 149 | } 150 | 151 | .message-history { 152 | margin-left: 220px; 153 | overflow-y: auto; 154 | overflow-x: hidden; 155 | height: 100%; 156 | padding: 5px 18px 1rem 1.5rem; 157 | } 158 | 159 | .listings-channels { 160 | margin: 1rem 0 2rem; 161 | } 162 | 163 | .listings-header { 164 | text-align: left; 165 | font-size: .8rem; 166 | line-height: 1.25rem; 167 | margin: 0 1rem .1rem; 168 | text-transform: uppercase; 169 | font-weight: 700; 170 | color: #ab9ba9; 171 | width: 165px; 172 | position: relative; 173 | } 174 | 175 | .channel-list { 176 | list-style-type: none; 177 | text-align: left; 178 | color: #ab9ba9; 179 | } 180 | 181 | .channel { 182 | height: 24px; 183 | line-height: 24px; 184 | -moz-border-radius-topright: 0.25rem; 185 | -webkit-border-top-right-radius: 0.25rem; 186 | border-top-right-radius: 0.25rem; 187 | -moz-border-radius-bottomright: 0.25rem; 188 | -webkit-border-bottom-right-radius: 0.25rem; 189 | border-bottom-right-radius: 0.25rem; 190 | margin-right: 17px; 191 | padding-left: 1rem; 192 | cursor: pointer; 193 | } 194 | 195 | .channel a { 196 | color: #ffffff; 197 | text-decoration: none; 198 | } 199 | 200 | .unread { 201 | color: #ffffff; 202 | background: #eb4d5c; 203 | border-radius: 9px; 204 | padding: 2px 9px; 205 | font-size: .8rem; 206 | line-height: 14px; 207 | font-weight: 700; 208 | vertical-align: baseline; 209 | white-space: nowrap; 210 | text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); 211 | float: right; 212 | margin-right: 3px; 213 | margin-top: 3px; 214 | } 215 | 216 | .channel.active { 217 | background: #4c9689; 218 | } 219 | 220 | .channel-prefix { 221 | color: #b2d5c9; 222 | } 223 | 224 | .disclaimer { 225 | font-size: 0.8rem; 226 | padding-left: 1rem; 227 | margin-right: 17px; 228 | } 229 | 230 | .message { 231 | position: relative; 232 | margin-top: .5rem; 233 | padding: 0.25rem 2rem 1.1rem 3rem; 234 | min-height: 36px; 235 | } 236 | 237 | .message:hover { 238 | background: #f9f9f9; 239 | } 240 | 241 | .action-hover-container { 242 | display: none; 243 | position: absolute; 244 | top: -13px; 245 | right: 18px; 246 | border: 1px solid rgba(0,0,0,.15); 247 | border-radius: 6px; 248 | overflow: hidden; 249 | -webkit-transition: border 50ms ease-in 0s; 250 | -moz-transition: border 50ms ease-in 0s; 251 | transition: border 50ms ease-in 0s; 252 | z-index: 97; 253 | background-color: #fff; 254 | } 255 | 256 | .action-hover-container .delete-message { 257 | border-radius: 0 6px 6px 0; 258 | border-right: none; 259 | cursor: pointer; 260 | box-shadow: 0 1px 1px rgba(0,0,0,.1); 261 | border-color: rgba(0,0,0,.3); 262 | padding: 5px 10px 5px 10px; 263 | display: inline-block; 264 | } 265 | 266 | .action-hover-container .delete-message:hover { 267 | color: #3aa3e3; 268 | } 269 | 270 | .message:hover .action-hover-container { 271 | display: block; 272 | } 273 | 274 | .message-profile-pic { 275 | position: absolute; 276 | left: 0; 277 | display: block; 278 | -moz-border-radius: 0.2rem; 279 | -webkit-border-radius: 0.2rem; 280 | border-radius: 0.2rem; 281 | width: 36px; 282 | height: 36px; 283 | background-size: cover; 284 | } 285 | 286 | .message-username { 287 | font-weight: 900; 288 | padding-right: .25rem; 289 | color: #3d3c40 !important; 290 | margin-left: 0; 291 | font-style: normal; 292 | text-decoration: none; 293 | } 294 | 295 | .message-timestamp { 296 | text-align: left; 297 | display: inline; 298 | position: relative; 299 | top: 0; 300 | left: 0; 301 | color: #babbbf; 302 | font-size: 12px; 303 | line-height: 1.2rem; 304 | width: 36px; 305 | margin-right: 0; 306 | margin-left: 0; 307 | } 308 | 309 | .message-content { 310 | color: #8b898f; 311 | display: block; 312 | min-height: 1rem; 313 | } 314 | 315 | .user-menu { 316 | float: left; 317 | width: 220px; 318 | cursor: pointer; 319 | background: #3e313c; 320 | border-top: 2px solid #372c36; 321 | padding: 7px 0 9px 8px; 322 | height: 4rem; 323 | position: fixed; 324 | bottom: 0; 325 | left: 0; 326 | } 327 | 328 | .user-menu-profile-pic { 329 | display: inline-block; 330 | float: left; 331 | -moz-border-radius: 0.2rem; 332 | -webkit-border-radius: 0.2rem; 333 | border-radius: 0.2rem; 334 | width: 48px; 335 | height: 48px; 336 | background-size: cover; 337 | margin-right: 8px; 338 | } 339 | 340 | .user-menu .user-menu-username { 341 | display: block; 342 | font-weight: 900; 343 | line-height: 1.5rem; 344 | margin-top: .2rem; 345 | width: 146px; 346 | height: 35px; 347 | } 348 | 349 | .user-menu span.user-menu-username { 350 | color: #fff; 351 | } 352 | 353 | .connection-icon { 354 | width: 12px; 355 | height: 12px; 356 | } 357 | 358 | .connection-status { 359 | color: #ab9ba9; 360 | } 361 | 362 | .input-box { 363 | height: 100%; 364 | margin-left: 220px; 365 | } 366 | 367 | #header-search-form { 368 | margin: 9pt 0 0; 369 | width: 309px; 370 | padding-right: 3rem; 371 | position: absolute; 372 | top: 0; 373 | right: 6px; 374 | } 375 | 376 | .search-form { 377 | padding-right: 118px !important; 378 | } 379 | 380 | .no-bottom-margin { 381 | margin-bottom: 0!important; 382 | } 383 | 384 | #header-search-form .search-input { 385 | margin-right: 0; 386 | outline: 0; 387 | } 388 | 389 | .search-form .search-input { 390 | padding: 2px 2rem; 391 | font-size: 15px; 392 | height: 1.9rem; 393 | width: 100%; 394 | border-radius: 40px; 395 | background: #FFF; 396 | border: 1px solid rgba(214,214,214,.61); 397 | } 398 | 399 | input.search-input { 400 | display: inline-block; 401 | margin-bottom: 0; 402 | margin-right: .5rem; 403 | border-radius: 1rem; 404 | width: 100%; 405 | padding: 0 .8rem; 406 | font-size: .9rem; 407 | } 408 | 409 | input.search-input { 410 | margin: 0; 411 | padding-left: 1.9rem!important; 412 | padding-right: 1.9rem!important; 413 | } 414 | 415 | .search-input-highlighted { 416 | position: relative; 417 | background-color: transparent!important; 418 | z-index: 2; 419 | text-rendering: optimizeSpeed; 420 | } 421 | 422 | .highlighter-underlay { 423 | outline: rgb(85, 84, 89) none 0; 424 | border-width: 1px; 425 | text-indent: 0px; 426 | font-size: 15px; 427 | line-height: normal; 428 | font-family: Slack-Lato, appleLogo, sans-serif; 429 | letter-spacing: 0; 430 | word-spacing: 0; 431 | font-weight: 400; 432 | margin: 2px 30px; 433 | width: 230px; 434 | top: 0; 435 | left: 0; 436 | } 437 | 438 | #header-search-form .highlighter-underlay { 439 | line-height: 24px!important; 440 | border-width: 1px!important; 441 | font-size: 15px!important; 442 | } 443 | 444 | .highlighter-underlay { 445 | border-color: transparent!important; 446 | border-style: solid; 447 | color: transparent!important; 448 | position: absolute; 449 | top: 0; 450 | left: 0; 451 | z-index: 1; 452 | -webkit-touch-callout: none; 453 | -webkit-user-select: none; 454 | -khtml-user-select: none; 455 | -moz-user-select: none; 456 | -ms-user-select: none; 457 | user-select: none; 458 | white-space: pre; 459 | overflow: hidden; 460 | text-rendering: optimizeSpeed; 461 | } 462 | 463 | #header-search-form.search-form .icon-search { 464 | opacity: .5; 465 | -moz-opacity: .5; 466 | -khtml-opacity: .5; 467 | top: 6px; 468 | left: 7px; 469 | color: #67686e; 470 | } 471 | 472 | #header-search-form.search-form .icon-search { 473 | top: 8px; 474 | left: 10px; 475 | text-shadow: none; 476 | } 477 | 478 | #client-ui .icon-search { 479 | position: absolute; 480 | font-size: .9rem; 481 | top: 3px; 482 | left: 7px; 483 | color: #CCC; 484 | } 485 | 486 | .search-form .icon-search { 487 | position: absolute; 488 | top: 6px; 489 | left: 11px; 490 | color: #babbbf; 491 | } 492 | 493 | .input-box-text { 494 | font-size: .95rem; 495 | width: 75%; 496 | margin-left: 2%; 497 | margin-bottom: auto; 498 | line-height: 1.2rem; 499 | border: 2px solid #e0e0e0; 500 | -moz-border-radius: 0.2rem; 501 | -webkit-border-radius: 0.2rem; 502 | border-radius: 0.2rem; 503 | -moz-background-clip: padding-box; 504 | -webkit-background-clip: padding-box; 505 | background-clip: padding-box; 506 | color: #3d3c40; 507 | -webkit-appearance: none; 508 | -webkit-box-shadow: none; 509 | -moz-box-shadow: none; 510 | box-shadow: none; 511 | outline: 0; 512 | bottom: 0; 513 | min-height: 41px; 514 | padding: 9px 5px 9px 8px; 515 | } 516 | 517 | .channels-list-new-btn { 518 | float: right; 519 | margin-right: 1pc; 520 | margin-top: -1px; 521 | font-size: 1rem; 522 | cursor: pointer; 523 | color: #ab9ba9; 524 | opacity: .6; 525 | -moz-opacity: .6; 526 | -khtml-opacity: .6; 527 | -webkit-transition: opacity 50ms; 528 | -moz-transition: opacity 50ms; 529 | -o-transition: opacity 50ms; 530 | transition: opacity 50ms; 531 | border-radius: 10px; 532 | padding: 2px 7px 3px 7px; 533 | background: #fff; 534 | } 535 | .channels-list-new-btn:hover { 536 | opacity: .8; 537 | -moz-opacity: .8; 538 | -khtml-opacity: .8; 539 | } 540 | 541 | .new-channel-name { 542 | margin-left: 9px; 543 | margin-top: 5px; 544 | padding: 5px 0 6px 5px; 545 | margin-bottom: 10px; 546 | } 547 | 548 | .channels-headline { 549 | z-index: 2; 550 | position: relative; 551 | background-color: #4D394B; 552 | } 553 | 554 | .new-channel { 555 | z-index: 1; 556 | } 557 | 558 | /* always present */ 559 | .expand-transition { 560 | transition: all .3s ease; 561 | height: 30px; 562 | padding: 10px; 563 | background-color: #eee; 564 | overflow: hidden; 565 | } 566 | 567 | /* .expand-enter defines the starting state for entering */ 568 | /* .expand-leave defines the ending state for leaving */ 569 | .expand-enter, .expand-leave { 570 | height: 0; 571 | padding: 0 10px; 572 | opacity: 0; 573 | } 574 | 575 | .search-right { 576 | position: absolute; 577 | top: 53px; 578 | width: 20%; 579 | right: 0; 580 | overflow: hidden; 581 | height: 84%; 582 | } 583 | 584 | .search-right .message-history { 585 | margin-left: 0; 586 | padding: 0 0 15px 0; 587 | } 588 | 589 | #app { 590 | height: 100%; 591 | } 592 | 593 | .bottom-input { 594 | position: absolute; 595 | left: 0; 596 | bottom: 0; 597 | height: 64px; 598 | width: 100%; 599 | } 600 | 601 | .messages { 602 | height: 100%; 603 | } -------------------------------------------------------------------------------- /src/style/login.css: -------------------------------------------------------------------------------- 1 | #login { 2 | width: 300px; 3 | height: 280px; 4 | margin: auto; 5 | border: 1px solid rgba(0,0,0,.15); 6 | border-radius: 6px; 7 | background-color: #fff; 8 | padding-top: 40px; 9 | } 10 | 11 | #login .title { 12 | margin-left: 10px; 13 | color: #3e313c; 14 | font-weight: bold; 15 | font-size: 1.5em; 16 | } 17 | 18 | #login input { 19 | display: block; 20 | width: 275px; 21 | margin: 20px auto 10px auto; 22 | } 23 | 24 | #login .login { 25 | border-radius: 6px; 26 | cursor: pointer; 27 | box-shadow: 0 1px 1px rgba(0,0,0,.1); 28 | padding: 5px 10px 5px 10px; 29 | border: 1px solid rgba(0,0,0,.15); 30 | display: inline-block; 31 | margin-left: 12px; 32 | background-color: #FBFBFB; 33 | } 34 | 35 | #login p.error { 36 | margin: 20px 0 0 10px; 37 | color: red; 38 | font-weight: bold; 39 | } -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xbill82/slack-vuejs-kuzzleio/ed9d612e817baabe46c635b2d89da37f0eb71c11/static/.gitkeep -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // the name of the method is the filename. 3 | // can be used in tests like this: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // for how to write custom assertions see 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | exports.assertion = function (selector, count) { 10 | this.message = 'Testing if element <' + selector + '> has count: ' + count 11 | this.expected = count 12 | this.pass = function (val) { 13 | return val === this.expected 14 | } 15 | this.value = function (res) { 16 | return res.value 17 | } 18 | this.command = function (cb) { 19 | var self = this 20 | return this.api.execute(function (selector) { 21 | return document.querySelectorAll(selector).length 22 | }, [selector], function (res) { 23 | cb.call(self, res) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | // http://nightwatchjs.org/guide#settings-file 2 | module.exports = { 3 | "src_folders": ["test/e2e/specs"], 4 | "output_folder": "test/e2e/reports", 5 | "custom_assertions_path": ["test/e2e/custom-assertions"], 6 | 7 | "selenium": { 8 | "start_process": true, 9 | "server_path": "node_modules/selenium-server/lib/runner/selenium-server-standalone-2.53.0.jar", 10 | "host": "127.0.0.1", 11 | "port": 4444, 12 | "cli_args": { 13 | "webdriver.chrome.driver": require('chromedriver').path 14 | } 15 | }, 16 | 17 | "test_settings": { 18 | "default": { 19 | "selenium_port": 4444, 20 | "selenium_host": "localhost", 21 | "silent": true 22 | }, 23 | 24 | "chrome": { 25 | "desiredCapabilities": { 26 | "browserName": "chrome", 27 | "javascriptEnabled": true, 28 | "acceptSslCerts": true 29 | } 30 | }, 31 | 32 | "firefox": { 33 | "desiredCapabilities": { 34 | "browserName": "firefox", 35 | "javascriptEnabled": true, 36 | "acceptSslCerts": true 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | var server = require('../../build/dev-server.js') 4 | 5 | // 2. run the nightwatch test suite against it 6 | // to run in additional browsers: 7 | // 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings" 8 | // 2. add it to the --env flag below 9 | // For more information on Nightwatch's config file, see 10 | // http://nightwatchjs.org/guide#settings-file 11 | var spawn = require('cross-spawn') 12 | var runner = spawn( 13 | './node_modules/.bin/nightwatch', 14 | [ 15 | '--config', 'test/e2e/nightwatch.conf.js', 16 | '--env', 'chrome,firefox' 17 | ], 18 | { 19 | stdio: 'inherit' 20 | } 21 | ) 22 | 23 | runner.on('exit', function (code) { 24 | server.close() 25 | process.exit(code) 26 | }) 27 | 28 | runner.on('error', function (err) { 29 | server.close() 30 | throw err 31 | }) 32 | -------------------------------------------------------------------------------- /test/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // For authoring Nightwatch tests, see 2 | // http://nightwatchjs.org/guide#usage 3 | 4 | module.exports = { 5 | 'default e2e tests': function (browser) { 6 | browser 7 | .url('http://localhost:8080') 8 | .waitForElementVisible('#app', 5000) 9 | .assert.elementPresent('.logo') 10 | .assert.containsText('h1', 'Hello World!') 11 | .assert.elementCount('p', 3) 12 | .end() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | // Polyfill fn.bind() for PhantomJS 2 | /* eslint-disable no-extend-native */ 3 | Function.prototype.bind = require('function-bind') 4 | 5 | // require all test files (files that ends with .spec.js) 6 | var testsContext = require.context('./specs', true, /\.spec$/) 7 | testsContext.keys().forEach(testsContext) 8 | 9 | // require all src files except main.js for coverage. 10 | // you can also change this to match only the subset of files that 11 | // you want coverage for. 12 | var srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) 13 | srcContext.keys().forEach(srcContext) 14 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | // This is a karma config file. For more details see 2 | // http://karma-runner.github.io/0.13/config/configuration-file.html 3 | // we are also using it with karma-webpack 4 | // https://github.com/webpack/karma-webpack 5 | 6 | var path = require('path') 7 | var merge = require('webpack-merge') 8 | var baseConfig = require('../../build/webpack.base.conf') 9 | var projectRoot = path.resolve(__dirname, '../../') 10 | 11 | var webpackConfig = merge(baseConfig, { 12 | // use inline sourcemap for karma-sourcemap-loader 13 | devtool: '#inline-source-map', 14 | vue: { 15 | loaders: { 16 | js: 'isparta' 17 | } 18 | } 19 | }) 20 | 21 | // no need for app entry during tests 22 | delete webpackConfig.entry 23 | 24 | // make sure isparta loader is applied before eslint 25 | webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || [] 26 | webpackConfig.module.preLoaders.unshift({ 27 | test: /\.js$/, 28 | loader: 'isparta', 29 | include: projectRoot, 30 | exclude: /test\/unit|node_modules/ 31 | }) 32 | 33 | // only apply babel for test files when using isparta 34 | webpackConfig.module.loaders.some(function (loader, i) { 35 | if (loader.loader === 'babel') { 36 | loader.include = /test\/unit/ 37 | return true 38 | } 39 | }) 40 | 41 | module.exports = function (config) { 42 | config.set({ 43 | // to run in additional browsers: 44 | // 1. install corresponding karma launcher 45 | // http://karma-runner.github.io/0.13/config/browsers.html 46 | // 2. add it to the `browsers` array below. 47 | browsers: ['PhantomJS'], 48 | frameworks: ['mocha', 'sinon-chai'], 49 | reporters: ['spec', 'coverage'], 50 | files: ['./index.js'], 51 | preprocessors: { 52 | './index.js': ['webpack', 'sourcemap'] 53 | }, 54 | webpack: webpackConfig, 55 | webpackMiddleware: { 56 | noInfo: true 57 | }, 58 | coverageReporter: { 59 | dir: './coverage', 60 | reporters: [ 61 | { type: 'lcov', subdir: '.' }, 62 | { type: 'text-summary' } 63 | ] 64 | } 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /test/unit/specs/Hello.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Hello from 'src/components/Hello' 3 | 4 | describe('Hello.vue', () => { 5 | it('should render correct contents', () => { 6 | const vm = new Vue({ 7 | template: '
', 8 | components: { Hello } 9 | }).$mount() 10 | expect(vm.$el.querySelector('.hello h1').textContent).to.contain('Hello World!') 11 | }) 12 | }) 13 | --------------------------------------------------------------------------------