diff --git a/package.json b/package.json index 30e1a5fe1..a78dfded3 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "memfs": "^3.4.0", "nock": "^13.1.1", "nyc": "^15.1.0", + "quibble": "^0.6.8", "rollup": "^2.53.2", "tsd": "^0.19.0" } diff --git a/packages/core/package.json b/packages/core/package.json index e5e53df1d..a2dc85110 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,8 +34,11 @@ "@percy/config": "1.0.0-beta.75", "@percy/dom": "1.0.0-beta.75", "@percy/logger": "1.0.0-beta.75", + "content-disposition": "^0.5.4", "cross-spawn": "^7.0.3", "extract-zip": "^2.0.1", + "mime-types": "^2.1.34", + "path-to-regexp": "^6.2.0", "rimraf": "^3.0.2", "ws": "^8.0.0" } diff --git a/packages/core/src/api.js b/packages/core/src/api.js new file mode 100644 index 000000000..7fac93237 --- /dev/null +++ b/packages/core/src/api.js @@ -0,0 +1,68 @@ +import fs from 'fs'; +import logger from '@percy/logger'; +import Server from './server'; +import pkg from '../package.json'; + +export function createPercyServer(percy, port) { + return new Server({ port }) + // facilitate logger websocket connections + .websocket(ws => logger.connect(ws)) + // general middleware + .route((req, res, next) => { + // treat all request bodies as json + if (req.body) try { req.body = JSON.parse(req.body); } catch {} + + // add version header + res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version'); + res.setHeader('X-Percy-Core-Version', pkg.version); + + // return json errors + return next().catch(e => res.json(e.status ?? 500, { + error: e.message, + success: false + })); + }) + // healthcheck returns basic information + .route('get', '/percy/healthcheck', (req, res) => res.json(200, { + loglevel: percy.loglevel(), + config: percy.config, + build: percy.build, + success: true + })) + // get or set config options + .route(['get', 'post'], '/percy/config', async (req, res) => res.json(200, { + config: req.body ? await percy.setConfig(req.body) : percy.config, + success: true + })) + // responds once idle (may take a long time) + .route('get', '/percy/idle', async (req, res) => res.json(200, { + success: await percy.idle().then(() => true) + })) + // convenient @percy/dom bundle + .route('get', '/percy/dom.js', (req, res) => { + return res.file(200, require.resolve('@percy/dom')); + }) + // legacy agent wrapper for @percy/dom + .route('get', '/percy-agent.js', async (req, res) => { + logger('core:server').deprecated([ + 'It looks like you’re using @percy/cli with an older SDK.', + 'Please upgrade to the latest version to fix this warning.', + 'See these docs for more info: https:docs.percy.io/docs/migrating-to-percy-cli' + ].join(' ')); + + let content = await fs.promises.readFile(require.resolve('@percy/dom'), 'utf-8'); + let wrapper = '(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });'; + return res.send(200, 'applicaton/javascript', content.concat(wrapper)); + }) + // post one or more snapshots + .route('post', '/percy/snapshot', async (req, res) => { + let snapshot = percy.snapshot(req.body); + if (!req.url.searchParams.has('async')) await snapshot; + return res.json(200, { success: true }); + }) + // stops percy at the end of the current event loop + .route('/percy/stop', (req, res) => { + setImmediate(() => percy.stop()); + return res.json(200, { success: true }); + }); +} diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 48931f313..7cc5c1309 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -4,7 +4,7 @@ import { merge } from '@percy/config/dist/utils'; import logger from '@percy/logger'; import Queue from './queue'; import Browser from './browser'; -import createPercyServer from './server'; +import { createPercyServer } from './api'; import { getSnapshotConfig, @@ -85,8 +85,7 @@ export class Percy { }); if (server) { - this.server = createPercyServer(this); - this.port = port; + this.server = createPercyServer(this, port); } } @@ -97,7 +96,7 @@ export class Percy { // Snapshot server API address address() { - return `http://localhost:${this.port}`; + return this.server?.address(); } // Set client & environment info, and override loaded config options @@ -185,7 +184,7 @@ export class Percy { } // start the server after everything else is ready - yield this.server?.listen(this.port); + yield this.server?.listen(); // mark instance as started this.log.info('Percy has started!'); diff --git a/packages/core/src/server.js b/packages/core/src/server.js index bfe2fc7ee..48fa225d9 100644 --- a/packages/core/src/server.js +++ b/packages/core/src/server.js @@ -1,177 +1,355 @@ import fs from 'fs'; +import path from 'path'; import http from 'http'; -import { Server as WSS } from 'ws'; -import logger from '@percy/logger'; -import pkg from '../package.json'; - -async function getReply({ version, routes }, request, response) { - let [url] = request.url.split('?'); - let route = routes[url] || routes.default; - let reply; - - // cors preflight - if (request.method === 'OPTIONS') { - reply = [204, {}]; - reply[1]['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS'; - reply[1]['Access-Control-Request-Headers'] = 'Vary'; - let allowed = request.headers['access-control-request-headers']; - if (allowed?.length) reply[1]['Access-Control-Allow-Headers'] = allowed; - } else { - reply = await Promise.resolve() - .then(() => routes.middleware?.(request, response)) - .then(() => route?.(request, response)) - .catch(routes.catch); - } - - // response was handled - if (response.headersSent) return []; - - // default 404 when reply is not an array - let [status, headers, body] = Array.isArray(reply) ? reply : [404, {}]; - // support content-type header shortcut - if (typeof headers === 'string') headers = { 'Content-Type': headers }; - // auto stringify json - if (headers['Content-Type']?.includes('json')) body = JSON.stringify(body); - // add additional headers - headers['Content-Length'] = body?.length ?? 0; - // cors headers - headers['Access-Control-Expose-Headers'] = 'X-Percy-Core-Version'; - headers['Access-Control-Allow-Origin'] = '*'; - // version header - headers['X-Percy-Core-Version'] = version; - - return [status, headers, body]; +import WebSocket from 'ws'; +import mime from 'mime-types'; +import disposition from 'content-disposition'; +import { + pathToRegexp, + match as pathToMatch, + compile as makeToPath +} from 'path-to-regexp'; + +// custom incoming message adds a `url` and `body` properties containing the parsed URL and message +// buffer respectively; both available after the 'end' event is emitted +export class IncomingMessage extends http.IncomingMessage { + constructor(socket) { + let buffer = []; + + super(socket).on('data', d => buffer.push(d)).on('end', () => { + this.url = new URL(this.url, `http://${this.headers.host}`); + if (buffer.length) this.body = Buffer.concat(buffer); + + if (this.body && this.headers['content-type']?.includes('json')) { + try { this.body = JSON.parse(this.body); } catch {} + } + }); + } } -export function createServer(routes) { - let context = { - version: pkg.version, +// custom server response adds additional convenience methods +export class ServerResponse extends http.ServerResponse { + // responds with a status, headers, and body; the second argument can be an content-type string, + // or a headers object, with content-length being automatically set when a `body` is provided + send(status, headers, body) { + if (typeof headers === 'string') { + this.setHeader('Content-Type', headers); + headers = null; + } - get listening() { - return context.server.listening; + if (body != null && !this.hasHeader('Content-Length')) { + this.setHeader('Content-Length', Buffer.byteLength(body)); } - }; - // create a simple server to route request responses - context.routes = routes; - context.server = http.createServer((request, response) => { - request.params = new URLSearchParams(request.url.split('?')[1]); + return this.writeHead(status, headers).end(body); + } - request.on('data', chunk => { - request.body = (request.body || '') + chunk; - }); + // responds with a status and content with a plain/text content-type + text(status, content) { + if (arguments.length < 2) [status, content] = [200, status]; + return this.send(status, 'text/plain', content.toString()); + } + + // responds with a status and stringified `data` with a json content-type + json(status, data) { + if (arguments.length < 2) [status, data] = [200, status]; + return this.send(status, 'application/json', JSON.stringify(data)); + } - request.on('end', async () => { - try { request.body = JSON.parse(request.body); } catch (e) {} - let [status, headers, body] = await getReply(context, request, response); - if (!response.headersSent) response.writeHead(status, headers).end(body); + // responds with a status and streams a file with appropriate headers + file(status, filepath) { + if (arguments.length < 2) [status, filepath] = [200, status]; + + filepath = path.resolve(filepath); + let { size } = fs.lstatSync(filepath); + let range = parseByteRange(this.req.headers.range, size); + + // support simple range requests + if (this.req.headers.range) { + let byteRange = range ? `${range.start}-${range.end}` : '*'; + this.setHeader('Content-Range', `bytes ${byteRange}/${size}`); + if (!range) return this.send(416); + } + + this.writeHead(range ? 206 : status, { + 'Accept-Ranges': 'bytes', + 'Content-Type': mime.contentType(path.extname(filepath)), + 'Content-Length': range ? (range.end - range.start + 1) : size, + 'Content-Disposition': disposition(filepath, { type: 'inline' }) }); - }); - - // track connections - context.sockets = new Set(); - context.server.on('connection', s => { - context.sockets.add(s.on('close', () => context.sockets.delete(s))); - }); - - // immediately kill connections on close - context.close = () => new Promise(resolve => { - context.sockets.forEach(s => s.destroy()); - context.server.close(resolve); - }); - - // starts the server - context.listen = port => new Promise((resolve, reject) => { - context.server.on('listening', () => resolve(context)); - context.server.on('error', reject); - context.server.listen(port); - }); - - // add routes programatically - context.reply = (url, handler) => { - routes[url] = handler; - return context; - }; - - return context; + + fs.createReadStream(filepath, range).pipe(this); + return this; + } +} + +// custom server error with a status and default reason +export class ServerError extends Error { + static throw(status, reason) { + throw new this(status, reason); + } + + constructor(status = 500, reason) { + super(reason || http.STATUS_CODES[status]); + this.status = status; + } } -export function createPercyServer(percy) { - let log = logger('core:server'); - - let context = createServer({ - // healthcheck returns meta info on success - '/percy/healthcheck': () => [200, 'application/json', { - success: true, - config: percy.config, - loglevel: percy.loglevel(), - build: percy.build - }], - - // remotely get and set percy config options - '/percy/config': ({ body }) => [200, 'application/json', { - config: body ? percy.setConfig(body) : percy.config, - success: true - }], - - // responds when idle - '/percy/idle': () => percy.idle() - .then(() => [200, 'application/json', { success: true }]), - - // serves @percy/dom as a convenience - '/percy/dom.js': () => fs.promises - .readFile(require.resolve('@percy/dom'), 'utf-8') - .then(content => [200, 'applicaton/javascript', content]), - - // serves the new DOM library, wrapped for compatability to `@percy/agent` - '/percy-agent.js': () => fs.promises - .readFile(require.resolve('@percy/dom'), 'utf-8') - .then(content => { - let wrapper = '(window.PercyAgent = class PercyAgent { snapshot(n, o) { return PercyDOM.serialize(o); } });'; - log.deprecated('It looks like you’re using @percy/cli with an older SDK. Please upgrade to the latest version' + ( - ' to fix this warning. See these docs for more info: https://docs.percy.io/docs/migrating-to-percy-cli')); - return [200, 'applicaton/javascript', content.concat(wrapper)]; - }), - - // forward snapshot requests - '/percy/snapshot': async ({ body, params }) => { - let snapshot = percy.snapshot(body); - if (!params.has('async')) await snapshot; - return [200, 'application/json', { success: true }]; - }, - - // stops the instance async at the end of the event loop - '/percy/stop': () => { - setImmediate(async () => await percy.stop()); - return [200, 'application/json', { success: true }]; - }, - - // other routes 404 - default: () => [404, 'application/json', { - error: 'Not found', - success: false - }], - - // generic error handler - catch: ({ message }) => [500, 'application/json', { - error: message, - success: false - }] - }); - - // start a websocket server - context.wss = new WSS({ noServer: true }); - - // manually handle upgrades to avoid wss handling all events - context.server.on('upgrade', (req, sock, head) => { - context.wss.handleUpgrade(req, sock, head, socket => { - // allow remote logging connections - let disconnect = logger.connect(socket); - socket.once('close', () => disconnect()); +// custom server class handles routing requests and provides alternate methods and properties +export class Server extends http.Server { + #sockets = new Set(); + #defaultPort; + + constructor({ port } = {}) { + super({ IncomingMessage, ServerResponse }); + this.#defaultPort = port; + + // handle requests on end + this.on('request', (req, res) => { + req.on('end', () => this.#handleRequest(req, res)); }); - }); - return context; + // handle websocket upgrades + this.on('upgrade', (req, sock, head) => { + this.#handleUpgrade(req, sock, head); + }); + + // track open connections to terminate when the server closes + this.on('connection', socket => { + let handleClose = () => this.#sockets.delete(socket); + this.#sockets.add(socket.on('close', handleClose)); + }); + } + + // return the listening port or any default port + get port() { + return super.address()?.port ?? this.#defaultPort; + } + + // return a string representation of the server address + address() { + let port = this.port; + let host = 'http://localhost'; + return port ? `${host}:${port}` : host; + } + + // return a promise that resolves when the server is listening + listen(port = this.#defaultPort) { + return new Promise((resolve, reject) => { + let handle = err => off() && err ? reject(err) : resolve(this); + let off = () => this.off('error', handle).off('listening', handle); + super.listen(port, handle).once('error', handle); + }); + } + + // return a promise that resolves when the server closes + close() { + return new Promise(resolve => { + this.#sockets.forEach(socket => socket.destroy()); + super.close(resolve); + }); + } + + // handle websocket upgrades + #up = []; + + websocket(pathname, handle) { + if (!handle) [pathname, handle] = [null, pathname]; + + this.#up.push({ + match: pathname && pathToMatch(pathname), + handle: (req, sock, head) => new Promise(resolve => { + let wss = new WebSocket.Server({ noServer: true, clientTracking: false }); + wss.handleUpgrade(req, sock, head, resolve); + }).then(ws => handle(ws, req)) + }); + + if (pathname) { + this.#up.sort((a, b) => (a.match ? -1 : 1) - (b.match ? -1 : 1)); + } + + return this; + } + + #handleUpgrade(req, sock, head) { + let up = this.#up.find(u => !u.match || u.match(req.url)); + if (up) return up.handle(req, sock, head); + + sock.write( + `HTTP/1.1 400 ${http.STATUS_CODES[400]}\r\n` + + 'Connection: close\r\n\r\n'); + sock.destroy(); + } + + // initial routes include cors and 404 handling + #routes = [{ + priority: -1, + handle: (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (req.method === 'OPTIONS') { + let allowHeaders = req.headers['access-control-request-headers'] || '*'; + let allowMethods = [...new Set(this.#routes.flatMap(route => ( + (!route.match || route.match(req.url.pathname)) && route.methods + ) || []))].join(', '); + + res.setHeader('Access-Control-Allow-Headers', allowHeaders); + res.setHeader('Access-Control-Allow-Methods', allowMethods); + res.writeHead(204).end(); + } else { + res.setHeader('Access-Control-Expose-Headers', '*'); + return next(); + } + } + }, { + priority: 3, + handle: (req) => ServerError.throw(404) + }]; + + // adds a route in the correct priority order + #route(route) { + let i = this.#routes.findIndex(r => r.priority >= route.priority); + this.#routes.splice(i, 0, route); + return this; + } + + // set request routing and handling for pathnames and methods + route(method, pathname, handle) { + if (arguments.length === 1) [handle, method] = [method]; + if (arguments.length === 2) [handle, pathname] = [pathname]; + if (arguments.length === 2 && !Array.isArray(method) && + method[0] === '/') [pathname, method] = [method]; + + return this.#route({ + priority: !pathname ? 0 : !method ? 1 : 2, + methods: method && [].concat(method).map(m => m.toUpperCase()), + match: pathname && pathToMatch(pathname), + handle + }); + } + + // install a route that serves requested files from the provided directory + serve(pathname, directory, options) { + if (typeof directory !== 'string') [options, directory] = [directory]; + if (!directory) [pathname, directory] = ['/', pathname]; + + let root = path.resolve(directory); + let mountPattern = pathToRegexp(pathname, null, { end: false }); + let rewritePath = createRewriter(options?.rewrites, (pathname, rewrite) => { + try { + let filepath = decodeURIComponent(pathname.replace(mountPattern, '')); + if (!isPathInside(root, filepath)) ServerError.throw(); + return rewrite(filepath); + } catch { + throw new ServerError(400); + } + }); + + return this.#route({ + priority: 2, + methods: ['GET'], + match: pathname => mountPattern.test(pathname), + handle: async (req, res, next) => { + try { + let pathname = rewritePath(req.url.pathname); + let file = await getFile(root, pathname, options?.cleanUrls); + if (!file?.stats.isFile()) return await next(); + return res.file(file.path); + } catch (err) { + let statusPage = path.join(root, `${err.status}.html`); + if (!fs.existsSync(statusPage)) throw err; + return res.file(err.status, statusPage); + } + } + }); + } + + // route and respond to requests; handling errors if necessary + async #handleRequest(req, res) { + // support node < 15.7.0 + res.req ??= req; + + try { + // invoke routes like middleware + await (async function cont(routes, i = 0) { + let next = () => cont(routes, i + 1); + let { methods, match, handle } = routes[i]; + let result = !methods || methods.includes(req.method); + result &&= !match || match(req.url.pathname); + if (result) req.params = result.params; + return result ? handle(req, res, next) : next(); + })(this.#routes); + } catch (error) { + let { status = 500, message } = error; + + // fallback error handling + if (req.headers.accept?.includes('json') || + req.headers['content-type']?.includes('json')) { + res.json(status, { error: message }); + } else { + res.text(status, message); + } + } + } +} + +// create a url rewriter from provided rewrite rules +function createRewriter(rewrites = [], cb) { + let normalize = p => path.posix.normalize(path.posix.join('/', p)); + if (!Array.isArray(rewrites)) rewrites = Object.entries(rewrites); + + let rewrite = [{ + // resolve and normalize the path before rewriting + apply: p => path.posix.resolve(normalize(p)) + }].concat(rewrites.map(([src, dest]) => { + // compile rewrite rules into functions + let match = pathToMatch(normalize(src)); + let toPath = makeToPath(normalize(dest)); + return { match, apply: r => toPath(r.params) }; + })).reduceRight((next, rule) => pathname => { + // compose all rewrites into a single function + let result = rule.match?.(pathname) ?? pathname; + if (result) pathname = rule.apply(result); + return next(pathname); + }, p => p); + + // allow additional pathname processing around the rewriter + return p => cb(p, rewrite); +} + +// returns true if the pathname is inside the root pathname +function isPathInside(root, pathname) { + let abs = path.resolve(path.join(root, pathname)); + + return !abs.lastIndexOf(root, 0) && ( + abs[root.length] === path.sep || !abs[root.length] + ); +} + +// get the absolute path and stats of a possible file +async function getFile(root, pathname, cleanUrls) { + for (let filename of [pathname].concat( + cleanUrls ? path.join(pathname, 'index.html') : [], + cleanUrls && pathname.length > 2 ? pathname.replace(/\/?$/, '.html') : [] + )) { + let filepath = path.resolve(path.join(root, filename)); + let stats = await fs.promises.lstat(filepath).catch(() => {}); + if (stats?.isFile()) return { path: filepath, stats }; + } +} + +// returns the start and end of a byte range or undefined if unable to parse +const RANGE_REGEXP = /^bytes=(\d*)?-(\d*)?(?:\b|$)/; + +function parseByteRange(range, size) { + let [, start, end = size] = range?.match(RANGE_REGEXP) ?? [0, 0, 0]; + start = Math.max(parseInt(start, 10), 0); + end = Math.min(parseInt(end, 10), size - 1); + if (isNaN(start)) [start, end] = [size - end, size - 1]; + if (start >= 0 && start < end) return { start, end }; } -export default createPercyServer; +// include ServerError and createRewriter as static properties +Server.Error = ServerError; +Server.createRewriter = createRewriter; +export default Server; diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js new file mode 100644 index 000000000..afd001d09 --- /dev/null +++ b/packages/core/test/api.test.js @@ -0,0 +1,217 @@ +import PercyConfig from '@percy/config'; +import Percy from '../src'; +import pkg from '../package.json'; +import { logger } from './helpers'; + +describe('API Server', () => { + let percy; + + async function request(path, ...args) { + let { request } = await import('./helpers/request'); + return request(new URL(path, percy.address()), ...args); + } + + beforeEach(() => { + percy = new Percy({ + token: 'PERCY_TOKEN', + port: 1337 + }); + }); + + afterEach(async () => { + percy.stop.and?.callThrough(); + await percy.stop(); + }); + + it('has a default port', () => { + expect(new Percy()).toHaveProperty('server.port', 5338); + }); + + it('can specify a custom port', () => { + expect(percy).toHaveProperty('server.port', 1337); + }); + + it('starts a server at the specified port', async () => { + await expectAsync(percy.start()).toBeResolved(); + await expectAsync(request('/', false)).toBeResolved(); + }); + + it('has a /healthcheck endpoint', async () => { + await percy.start(); + + let [data, res] = await request('/percy/healthcheck', true); + expect(res.headers).toHaveProperty('x-percy-core-version', pkg.version); + expect(data).toEqual({ + success: true, + loglevel: 'info', + config: PercyConfig.getDefaults(), + build: { + id: '123', + number: 1, + url: 'https://percy.io/test/test/123' + } + }); + }); + + it('has a /config endpoint that returns loaded config options', async () => { + await percy.start(); + + await expectAsync(request('/percy/config')).toBeResolvedTo({ + success: true, + config: PercyConfig.getDefaults() + }); + }); + + it('can set config options via the /config endpoint', async () => { + let expected = PercyConfig.getDefaults({ snapshot: { widths: [1000] } }); + await percy.start(); + + expect(percy.config).not.toEqual(expected); + + await expectAsync(request('/percy/config', { + method: 'POST', + body: { snapshot: { widths: [1000] } } + })).toBeResolvedTo({ + config: expected, + success: true + }); + + expect(percy.config).toEqual(expected); + }); + + it('has an /idle endpoint that calls #idle()', async () => { + spyOn(percy, 'idle').and.resolveTo(); + await percy.start(); + + await expectAsync(request('/percy/idle')).toBeResolvedTo({ success: true }); + expect(percy.idle).toHaveBeenCalled(); + }); + + it('serves the @percy/dom bundle', async () => { + await percy.start(); + + await expectAsync(request('/percy/dom.js')).toBeResolvedTo( + require('fs').readFileSync(require.resolve('@percy/dom'), { encoding: 'utf-8' }) + ); + }); + + it('serves the legacy percy-agent.js dom bundle', async () => { + await percy.start(); + + await expectAsync(request('/percy-agent.js')).toBeResolvedTo( + require('fs').readFileSync(require.resolve('@percy/dom'), { encoding: 'utf-8' }) + .concat('(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });') + ); + + expect(logger.stderr).toEqual(['[percy] Warning: ' + [ + 'It looks like you’re using @percy/cli with an older SDK.', + 'Please upgrade to the latest version to fix this warning.', + 'See these docs for more info: https:docs.percy.io/docs/migrating-to-percy-cli' + ].join(' ')]); + }); + + it('has a /stop endpoint that calls #stop()', async () => { + spyOn(percy, 'stop').and.resolveTo(); + await percy.start(); + + await expectAsync(request('/percy/stop', 'POST')).toBeResolvedTo({ success: true }); + expect(percy.stop).toHaveBeenCalled(); + }); + + it('has a /snapshot endpoint that calls #snapshot() with provided options', async () => { + spyOn(percy, 'snapshot').and.resolveTo(); + await percy.start(); + + await expectAsync(request('/percy/snapshot', { + method: 'POST', + body: { 'test-me': true, me_too: true } + })).toBeResolvedTo({ + success: true + }); + + expect(percy.snapshot).toHaveBeenCalledOnceWith( + { 'test-me': true, me_too: true } + ); + }); + + it('can handle snapshots async with a parameter', async () => { + let test = new Promise(r => setTimeout(r, 500)); + spyOn(percy, 'snapshot').and.returnValue(test); + await percy.start(); + + await expectAsync( + request('/percy/snapshot?async', 'POST') + ).toBeResolvedTo({ + success: true + }); + + await expectAsync(test).toBePending(); + await test; // no hanging promises + }); + + it('returns a 500 error when an endpoint throws', async () => { + spyOn(percy, 'snapshot').and.rejectWith(new Error('test error')); + await percy.start(); + + let [data, res] = await request('/percy/snapshot', 'POST', true); + expect(res.statusCode).toBe(500); + expect(data).toEqual({ success: false, error: 'test error' }); + }); + + it('returns a 404 for any other endpoint', async () => { + await percy.start(); + + let [data, res] = await request('/foobar', true); + expect(res.statusCode).toBe(404); + expect(data).toEqual({ success: false, error: 'Not Found' }); + }); + + it('facilitates logger websocket connections', async () => { + await percy.start(); + + logger.reset(); + logger.loglevel('debug'); + + // log from a separate async process + let [stdout, stderr] = await new Promise((resolve, reject) => { + require('child_process').exec('node -e "' + [ + "let logger = require('@percy/logger');", + "let ws = new (require('ws'))('ws://localhost:1337');", + "logger.loglevel('debug');", + 'logger.remote(() => ws)', + " .then(() => logger('remote-sdk').info('whoa'))", + ' .then(() => setTimeout(() => ws.close(), 100));' + ].join('') + '"', (err, stdout, stderr) => { + if (!err) resolve([stdout, stderr]); + else reject(err); + }); + }); + + // child logs are present on connection failure + expect(stdout.toString()).toEqual(''); + expect(stderr.toString()).toEqual(''); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy:remote-sdk] whoa' + ]); + }); + + describe('when the server is disabled', () => { + beforeEach(async () => { + percy = await Percy.start({ + token: 'PERCY_TOKEN', + server: false + }); + }); + + it('does not start a server with #start()', async () => { + await expectAsync(request('http://localhost:5883')) + .toBeRejectedWithError(/ECONNREFUSED/); + }); + + it('does not error when stopping', async () => { + await expectAsync(percy.stop()).toBeResolved(); + }); + }); +}); diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 58660eb5a..11849fde9 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -26,14 +26,10 @@ describe('Discovery', () => { captured = []; mockAPI.reply('/builds/123/snapshots', ({ body }) => { - captured.push( - // order is not important, stabilize it for testing - body.data.relationships.resources.data - .sort((a, b) => ( - a.attributes['resource-url'] < b.attributes['resource-url'] ? -1 - : (a.attributes['resource-url'] > b.attributes['resource-url'] ? 1 : 0) - )) - ); + // resource order is not important, stabilize it for testing + captured.push(body.data.relationships.resources.data.sort((a, b) => ( + a.attributes['resource-url'].localeCompare(b.attributes['resource-url']) + ))); return [201, { data: { id: '4567' } }]; }); @@ -297,7 +293,7 @@ describe('Discovery', () => { `; server.reply('/events', () => [200, 'text/html', eventStreamDOM]); - server.reply('/event-stream', (req, res) => { + server.route('/event-stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); // network idle should wait for the first event, so delay it to make sure setTimeout(() => res.write('data: This came from a server-sent event\n\n'), 1000); diff --git a/packages/core/test/helpers/request.js b/packages/core/test/helpers/request.js new file mode 100644 index 000000000..61dc18b8f --- /dev/null +++ b/packages/core/test/helpers/request.js @@ -0,0 +1,14 @@ +import req from '@percy/client/dist/request'; + +export async function request(url, method = 'GET', handle) { + if (typeof method === 'boolean' || typeof method === 'function') [handle, method] = [method, 'GET']; + let cb = typeof handle === 'boolean' ? (handle ? (...a) => a : (_, r) => r) : handle; + let options = typeof method === 'string' ? { method } : method; + + try { return await req(url, options, cb); } catch (error) { + if (typeof handle !== 'boolean') throw error; + return handle ? [error.response.body, error.response] : error.response; + } +} + +export default request; diff --git a/packages/core/test/helpers/server.js b/packages/core/test/helpers/server.js index 8d5142f81..972e8d47c 100644 --- a/packages/core/test/helpers/server.js +++ b/packages/core/test/helpers/server.js @@ -1,20 +1,33 @@ // aliased to src for coverage during tests without needing to compile this file -const { createServer } = require('@percy/core/dist/server'); +const { default: Server } = require('@percy/core/dist/server'); -function createTestServer(routes, port = 8000) { - let context = createServer(routes); +function createTestServer({ default: defaultReply, ...replies }, port = 8000) { + let server = new Server(); - // handle route errors - context.routes.catch = ({ message }) => [500, 'text/plain', message]; - - // track requests - context.requests = []; - context.routes.middleware = ({ url, body }) => { - context.requests.push(body ? [url, body] : [url]); + // alternate route handling + let handleReply = reply => async (req, res) => { + let [status, headers, body] = typeof reply === 'function' ? await reply(req) : reply; + if (!Buffer.isBuffer(body) && typeof body !== 'string') body = JSON.stringify(body); + return res.send(status, headers, body); }; + // map replies to alternate route handlers + server.reply = (p, reply) => (replies[p] = handleReply(reply)); + for (let [p, reply] of Object.entries(replies)) server.reply(p, reply); + if (defaultReply) defaultReply = handleReply(defaultReply); + + // track requests and route replies + server.requests = []; + server.route(async (req, res, next) => { + let pathname = req.url.pathname; + if (req.url.search) pathname += req.url.search; + server.requests.push(req.body ? [pathname, req.body] : [pathname]); + let reply = replies[req.url.pathname] || defaultReply; + return reply ? await reply(req, res) : next(); + }); + // automatically listen - return context.listen(port); + return server.listen(port); }; // support commonjs environments diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 2d9b18692..161920458 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; import Percy from '../src'; import { mockAPI, logger, createTestServer } from './helpers'; @@ -189,11 +188,12 @@ describe('Percy', () => { }); it('starts a server after launching a browser', async () => { + let { request } = await import('./helpers/request'); spyOn(percy.browser, 'launch').and.callThrough(); spyOn(percy.server, 'listen').and.callThrough(); await expectAsync(percy.start()).toBeResolved(); - await expectAsync(fetch('http://localhost:5338')).toBeResolved(); + await expectAsync(request('http://localhost:5338', false)).toBeResolved(); expect(percy.browser.launch) .toHaveBeenCalledBefore(percy.server.listen); @@ -399,7 +399,8 @@ describe('Percy', () => { }); it('stops the server', async () => { - await expectAsync(fetch('http://localhost:5338')).toBeResolved(); + let { request } = await import('./helpers/request'); + await expectAsync(request('http://localhost:5338', false)).toBeResolved(); await expectAsync(percy.stop()).toBeResolved(); expect(percy.server.listening).toBe(false); }); diff --git a/packages/core/test/server.test.js b/packages/core/test/server.test.js deleted file mode 100644 index 894a83f53..000000000 --- a/packages/core/test/server.test.js +++ /dev/null @@ -1,263 +0,0 @@ -import fetch from 'node-fetch'; -import PercyConfig from '@percy/config'; -import Percy from '../src'; -import pkg from '../package.json'; -import { logger } from './helpers'; - -describe('Server', () => { - let percy; - - beforeEach(() => { - percy = new Percy({ - token: 'PERCY_TOKEN', - port: 1337 - }); - }); - - afterEach(async () => { - percy.stop.and?.callThrough(); - await percy.stop(); - }); - - it('has a default port', () => { - expect(new Percy()).toHaveProperty('port', 5338); - }); - - it('can specify a custom port', () => { - expect(percy).toHaveProperty('port', 1337); - }); - - it('starts a server at the specified port', async () => { - await expectAsync(percy.start()).toBeResolved(); - await expectAsync(fetch('http://localhost:1337')).toBeResolved(); - }); - - it('has a /healthcheck endpoint', async () => { - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/healthcheck'); - expect(response.headers.get('x-percy-core-version')).toMatch(pkg.version); - await expectAsync(response.json()).toBeResolvedTo({ - success: true, - loglevel: 'info', - config: PercyConfig.getDefaults(), - build: { - id: '123', - number: 1, - url: 'https://percy.io/test/test/123' - } - }); - }); - - it('has a /config endpoint that returns loaded config options', async () => { - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/config'); - await expectAsync(response.json()).toBeResolvedTo({ - success: true, - config: PercyConfig.getDefaults() - }); - }); - - it('can set config options via the /config endpoint', async () => { - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/config', { - method: 'POST', - body: JSON.stringify({ - snapshot: { widths: [1000] } - }) - }); - - await expectAsync(response.json()).toBeResolvedTo({ - success: true, - config: PercyConfig.getDefaults({ - snapshot: { widths: [1000] } - }) - }); - - expect(percy.config).toEqual( - PercyConfig.getDefaults({ - snapshot: { widths: [1000] } - }) - ); - }); - - it('has an /idle endpoint that calls #idle()', async () => { - spyOn(percy, 'idle').and.resolveTo(); - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/idle'); - expect(response.headers.get('x-percy-core-version')).toMatch(pkg.version); - await expectAsync(response.json()).toBeResolvedTo({ success: true }); - expect(percy.idle).toHaveBeenCalled(); - }); - - it('serves the @percy/dom bundle', async () => { - let bundle = require('fs') - .readFileSync(require.resolve('@percy/dom'), { encoding: 'utf-8' }); - - await percy.start(); - let response = await fetch('http://localhost:1337/percy/dom.js'); - await expectAsync(response.text()).toBeResolvedTo(bundle); - }); - - it('serves the legacy percy-agent.js dom bundle', async () => { - let bundle = require('fs') - .readFileSync(require.resolve('@percy/dom'), { encoding: 'utf-8' }) - .concat('(window.PercyAgent = class PercyAgent { snapshot(n, o) { return PercyDOM.serialize(o); } });'); - - await percy.start(); - let response = await fetch('http://localhost:1337/percy-agent.js'); - - await expectAsync(response.text()).toBeResolvedTo(bundle); - expect(logger.stderr).toEqual([ - '[percy] Warning: It looks like you’re using @percy/cli with an older SDK. Please upgrade to the latest version' + - ' to fix this warning. See these docs for more info: https://docs.percy.io/docs/migrating-to-percy-cli' - ]); - }); - - it('has a /stop endpoint that calls #stop()', async () => { - spyOn(percy, 'stop').and.resolveTo(); - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/stop', { method: 'post' }); - expect(response.headers.get('x-percy-core-version')).toMatch(pkg.version); - await expectAsync(response.json()).toBeResolvedTo({ success: true }); - expect(percy.stop).toHaveBeenCalled(); - }); - - it('has a /snapshot endpoint that calls #snapshot() with provided options', async () => { - spyOn(percy, 'snapshot').and.resolveTo(); - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/snapshot', { - method: 'post', - body: '{ "test-me": true, "me_too": true }' - }); - - expect(response.headers.get('x-percy-core-version')).toMatch(pkg.version); - await expectAsync(response.json()).toBeResolvedTo({ success: true }); - expect(percy.snapshot).toHaveBeenCalledOnceWith({ 'test-me': true, me_too: true }); - }); - - it('can handle snapshots async with a parameter', async () => { - let test = new Promise(r => setTimeout(r, 500)); - spyOn(percy, 'snapshot').and.returnValue(test); - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/snapshot?async', { - method: 'post', - body: '{}' - }); - - await expectAsync(response.json()).toBeResolvedTo({ success: true }); - await expectAsync(test).toBePending(); - await test; // no hanging promises - }); - - it('returns a 500 error when an endpoint throws', async () => { - spyOn(percy, 'snapshot').and.rejectWith(new Error('test error')); - await percy.start(); - - let response = await fetch('http://localhost:1337/percy/snapshot', { - method: 'post', - body: '{ "test": true }' - }); - - expect(response.headers.get('x-percy-core-version')).toMatch(pkg.version); - await expectAsync(response.json()).toBeResolvedTo({ - success: false, - error: 'test error' - }); - }); - - it('returns a 404 for any other endpoint', async () => { - await percy.start(); - - let response = await fetch('http://localhost:1337/foobar'); - expect(response).toHaveProperty('status', 404); - - expect(response.headers.get('x-percy-core-version')).toMatch(pkg.version); - await expectAsync(response.json()).toBeResolvedTo({ - success: false, - error: 'Not found' - }); - }); - - it('accepts preflight cors checks', async () => { - let response; - - spyOn(percy, 'snapshot').and.resolveTo(); - await percy.start(); - - response = await fetch('http://localhost:1337/percy/snapshot', { - method: 'OPTIONS' - }); - - expect(response.status).toBe(204); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,OPTIONS'); - expect(response.headers.get('Access-Control-Request-Headers')).toBe('Vary'); - expect(response.headers.get('Access-Control-Expose-Headers')).toBe('X-Percy-Core-Version'); - expect(percy.snapshot).not.toHaveBeenCalled(); - - response = await fetch('http://localhost:1337/percy/snapshot', { - headers: { 'Access-Control-Request-Headers': 'Content-Type' }, - method: 'OPTIONS' - }); - - expect(response.status).toBe(204); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type'); - expect(percy.snapshot).not.toHaveBeenCalled(); - }); - - it('facilitates logger websocket connections', async () => { - await percy.start(); - - logger.reset(); - logger.loglevel('debug'); - - // log from a separate async process - let [stdout, stderr] = await new Promise((resolve, reject) => { - require('child_process').exec('node -e "' + [ - "let logger = require('@percy/logger');", - "let ws = new (require('ws'))('ws://localhost:1337');", - "logger.loglevel('debug');", - 'logger.remote(() => ws)', - " .then(() => logger('remote-sdk').info('whoa'))", - ' .then(() => setTimeout(() => ws.close(), 100));' - ].join('') + '"', (err, stdout, stderr) => { - if (!err) resolve([stdout, stderr]); - else reject(err); - }); - }); - - // child logs are present on connection failure - expect(stdout.toString()).toEqual(''); - expect(stderr.toString()).toEqual(''); - - expect(logger.stderr).toEqual([]); - expect(logger.stdout).toEqual([ - '[percy:remote-sdk] whoa' - ]); - }); - - describe('when the server is disabled', () => { - beforeEach(async () => { - percy = await Percy.start({ - token: 'PERCY_TOKEN', - server: false - }); - }); - - it('does not start a server with #start()', async () => { - await expectAsync(fetch('http://localhost:5883')) - .toBeRejectedWithError(/ECONNREFUSED/); - }); - - it('does not error when stopping', async () => { - await expectAsync(percy.stop()).toBeResolved(); - }); - }); -}); diff --git a/packages/core/test/unit/server.test.js b/packages/core/test/unit/server.test.js new file mode 100644 index 000000000..2d4eb6292 --- /dev/null +++ b/packages/core/test/unit/server.test.js @@ -0,0 +1,384 @@ +import quibble from 'quibble'; +import * as memfs from 'memfs'; + +describe('Unit / Server', () => { + let Server, server; + + async function request(path, ...args) { + let { request } = await import('../helpers/request'); + return request(new URL(path, server.address()), ...args); + } + + beforeEach(async () => { + quibble('fs', memfs.fs); + memfs.vol.mkdirSync(process.cwd(), { recursive: true }); + ({ Server } = await import('../../src/server')); + server = new Server({ port: 8000 }); + }); + + afterEach(async () => { + await server.close(); + // wait 2 ticks before reseting memfs too quickly + await new Promise(r => setImmediate(setImmediate, r)); + memfs.vol.reset(); + quibble.reset(); + }); + + describe('#port', () => { + it('returns the provided default port when not listening', () => { + expect(server.port).toEqual(8000); + }); + + it('returns the port in use when listening', async () => { + await server.listen(9000); + expect(server.port).toEqual(9000); + }); + }); + + describe('#address()', () => { + it('returns the localhost address for the server', () => { + expect(server.address()).toEqual('http://localhost:8000'); + }); + + it('does not include the port without a default when not listening', () => { + expect(new Server().address()).toEqual('http://localhost'); + }); + }); + + describe('#listen([port])', () => { + it('resolves when the server begins listening for requests', async () => { + expect(server.listening).toEqual(false); + await server.listen(); + expect(server.listening).toEqual(true); + }); + + it('can listen on the provided port instead of the default port', async () => { + expect(server.port).toEqual(8000); + await server.listen(9000); + expect(server.port).toEqual(9000); + }); + + it('rejects when an error occurs trying to listen', async () => { + await server.listen(); + await expectAsync(new Server().listen(server.port)).toBeRejected(); + }); + }); + + describe('#close()', () => { + it('resolves when the server stops listening', async () => { + await server.listen(); + expect(server.listening).toEqual(true); + await server.close(); + expect(server.listening).toEqual(false); + }); + }); + + describe('#websocket([pathname], handler)', () => { + async function ws(path) { + let res, rej, deferred; + let { default: WS } = await import('ws'); + deferred = new Promise((...a) => ([res, rej] = a)); + new WS(server.address().replace('http', 'ws') + path) + .once('message', v => res(v.toString())) + .once('error', e => rej(e)); + return deferred; + } + + beforeEach(async () => { + server.websocket('/foo', ws => ws.send('bar')); + await server.listen(); + }); + + it('handles websocket upgrades at the specified pathname', async () => { + await expectAsync(ws('/foo')).toBeResolvedTo('bar'); + await expectAsync(ws('/')) + .toBeRejectedWithError('Unexpected server response: 400'); + }); + + it('handles websocket upgrades without a pathname', async () => { + server.websocket(ws => ws.send('foo')); + server.websocket('/bar', ws => ws.send('baz')); + + await expectAsync(ws('/')).toBeResolvedTo('foo'); + await expectAsync(ws('/foo')).toBeResolvedTo('bar'); + await expectAsync(ws('/bar')).toBeResolvedTo('baz'); + await expectAsync(ws('/baz')).toBeResolvedTo('foo'); + }); + }); + + describe('#route([method][, pathname], handler)', () => { + beforeEach(async () => { + await server.listen(); + }); + + it('routes requests matching a method and pathname', async () => { + server.route('get', '/test/:path', (req, res) => res.text(req.params.path)); + + await expectAsync(request('/test/foo', 'GET')).toBeResolvedTo('foo'); + await expectAsync(request('/test/foo', 'POST')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/test/bar', 'GET')).toBeResolvedTo('bar'); + await expectAsync(request('/test/bar', 'PUT')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/foo/bar', 'GET')).toBeRejectedWithError('404 Not Found'); + }); + + it('routes requests matching multiple methods for a pathname', async () => { + server.route(['get', 'post'], '/test', (req, res) => res.text('foo')); + + await expectAsync(request('/test', 'GET')).toBeResolvedTo('foo'); + await expectAsync(request('/test', 'POST')).toBeResolvedTo('foo'); + await expectAsync(request('/test', 'PUT')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/test', 'DELETE')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/foo', 'GET')).toBeRejectedWithError('404 Not Found'); + }); + + it('routes requests matching a method for any pathname', async () => { + server.route('post', (req, res) => res.text(req.url.pathname.slice(1))); + + await expectAsync(request('/foo', 'POST')).toBeResolvedTo('foo'); + await expectAsync(request('/foo', 'GET')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/bar', 'POST')).toBeResolvedTo('bar'); + await expectAsync(request('/bar', 'PUT')).toBeRejectedWithError('404 Not Found'); + }); + + it('routes requests matching multiple methods for any pathname', async () => { + server.route(['get', 'post'], (req, res) => res.text(req.url.pathname.slice(1))); + + await expectAsync(request('/foo', 'GET')).toBeResolvedTo('foo'); + await expectAsync(request('/foo', 'PUT')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/bar', 'POST')).toBeResolvedTo('bar'); + await expectAsync(request('/bar', 'DELETE')).toBeRejectedWithError('404 Not Found'); + }); + + it('routes requests matching any method for a pathname', async () => { + server.route('/test/:path', (req, res) => res.text(req.params.path)); + + await expectAsync(request('/test/foo', 'GET')).toBeResolvedTo('foo'); + await expectAsync(request('/test/bar', 'POST')).toBeResolvedTo('bar'); + await expectAsync(request('/test/baz', 'PUT')).toBeResolvedTo('baz'); + await expectAsync(request('/test/qux', 'DELETE')).toBeResolvedTo('qux'); + await expectAsync(request('/foo/bar', 'GET')).toBeRejectedWithError('404 Not Found'); + }); + + it('routes requests matching any method and any pathname', async () => { + server.route((req, res) => res.json(req.url.pathname.slice(1))); + + await expectAsync(request('/foo', 'GET')).toBeResolvedTo('foo'); + await expectAsync(request('/foo', 'POST')).toBeResolvedTo('foo'); + await expectAsync(request('/bar', 'GET')).toBeResolvedTo('bar'); + await expectAsync(request('/bar', 'PUT')).toBeResolvedTo('bar'); + await expectAsync(request('/foo/bar', 'DELETE')).toBeResolvedTo('foo/bar'); + }); + + it('can send text, json, or other content with an optional status code', async () => { + server.route('/:test', (req, res) => res[req.params.test](...req.body)); + let test = (t, b) => request(`/${t}`, { method: 'POST', body: b }, true); + + // dry up expectations below + let res = (status, type, body = '') => [body, jasmine.objectContaining({ + headers: jasmine.objectContaining( + typeof type === 'string' ? { 'content-type': type } : type ?? {}), + statusCode: status + })]; + + await expectAsync(test('text', ['hello'])) + .toBeResolvedTo(res(200, 'text/plain', 'hello')); + await expectAsync(test('text', [201, 'hello'])) + .toBeResolvedTo(res(201, 'text/plain', 'hello')); + await expectAsync(test('json', [{ foo: 'bar' }])) + .toBeResolvedTo(res(200, 'application/json', { foo: 'bar' })); + await expectAsync(test('json', [202, { foo: 'bar' }])) + .toBeResolvedTo(res(202, 'application/json', { foo: 'bar' })); + await expectAsync(test('send', [200, 'text/html', '

hello

'])) + .toBeResolvedTo(res(200, 'text/html', '

hello

')); + await expectAsync(test('send', [201, { 'X-Foo': 'bar' }])) + .toBeResolvedTo(res(201, { 'x-foo': 'bar' })); + await expectAsync(test('send', [204])) + .toBeResolvedTo(res(204)); + }); + + it('parses request body contents', async () => { + server.route('/q', (req, res) => res.json(Object.fromEntries(req.url.searchParams))); + server.route('/j', (req, res) => res.json(req.body)); + + await expectAsync(request('/q?a=b')).toBeResolvedTo({ a: 'b' }); + await expectAsync(request('/j', { + method: 'POST', + body: { foo: ['bar', { baz: true }] } + })).toBeResolvedTo({ + foo: ['bar', { baz: true }] + }); + }); + + it('handles CORS preflight requests', async () => { + server.route(['get', 'post'], '/1', (req, res) => res.send(200)); + server.route(['put', 'delete'], '/2', (req, res) => res.text(200)); + + let res1 = await request('/1', 'OPTIONS', false); + + expect(res1.statusCode).toBe(204); + expect(res1.headers).toHaveProperty('access-control-allow-origin', '*'); + expect(res1.headers).toHaveProperty('access-control-allow-headers', '*'); + expect(res1.headers).toHaveProperty('access-control-allow-methods', 'GET, POST'); + + let res2 = await request('/2', { + method: 'OPTIONS', + headers: { 'Access-Control-Request-Headers': 'Content-Type' } + }, false); + + expect(res2.statusCode).toBe(204); + expect(res2.headers).toHaveProperty('access-control-allow-origin', '*'); + expect(res2.headers).toHaveProperty('access-control-allow-headers', 'Content-Type'); + expect(res2.headers).toHaveProperty('access-control-allow-methods', 'PUT, DELETE'); + }); + + it('handles server errors', async () => { + server.route('/e/foo', () => { throw new Error('foo'); }); + server.route('/e/bar', () => { throw new Server.Error(418); }); + await expectAsync(request('/e/foo')).toBeRejectedWithError('500 Internal Server Error'); + await expectAsync(request('/e/bar')).toBeRejectedWithError('418 I\'m a Teapot'); + }); + + it('handles not found errors', async () => { + let res = await request('/404').catch(e => e.response); + + expect(res.statusCode).toBe(404); + expect(res.headers).toHaveProperty('content-type', 'text/plain'); + expect(res.body).toEqual('Not Found'); + }); + + it('handles json request errors', async () => { + let res = await request('/404', { + method: 'POST', + body: { testing: 'hello?' } + }).catch(e => e.response); + + expect(res.statusCode).toBe(404); + expect(res.headers).toHaveProperty('content-type', 'application/json'); + expect(res.body).toEqual({ error: 'Not Found' }); + }); + }); + + describe('#serve([pathname], directory[, options])', () => { + beforeEach(async () => { + await server.listen(); + + memfs.vol.fromJSON({ + './public/index.html': '

test

', + './public/foo.html': '

foo

', + './public/foo/bar.html': '

foo/bar

' + }); + }); + + it('serves directory contents at the specified pathname', async () => { + server.serve('/test', './public'); + + await expectAsync(request('/index.html')).toBeRejectedWithError('404 Not Found'); + await expectAsync(request('/test/index.html')).toBeResolvedTo('

test

'); + await expectAsync(request('/test/foo.html')).toBeResolvedTo('

foo

'); + await expectAsync(request('/test/foo/bar.html')).toBeResolvedTo('

foo/bar

'); + }); + + it('serves directory contents at the base-url without a pathname', async () => { + server.serve('./public'); + + await expectAsync(request('/index.html')).toBeResolvedTo('

test

'); + await expectAsync(request('/foo.html')).toBeResolvedTo('

foo

'); + await expectAsync(request('/foo/bar.html')).toBeResolvedTo('

foo/bar

'); + await expectAsync(request('/test/index.html')).toBeRejectedWithError('404 Not Found'); + }); + + it('serves directory contents derived from url rewrites', async () => { + server.serve('./public', { + rewrites: { '/foo/:path+': '/foo/bar.html' }, + cleanUrls: true + }); + + await expectAsync(request('/')).toBeResolvedTo('

test

'); + await expectAsync(request('/foo')).toBeResolvedTo('

foo

'); + await expectAsync(request('/foo/bar')).toBeResolvedTo('

foo/bar

'); + await expectAsync(request('/foo/bar/baz')).toBeResolvedTo('

foo/bar

'); + await expectAsync(request('/foo/bar/baz/qux')).toBeResolvedTo('

foo/bar

'); + }); + + it('serves partial content from a byte range', async () => { + server.serve('./public'); + + let get = range => request('/foo/bar.html', { + headers: { Range: `bytes=${range}` } + }, true); + + let [fromEnd, res1] = await get('-8'); + expect(res1.statusCode).toBe(206); + expect(res1.headers).toHaveProperty('content-range', 'bytes 6-13/14'); + expect(res1.headers).toHaveProperty('content-length', '8'); + + let [toEnd, res2] = await get('6-'); + expect(res2.statusCode).toBe(206); + expect(res2.headers).toHaveProperty('content-range', 'bytes 6-13/14'); + expect(res2.headers).toHaveProperty('content-length', '8'); + expect(fromEnd).toEqual(toEnd); + + let [foo, res3] = await get('3-5'); + expect(res3.headers).toHaveProperty('content-range', 'bytes 3-5/14'); + expect(res3.headers).toHaveProperty('content-length', '3'); + expect(foo).toEqual('foo'); + }); + + it('serves static error pages if present', async () => { + server.serve('./public'); + + memfs.vol.writeFileSync('./public/400.html', '

Wat?

'); + memfs.vol.writeFileSync('./public/404.html', '

Not here

'); + + let e1 = await request('/%E0%A4%A').catch(e => e.response); + let e2 = await request('/foobar').catch(e => e.response); + + expect(e1.body).toEqual('

Wat?

'); + expect(e2.body).toEqual('

Not here

'); + }); + + it('does not serve content when other routes match', async () => { + let handler = (req, res) => res.json(req.params); + + server.route('/foo{:ext}', handler); + server.serve('./public'); + server.route('/foo/:path', handler); + + await expectAsync(request('/index.html')).toBeResolvedTo('

test

'); + await expectAsync(request('/foo.html')).toBeResolvedTo({ ext: '.html' }); + await expectAsync(request('/foo/bar.html')).toBeResolvedTo({ path: 'bar.html' }); + await expectAsync(request('/foo/bar')).toBeResolvedTo({ path: 'bar' }); + }); + + it('does not serve content from an unsatisfiable byte range', async () => { + server.serve('./public'); + + let [body, res] = await request('/foo/bar.html', { + headers: { Range: 'bytes=1000-2000' } + }, true); + + expect(res.statusCode).toBe(416); + expect(res.headers).toHaveProperty('content-range', 'bytes */14'); + expect(body).toEqual(''); + }); + + it('does not serve content from an invalid byte range', async () => { + server.serve('./public'); + + let [body, res] = await request('/foo/bar.html', { + headers: { Range: 'bites=a-b' } + }, true); + + expect(res.statusCode).toBe(416); + expect(res.headers).toHaveProperty('content-range', 'bytes */14'); + expect(body).toEqual(''); + }); + + it('protects against path traversal', async () => { + server.serve('./public'); + memfs.vol.writeFileSync('./secret', '*wags finger* ☝️'); + // by encoding `../` we can sneak past `new URL().pathname` sanitization + await expectAsync(request('/%2E%2E%2Fsecret')).toBeRejectedWithError('400 Bad Request'); + }); + }); +}); diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index b9111304d..3fc10d02d 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -94,7 +94,7 @@ describe('SDK Utils', () => { }); it('disables snapshots when the API version is unsupported', async () => { - await helpers.call('server.version', ''); + await helpers.call('server.version', '0.1.0'); await expectAsync(isPercyEnabled()).toBeResolvedTo(false); expect(helpers.logger.stdout).toEqual([ diff --git a/packages/sdk-utils/test/server.js b/packages/sdk-utils/test/server.js index f34e86a5f..280736f5e 100644 --- a/packages/sdk-utils/test/server.js +++ b/packages/sdk-utils/test/server.js @@ -6,10 +6,12 @@ function context() { async call(path, ...args) { let [key, ...paths] = path.split('.').reverse(); let subject = paths.reduceRight((c, k) => c && c[k], ctx); - if (!(subject && key in subject)) return; + if (!subject) return; - let { value, get, set } = Object - .getOwnPropertyDescriptor(subject, key); + let { value, get, set } = ( + Object.getOwnPropertyDescriptor(subject, key) || + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(subject), key) + ) || {}; if (typeof value === 'function') { value = await value.apply(subject, args); @@ -40,7 +42,8 @@ function context() { }; if (ctx.server.close) ctx.server.close(); - ctx.server = Object.assign(await createTestServer({ + + ctx.server = await createTestServer({ '/percy/dom.js': () => [200, 'application/javascript', ( `window.PercyDOM = { serialize: ${serializeDOM} }`)], '/percy/healthcheck': () => [200, 'application/json', ( @@ -48,17 +51,24 @@ function context() { '/percy/config': ({ body }) => [200, 'application/json', ( { success: true, config: body })], '/percy/snapshot': () => [200, 'application/json', { success: true }] - }, 5338), { + }, 5338); + + ctx.server.route((req, res, next) => { + if (req.body) try { req.body = JSON.parse(req.body); } catch {} + res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version'); + res.setHeader('X-Percy-Core-Version', ctx.server.version || '1.0.0'); + return next(); + }); + + ctx.server.websocket(ws => { + if (!allowSocketConnections) return ws.terminate(); + ws.onmessage = ({ data }) => ctx.server.messages.push(data); + }); + + Object.assign(ctx.server, { mock: mockServer, messages: [], - reply: (url, route) => { - ctx.server.routes[url] = ( - typeof route === 'function' - ? route : () => route - ); - }, - test: { get serialize() { return serializeDOM; }, set serialize(fn) { return (serializeDOM = fn); }, @@ -68,17 +78,6 @@ function context() { remote: () => (allowSocketConnections = true) } }); - - ctx.wss = new (require('ws').Server)({ noServer: true }); - ctx.server.server.on('upgrade', (req, sock, head) => { - if (allowSocketConnections) { - ctx.wss.handleUpgrade(req, sock, head, socket => { - socket.onmessage = ({ data }) => ctx.server.messages.push(data); - }); - } else { - sock.destroy(); - } - }); }; let mockSite = async () => { diff --git a/yarn.lock b/yarn.lock index 2d47beace..3370702f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2819,6 +2819,13 @@ content-disposition@0.5.2: resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= +content-disposition@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -5350,6 +5357,11 @@ mime-db@1.45.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== +mime-db@1.51.0: + version "1.51.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c" + integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g== + mime-db@~1.33.0: version "1.33.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" @@ -5369,6 +5381,13 @@ mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24: dependencies: mime-db "1.45.0" +mime-types@^2.1.34: + version "2.1.34" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24" + integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A== + dependencies: + mime-db "1.51.0" + mime@^2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" @@ -6387,6 +6406,14 @@ queue@6.0.2: dependencies: inherits "~2.0.3" +quibble@^0.6.8: + version "0.6.8" + resolved "https://registry.yarnpkg.com/quibble/-/quibble-0.6.8.tgz#cd7d485e5fd985217b8f064596523a3ee554ec00" + integrity sha512-HQ89ZADQ4uZjyePn1yACZADE3OUQ16Py5gkVxcoPvV6IRiItAgGMBkeQ5f1rOnnnsVKccMdhic0xABd7+cY/jA== + dependencies: + lodash "^4.17.21" + resolve "^1.20.0" + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -6794,7 +6821,7 @@ rxjs@^6.6.0: dependencies: tslib "^1.9.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==