diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..03c44eb --- /dev/null +++ b/.babelrc @@ -0,0 +1,27 @@ +{ + "presets": [ + ["env", { + "targets": { + "browsers": [ + "last 2 versions", + "ios_saf >= 8", + "ie >= 10", + "chrome >= 49", + "firefox >= 49", + "> 1%" + ] + }, + "debug": false, + "loose": true, + "useBuiltIns": true + }], + "react" + ], + "plugins": [ + "transform-class-properties", + [ + "transform-object-rest-spread", + { "useBuiltIns": true } + ] + ] +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..f3da86f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +*.min.js +coverage diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..d87e033 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,10 @@ +{ + "parser": "babel-eslint", + "extends": ["standard", "standard-react"], + "rules": { + "jsx-quotes": 0 + }, + "env": { + "jest": true + } +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..120d8c3 --- /dev/null +++ b/index.js @@ -0,0 +1,5 @@ +const app = require('./server')() +const port = process.env.PORT || 3000 +app.listen(port, () => { + console.log('Listening at http://localhost:' + port) +}) diff --git a/keep-alive.js b/keep-alive.js new file mode 100644 index 0000000..9623c23 --- /dev/null +++ b/keep-alive.js @@ -0,0 +1,19 @@ +module.exports = class KeepAlive { + constructor (callback, delay) { + this.callback = callback + this.delay = delay + } + + start () { + this.id = setInterval(this.callback, this.delay) + } + + stop () { + clearInterval(this.id) + } + + reset () { + this.stop() + this.start() + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..448e6f4 --- /dev/null +++ b/package.json @@ -0,0 +1,84 @@ +{ + "name": "smee-server", + "version": "0.0.3", + "description": "", + "author": "Jason Etcovitch (https://github.com/JasonEtco)", + "license": "ISC", + "repository": "https://github.com/probot/smee.git", + "scripts": { + "start": "node ./index.js", + "start-dev": "concurrently \"nodemon --ignore src/ ./index.js\" \"webpack -w\"", + "build": "webpack -p", + "test": "jest --coverage && eslint", + "postinstall": "npm run build" + }, + "dependencies": { + "autoprefixer": "^7.1.6", + "babel-core": "^6.26.0", + "babel-eslint": "^7.2.3", + "babel-loader": "^7.1.2", + "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-preset-env": "^1.6.1", + "babel-preset-react": "^6.24.1", + "connect-sse": "^1.2.0", + "copy-to-clipboard": "^3.0.8", + "copy-webpack-plugin": "^4.2.0", + "crypto": "^1.0.1", + "css-loader": "^0.28.7", + "eventsource": "^1.0.5", + "express": "^4.16.2", + "express-sslify": "^1.2.0", + "extract-text-webpack-plugin": "^3.0.1", + "get-value": "^2.0.6", + "glob-all": "^3.1.0", + "helmet": "^3.9.0", + "highlight.js": "^9.12.0", + "html-webpack-plugin": "^2.30.1", + "moment": "^2.19.1", + "moment-timezone": "^0.5.14", + "node-sass": "^4.5.3", + "postcss-loader": "^2.0.8", + "primer-css": "^9.6.0", + "prop-types": "^15.6.0", + "purify-css": "^1.2.5", + "purifycss-webpack": "^0.7.0", + "react": "^16.0.0", + "react-dom": "^16.0.0", + "react-json-view": "^1.13.2", + "react-octicons": "^0.2.0", + "sass-loader": "^6.0.6", + "style-loader": "^0.19.0", + "webpack": "^3.8.1" + }, + "devDependencies": { + "babel-jest": "^21.2.0", + "concurrently": "^3.5.0", + "enzyme": "^3.2.0", + "enzyme-adapter-react-16": "^1.1.0", + "eslint-config-standard": "^10.2.1", + "eslint-config-standard-react": "^5.0.0", + "eslint-plugin-import": "^2.8.0", + "eslint-plugin-node": "^5.2.1", + "eslint-plugin-promise": "^3.6.0", + "eslint-plugin-react": "^7.5.1", + "eslint-plugin-standard": "^3.0.1", + "jest": "^21.2.1", + "nodemon": "^1.12.1", + "raf": "^3.4.0", + "react-test-renderer": "^16.2.0", + "standard": "^10.0.3", + "supertest": "^3.0.0" + }, + "engines": { + "node": "8.9.1" + }, + "jest": { + "setupFiles": [ + "./tests/setup.js" + ], + "testPathIgnorePatterns": [ + "/node_modules/" + ] + } +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..619053a Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..57316c8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,49 @@ + + + + + + + smee.io + + + + +
+
+
+
+
+
+
+

smee.io

+

Webhook payload delivery service

+

Receives payloads then sends them to your locally running application.

+ Start a new channel +
+ +
+

If your application needs to respond to webhooks, you'll need some way to expose localhost to the internet. smee.io is a small service that uses Server-Sent Events to proxy payloads from the webhook source, then transmit them to your locally running application.

+ +
+
+ Webhook Emitter +
+
+ localhost +
+
+
+
+ +
+
+ +

Tell your webhook source to send payloads to your smee.io channel, then either use the smee CLI or, if you're using Probot to build a GitHub App, just set the environment variable.

+
+ + + + \ No newline at end of file diff --git a/public/webhooks.html b/public/webhooks.html new file mode 100644 index 0000000..025df50 --- /dev/null +++ b/public/webhooks.html @@ -0,0 +1,21 @@ + + + + + + + Webhooks + + + + + +
+ + + \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..0af737c --- /dev/null +++ b/server.js @@ -0,0 +1,97 @@ +const sse = require('connect-sse')() +const express = require('express') +const crypto = require('crypto') +const bodyParser = require('body-parser') +const EventEmitter = require('events') +const path = require('path') + +const KeepAlive = require('./keep-alive') + +// Tiny logger to prevent logs in tests +const log = process.env.NODE_ENV === 'test' ? _ => _ : console.log + +module.exports = () => { + const events = new EventEmitter() + const app = express() + const pubFolder = path.join(__dirname, 'public') + + if (process.env.FORCE_HTTPS) { + app.use(require('helmet')()) + app.use(require('express-sslify').HTTPS({ trustProtoHeader: true })) + } + + app.use(bodyParser.json()) + app.use('/public', express.static(pubFolder)) + + app.get('/', (req, res) => { + res.sendFile(path.join(pubFolder, 'index.html')) + }) + + app.get('/new', (req, res) => { + const protocol = req.headers['x-forwarded-proto'] || req.protocol + const host = req.headers['x-forwarded-host'] || req.get('host') + const channel = crypto + .randomBytes(12) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '~') + + res.redirect(307, `${protocol}://${host}/${channel}`) + }) + + app.get('/:channel', (req, res, next) => { + if (req.accepts('html')) { + res.sendFile(path.join(pubFolder, 'webhooks.html')) + } else { + next() + } + }, sse, (req, res) => { + function send (data) { + res.json(data) + keepAlive.reset() + } + + function close () { + events.removeListener(channel, send) + keepAlive.stop() + log('Client disconnected', channel, events.listenerCount(channel)) + } + + const channel = req.params.channel + + // Setup interval to ping every 30 seconds to keep the connection alive + const keepAlive = new KeepAlive(() => res.json({}, 'ping'), 30 * 1000) + keepAlive.start() + + // Allow CORS + res.setHeader('Access-Control-Allow-Origin', '*') + + // Listen for events on this channel + events.on(channel, send) + + // Clean up when the client disconnects + res.on('close', close) + + res.json({}, 'ready') + + log('Client connected', channel, events.listenerCount(channel)) + }) + + app.post('/:channel', (req, res) => { + events.emit(req.params.channel, { + ...req.headers, + body: req.body, + timestamp: Date.now() + }) + res.status(200).end() + }) + + // Resend payload via the event emitter + app.post('/:channel/redeliver', (req, res) => { + events.emit(req.params.channel, req.body) + res.status(200).end() + }) + + return app +} diff --git a/src/components/App.js b/src/components/App.js new file mode 100644 index 0000000..8ed6ad5 --- /dev/null +++ b/src/components/App.js @@ -0,0 +1,121 @@ +import React, { Component } from 'react' +import ListItem from './ListItem' +import get from 'get-value' +import { AlertIcon, PulseIcon } from 'react-octicons' +import Blank from './Blank' + +function compare (a, b) { + if (a.timestamp < b.timestamp) return 1 + if (a.timestamp > b.timestamp) return -1 + return 0 +} + +export default class App extends Component { + constructor (props) { + super(props) + this.state = { log: [], filter: '', connection: false } + } + + componentDidMount () { + this.setupEventSource() + } + + setupEventSource () { + const url = window.location.pathname + console.log('Connecting to event source:', url) + this.events = new window.EventSource(url) + this.events.onopen = this.onopen.bind(this) + this.events.onmessage = this.onmessage.bind(this) + this.events.onerror = this.onerror.bind(this) + } + + onopen (data) { + this.setState({ + connection: true + }) + } + + onerror (err) { + this.setState({ + connection: false + }) + switch (this.events.readyState) { + case window.EventSource.CONNECTING: + console.log('Reconnecting...', err) + break + case window.EventSource.CLOSED: + console.log('Reinitializing...', err) + this.setupEventSource() + break + } + } + + onmessage (message) { + console.log('received message!') + const json = JSON.parse(message.data) + + // Prevent duplicates in the case of redelivered payloads + const idProp = 'x-github-delivery' + if (this.state.log.findIndex(l => l[idProp] === json[idProp]) === -1) { + this.setState({ + log: [...this.state.log, json] + }) + } + } + + render () { + const { log, filter } = this.state + let filtered = log + if (filter) { + filtered = log.filter(l => { + if (filter && filter.includes(':')) { + let [searchString, value] = filter.split(':') + if (!searchString.startsWith('payload')) searchString = `payload.${searchString}` + return get(l, searchString) === value + } + return true + }) + } + const sorted = filtered.sort(compare) + const stateString = this.state.connection ? 'Connected' : 'Not Connected' + return ( +
+
+
+

Recent Deliveries

+
+ {this.state.connection + ? + : + } +
+
+
+ + {log.length > 0 ? ( +
+
+
+ + Uses the get-value syntax +
+ this.setState({ filter: e.target.value })} + className="input input-lg width-full Box" + /> +
+
    + {sorted.map((item, i, arr) => )} +
+
+ ) : } +
+ ) + } +} diff --git a/src/components/Blank.js b/src/components/Blank.js new file mode 100644 index 0000000..206d1de --- /dev/null +++ b/src/components/Blank.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react' +import { InfoIcon } from 'react-octicons' +import hljs from 'highlight.js' + +export default class Blank extends Component { + render () { + const code = `const SmeeClient = require('smee-client') + +const smee = new SmeeClient({ + source: '${window.location.href}', + target: 'http://localhost:3000/events', + logger: console +}) + +const events = smee.start() + +// Stop forwarding events +events.close()` + + return ( +
+
+
+ + +
+ e.target.select()} + readOnly + value={window.location.href} + className="form-control input-xl input-block" + /> +

This page will automatically update as things happen.

+ +
+
+

Use the CLI

+

The smee command will forward webhooks from smee.io to your local development environment.

+

+              $ smee -s {window.location.href}
+            
+ +

Use the Node.js client

+
+
+            

Using Probot's built-in support

+
+              $ npm install --save smee-client
+            
+

Then set the environment variable:

+
+              WEBHOOK_PROXY_URL={window.location.href}
+            
+
+
+
+ ) + } +} diff --git a/src/components/EventDescription.js b/src/components/EventDescription.js new file mode 100644 index 0000000..48de26b --- /dev/null +++ b/src/components/EventDescription.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import { string, object, number } from 'prop-types' +import moment from 'moment-timezone' + +export default class EventDescription extends Component { + static propTypes = { + event: string.isRequired, + payload: object.isRequired, + timestamp: number.isRequired + } + + render () { + const { event, payload, timestamp } = this.props + + const formattedTime = moment(timestamp).format('dddd, MMMM Do YYYY, h:mm:ss a') + const onARepo = payload.repository && payload.repository.full_name + const onRepos = payload.repositories && payload.repositories.every(r => r.full_name) + + return ( +
+

There was a {event} event received on {formattedTime}.

+ {onARepo &&

This event was sent by {payload.repository.full_name}.

} + {onRepos &&

This event was triggered against: {payload.repositories.map(r => {r.full_name})}.

} +
+ ) + } +} diff --git a/src/components/ListItem.js b/src/components/ListItem.js new file mode 100644 index 0000000..87bded9 --- /dev/null +++ b/src/components/ListItem.js @@ -0,0 +1,127 @@ +import React, { Component } from 'react' +import { object, bool } from 'prop-types' +import moment from 'moment' +import ReactJson from 'react-json-view' +import { + RepoPushIcon, + PackageIcon, + GitPullRequestIcon, + BookmarkIcon, + IssueOpenedIcon, + IssueClosedIcon, + KebabHorizontalIcon, + ClippyIcon, + SyncIcon +} from 'react-octicons' +import EventDescription from './EventDescription' +import copy from 'copy-to-clipboard' + +const iconMap = { + push: , + pull_request: , + label: , + 'issues.opened': , + 'issues.closed': +} + +export default class ListItem extends Component { + static propTypes = { + item: object.isRequired, + last: bool.isRequired + } + + constructor (props) { + super(props) + this.toggleExpanded = () => this.setState({ expanded: !this.state.expanded }) + this.copy = this.copy.bind(this) + this.redeliver = this.redeliver.bind(this) + this.state = { expanded: false, copied: false, redelivered: false } + } + + copy () { + const copied = copy(JSON.stringify(this.props.item)) + this.setState({ copied }) + } + + redeliver () { + window.fetch(`${window.location.pathname}/redeliver`, { + method: 'POST', + body: JSON.stringify(this.props.item), + headers: { + 'Content-Type': 'application/json' + } + }).then(res => { + this.setState({ redelivered: res.status === 200 }) + }) + } + + render () { + const { expanded, copied, redelivered } = this.state + const { item, last } = this.props + + const event = item['x-github-event'] + const payload = item.body + const id = item['x-github-delivery'] + + let icon + + if (payload.action && iconMap[`${event}.${payload.action}`]) { + icon = iconMap[`${event}.${payload.action}`] + } else if (iconMap[event]) { + icon = iconMap[event] + } else { + icon = + } + + return ( +
  • +
    +
    + {icon} +
    + {event} + + +
    + + {expanded && ( +
    +
    +
    +

    Event ID: {id}

    + +
    + +
    + + +
    +
    +
    +
    +
    Payload
    + +
    +
    + )} +
  • + ) + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..cb606a9 --- /dev/null +++ b/src/main.js @@ -0,0 +1,6 @@ +import React from 'react' +import { render } from 'react-dom' +import './style.scss' +import App from './components/App' + +render(, document.querySelector('.mount')) diff --git a/src/style.scss b/src/style.scss new file mode 100644 index 0000000..f6d3e4d --- /dev/null +++ b/src/style.scss @@ -0,0 +1,35 @@ +@import 'primer-css/index.scss'; + +$blue-650: mix($blue-600, $blue-700, 50%); +$animationSpeed: 1500ms; + +@import 'styles/header-anim'; +@import 'styles/main-anim'; +@import 'styles/github'; + +body { + background-color: $gray-200; +} + +.btn-outline-blue { + background-color: transparent; + color: white; + border-color: white !important; + + &:hover { + color: $blue-500; + background-color: white; + } +} + +.blue-700 { + color: $blue-700; +} + +.input-xl { + font-size: $h1-size; + padding: $spacer-2; + line-height: 1.5; +} + +.octicon { fill: currentColor; } \ No newline at end of file diff --git a/src/styles/github.scss b/src/styles/github.scss new file mode 100644 index 0000000..791932b --- /dev/null +++ b/src/styles/github.scss @@ -0,0 +1,99 @@ +/* + +github.com style (c) Vasily Polovnyov + +*/ + +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + color: #333; + background: #f8f8f8; +} + +.hljs-comment, +.hljs-quote { + color: #998; + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: #333; + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: #008080; +} + +.hljs-string, +.hljs-doctag { + color: #d14; +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: #900; + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: #458; + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: #000080; + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: #009926; +} + +.hljs-symbol, +.hljs-bullet { + color: #990073; +} + +.hljs-built_in, +.hljs-builtin-name { + color: #0086b3; +} + +.hljs-meta { + color: #999; + font-weight: bold; +} + +.hljs-deletion { + background: #fdd; +} + +.hljs-addition { + background: #dfd; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} diff --git a/src/styles/header-anim.scss b/src/styles/header-anim.scss new file mode 100644 index 0000000..969426e --- /dev/null +++ b/src/styles/header-anim.scss @@ -0,0 +1,89 @@ + +.header__anim { + position: relative; + width: 128px; + height: 64px; + + &__circle { + border-radius: 50%; + position: absolute; + } + + &__center { + width: 25%; + height: 50%; + top: 25%; + left: 50%; + background-color: $red; + z-index: 4; + animation: pulse $animationSpeed ease-in-out infinite; + + @keyframes pulse { + 0% { transform: translateX(-50%) scale(1); } + 25% { transform: translateX(-50%) scale(1); } + 30% { transform: translateX(-50%) scale(1.1); } + 50% { transform: translateX(-50%) scale(1); } + 65% { transform: translateX(-50%) scale(1.1); } + 70% { transform: translateX(-50%) scale(1); } + 100% { transform: translateX(-50%) scale(1); } + } + } + + &__line { + position: absolute; + left: 0; + width: 100%; + height: 3px; + background-color: $blue-650; + } + + &__dashed-circle { + width: 50%; + height: 100%; + left: 25%; + top: 0; + border: 3px dotted $blue-650; + animation: simpleRotate 10000ms linear infinite; + + @keyframes simpleRotate { + from { transform: rotate(0deg); } + to { transform: rotate(-360deg); } + } + } + + &::before, &::after, &__line { + top: 50%; + transform: translateY(-50%); + } + + &::before, &::after { + content: ''; + position: absolute; + border-radius: 50%; + width: 10%; + height: 20%; + background-color: $blue-300; + z-index: 3; + } + + &::before { left: 0; } + &::after { right: 0; } + + &__payload { + position: absolute; + top: 50%; + left: 0; + width: 10%; + height: 3px; + z-index: 2; + background-color: $blue-800; + animation: payload $animationSpeed linear infinite; + + @keyframes payload { + 0% { transform: translate(0, -50%); } + 30% { transform: translate(500%, -50%); } + 70% { transform: translate(500%, -50%); } + 100% { transform: translate(900%, -50%); } + } + } +} \ No newline at end of file diff --git a/src/styles/main-anim.scss b/src/styles/main-anim.scss new file mode 100644 index 0000000..2447638 --- /dev/null +++ b/src/styles/main-anim.scss @@ -0,0 +1,101 @@ +$bounce: cubic-bezier(0.175, 0.885, 0.32, 1.275); + +.main__anim { + position: relative; + margin: 0 auto; + width: 500px; + height: 250px; + + &__circle { + border-radius: 50%; + position: absolute; + } + + &__center { + width: 25%; + height: 50%; + top: 25%; + left: 50%; + background-color: $red; + z-index: 4; + transform: translateX(-50%); + + &::before, &::after, span::before, span::after { + content: ''; + width: 50%; + position: absolute; + left: 25%; + height: 6px; + border-radius: 2px; + background-color: $red-700; + animation: scaleIn 6000ms ease-in-out infinite; + transform: scaleX(0); + } + + &::before { top: 35%; } + &::after { top: 45%; animation-delay: 200ms } + span::before { top: 55%; animation-delay: 400ms } + span::after { top: 65%; animation-delay: 600ms } + + @keyframes scaleIn { + 0% { transform: scaleX(0); } + 10% { transform: scaleX(1); } + 40% { transform: scaleX(1); } + 50% { transform: scaleX(0); } + 100% { transform: scaleX(0); } + } + } + + &__line { + position: absolute; + left: 0; + width: 100%; + height: 6px; + background-color: $gray-300; + } + + &__left, &__right, &__line { + top: 50%; + transform: translateY(-50%); + } + + &__left, &__right { + content: ''; + position: absolute; + border-radius: 50%; + width: 15%; + height: 30%; + background-color: $blue; + z-index: 3; + + span { + position: absolute; + bottom: -$spacer-2; + font-family: $mono-font; + text-align: center; + transform: translateY(100%); + } + } + + &__left { left: 0; } + &__right { right: 0; } + + &__payload { + position: absolute; + top: 50%; + left: 0; + width: 10%; + height: 6px; + border-radius: 3px; + z-index: 2; + background-color: $blue-800; + animation: payload $animationSpeed linear infinite; + + @keyframes payload { + 0% { transform: translate(0, -50%); } + 30% { transform: translate(500%, -50%); } + 70% { transform: translate(500%, -50%); } + 100% { transform: translate(900%, -50%); } + } + } +} \ No newline at end of file diff --git a/tests/EventDescription.test.js b/tests/EventDescription.test.js new file mode 100644 index 0000000..a4465f3 --- /dev/null +++ b/tests/EventDescription.test.js @@ -0,0 +1,41 @@ +import React from 'react' +import EventDescription from '../src/components/EventDescription' +import { shallow } from 'enzyme' +import moment from 'moment-timezone' + +describe('', () => { + let props + + beforeEach(() => { + moment.tz.setDefault('UTC') + props = { + event: 'issues', + timestamp: 1513148474751, + payload: { action: 'opened' } + } + }) + + describe('render', () => { + it('renders the correct description', () => { + const wrapper = shallow() + expect(wrapper.find('p').text()).toBe('There was a issues event received on Wednesday, December 13th 2017, 7:01:14 am.') + }) + + it('renders the correct description when on one repo', () => { + const payload = { repository: { full_name: 'probot/probot' } } + const wrapper = shallow() + expect(wrapper.children().length).toBe(2) + expect(wrapper.childAt(1).text()).toBe('This event was sent by probot/probot.') + }) + + it('renders the correct description when on multiple repos', () => { + const payload = { repositories: [ + { full_name: 'probot/probot' }, + { full_name: 'JasonEtco/pizza' } + ] } + const wrapper = shallow() + expect(wrapper.children().length).toBe(2) + expect(wrapper.childAt(1).text()).toBe('This event was triggered against: probot/probotJasonEtco/pizza.') + }) + }) +}) diff --git a/tests/ListItem.test.js b/tests/ListItem.test.js new file mode 100644 index 0000000..1f72788 --- /dev/null +++ b/tests/ListItem.test.js @@ -0,0 +1,90 @@ +import React from 'react' +import ListItem from '../src/components/ListItem' +import { shallow } from 'enzyme' + +describe('', () => { + let item, el + + beforeEach(() => { + item = { + 'x-github-event': 'issues', + timestamp: 1513148474751, + body: { action: 'opened' } + } + + el = shallow() + }) + + describe('render', () => { + it('should render with one child', () => { + expect(el.children().length).toBe(1) + }) + + it('should render the expanded markup', () => { + expect(el.children().length).toBe(1) + + el.find('button.ellipsis-expander').simulate('click') + expect(el.children().length).toBe(2) + }) + + it('renders the correct octicon if there is no action', () => { + const i = { ...item, 'x-github-event': 'test' } + const wrapper = shallow() + expect(wrapper.find('PackageIcon').length).toBe(1) + }) + + it('renders the package octicon if the event is unknown', () => { + const i = { + 'x-github-event': 'push', + timestamp: 1513148474751, + body: {} + } + const wrapper = shallow() + expect(wrapper.find('RepoPushIcon').length).toBe(1) + }) + }) + + describe('copy', () => { + beforeEach(() => { + el.find('button.ellipsis-expander').simulate('click') + }) + + it('changes the button\'s label onClick, then onBlur', async () => { + let btn = el.find('.js-copy-btn') + expect(el.state('copied')).toBeFalsy() + expect(btn.prop('aria-label')).toBe('Copy payload to clipboard') + + el.setState({ copied: true }) + btn = el.find('.js-copy-btn') + expect(btn.prop('aria-label')).toBe('Copied!') + + btn.simulate('focus') + btn.simulate('blur') + expect(el.state('copied')).toBeFalsy() + btn = el.find('.js-copy-btn') + expect(btn.prop('aria-label')).toBe('Copy payload to clipboard') + }) + }) + + describe('redeliver', () => { + beforeEach(() => { + el.find('button.ellipsis-expander').simulate('click') + }) + + it('changes the button\'s label onClick, then onBlur', async () => { + let btn = el.find('.js-redeliver-btn') + expect(el.state('redelivered')).toBeFalsy() + expect(btn.prop('aria-label')).toBe('Redeliver this payload') + + el.setState({ redelivered: true }) + btn = el.find('.js-redeliver-btn') + expect(btn.prop('aria-label')).toBe('Sent!') + + btn.simulate('focus') + btn.simulate('blur') + expect(el.state('redelivered')).toBeFalsy() + btn = el.find('.js-redeliver-btn') + expect(btn.prop('aria-label')).toBe('Redeliver this payload') + }) + }) +}) diff --git a/tests/__snapshots__/server.test.js.snap b/tests/__snapshots__/server.test.js.snap new file mode 100644 index 0000000..3c27c18 --- /dev/null +++ b/tests/__snapshots__/server.test.js.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`server GET / returns the proper HTML 1`] = ` +" + + + + + + smee.io + + + + +
    +
    +
    +
    +
    +
    +
    +

    smee.io

    +

    Webhook payload delivery service

    +

    Receives payloads then sends them to your locally running application.

    + Start a new channel +
    + +
    +

    If your application needs to respond to webhooks, you'll need some way to expose localhost to the internet. smee.io is a small service that uses Server-Sent Events to proxy payloads from the webhook source, then transmit them to your locally running application.

    + +
    +
    + Webhook Emitter +
    +
    + localhost +
    +
    +
    +
    + +
    +
    + +

    Tell your webhook source to send payloads to your smee.io channel, then either use the smee CLI or, if you're using Probot to build a GitHub App, just set the environment variable.

    +
    + + + +" +`; + +exports[`server GET /:channel returns the proper HTML 1`] = ` +" + + + + + + Webhooks + + + + + +
    + + +" +`; diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 0000000..bbf2d42 --- /dev/null +++ b/tests/server.test.js @@ -0,0 +1,85 @@ +const createServer = require('../server') +const request = require('supertest') +const EventSource = require('eventsource') + +describe('server', () => { + let app, server, events, url, channel + + beforeEach((done) => { + channel = '/fake-channel' + app = createServer() + + server = app.listen(0, () => { + url = `http://127.0.0.1:${server.address().port}${channel}` + + // Wait for event source to be ready + events = new EventSource(url) + events.addEventListener('ready', () => done()) + }) + }) + + afterEach(() => { + server && server.close() + events && events.close() + }) + + describe('GET /', () => { + it('returns the proper HTML', async () => { + const res = await request(server).get('/') + expect(res.status).toBe(200) + expect(res.text).toMatchSnapshot() + }) + }) + + describe('GET /new', () => { + it('redirects from /new to /TOKEN', async () => { + const res = await request(server).get('/new') + expect(res.status).toBe(307) + expect(res.headers.location).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/[\w-]+$/) + }) + }) + + describe('GET /:channel', () => { + it('returns the proper HTML', async () => { + const res = await request(server).get(channel) + expect(res.status).toBe(200) + expect(res.text).toMatchSnapshot() + }) + }) + + describe('events', () => { + it('emits events', async (done) => { + const payload = {payload: true} + + await request(server).post(channel) + .set('X-Foo', 'bar') + .send(payload) + .expect(200) + + events.addEventListener('message', (msg) => { + const data = JSON.parse(msg.data) + expect(data.body).toEqual(payload) + expect(data['x-foo']).toEqual('bar') + + // test is done if all of this gets called + done() + }) + }) + + it('POST /:channel/redeliver re-emits a payload', async (done) => { + const payload = {payload: true} + + await request(server).post(channel + '/redeliver') + .send(payload) + .expect(200) + + events.addEventListener('message', (msg) => { + const data = JSON.parse(msg.data) + expect(data).toEqual(payload) + + // test is done if all of this gets called + done() + }) + }) + }) +}) diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..b4fa22a --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,11 @@ +import 'raf/polyfill' +import Enzyme, { shallow, render, mount } from 'enzyme' +import Adapter from 'enzyme-adapter-react-16' + +// React 16 Enzyme adapter +Enzyme.configure({ adapter: new Adapter() }) + +// Make Enzyme functions available in all test files without importing +global.shallow = shallow +global.render = render +global.mount = mount diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..96cc4e5 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,73 @@ +const path = require('path') +const webpack = require('webpack') +const ExtractTextPlugin = require('extract-text-webpack-plugin') +const autoprefixer = require('autoprefixer') +const glob = require('glob-all') +const PurifyCSSPlugin = require('purifycss-webpack') + +const browsers = [ + 'last 2 versions', + 'ios_saf >= 8', + 'ie >= 10', + 'chrome >= 49', + 'firefox >= 49', + '> 1%' +] + +const cfg = { + entry: { + main: path.resolve(__dirname, 'src', 'main.js') + }, + output: { + path: path.join(__dirname, 'public'), + filename: '[name].min.js', + publicPath: '/' + }, + plugins: [ + new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), + // new webpack.optimize.UglifyJsPlugin(), + new ExtractTextPlugin('[name].min.css') + ], + module: { + rules: [{ + test: /\.jsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader' + } + }, { + test: /\.scss$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: ['css-loader', { + loader: 'postcss-loader', + options: { + sourceMap: true, + plugins: () => [autoprefixer(browsers)] + } + }, { + loader: 'sass-loader', + options: { + sourceMap: true, + includePaths: [ + 'node_modules' + ] + } + }] + }) + }] + } +} + +if (process.env.NODE_ENV === 'production') { + cfg.plugins.push(new PurifyCSSPlugin({ + minimize: true, + moduleExtensions: ['.js'], + paths: glob.sync([ + path.join(__dirname, 'src', '**/*.js'), + path.join(__dirname, 'public', '*.html') + ]) + })) +} + +module.exports = cfg