diff --git a/Dockerfile b/Dockerfile index 68b03be4072b6302e00b7c4df08f7448107ce1aa..30d3ef6a0914321ee5f17512ac337a58cb025adb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM node:8-alpine -RUN apk add --no-cache gettext imagemagick librsvg git - RUN mkdir -p /usr/src/app RUN mkdir /usr/src/app/private WORKDIR /usr/src/app diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 87b75a15894636695ae5faa861b4e81ae508a60d..10908f7ad02525f5bdbda520a5e4113bca191d61 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -14,6 +14,8 @@ public: redirectUri: 'REDIRECT_URI' + rasterUrl: 'RASTER_URL' + cors: allowedOrigin: __name: 'ALLOWED_ORIGIN' diff --git a/config/shields-io-production.yml b/config/shields-io-production.yml index f0f0c88f6099de91ea8bcef0e2cf66b5ff1b1309..1ca9d5805a5afdfcd3d139994c4f6c9a29bbbb99 100644 --- a/config/shields-io-production.yml +++ b/config/shields-io-production.yml @@ -11,6 +11,8 @@ public: redirectUrl: 'https://shields.io/' + rasterUrl: 'https://raster.shields.io' + private: # These are not really private; they should be moved to `public`. shields_ips: ['192.99.59.72', '51.254.114.150', '149.56.96.133'] diff --git a/config/test.yml b/config/test.yml index a09fada736276316ae810b344f6e68444c472da0..a330d56b1491c864f49a9a7f47a9b1920869b809 100644 --- a/config/test.yml +++ b/config/test.yml @@ -7,4 +7,6 @@ public: redirectUrl: 'http://badge-server.example.com' + rasterUrl: 'http://raster.example.com' + handleInternalErrors: false diff --git a/core/badge-urls/make-badge-url.js b/core/badge-urls/make-badge-url.js index 7dd9a3a85cdd1a823839d4d8291e297ee72631f2..f272f5ec5f9d351de5d001fb4ec64ca1f9f41376 100644 --- a/core/badge-urls/make-badge-url.js +++ b/core/badge-urls/make-badge-url.js @@ -1,5 +1,6 @@ 'use strict' +const { URL } = require('url') const queryString = require('query-string') const pathToRegexp = require('path-to-regexp') @@ -134,6 +135,15 @@ function dynamicBadgeUrl({ return `${baseUrl}/badge/dynamic/${datatype}.${format}?${outQueryString}` } +function rasterRedirectUrl({ rasterUrl }, badgeUrl) { + // Ensure we're always using the `rasterUrl` by using just the path from + // the request URL. + const { pathname, search } = new URL(badgeUrl, 'https://bogus.test') + const result = new URL(pathname, rasterUrl) + result.search = search + return result +} + module.exports = { badgeUrlFromPath, badgeUrlFromPattern, @@ -141,4 +151,5 @@ module.exports = { staticBadgeUrl, queryStringStaticBadgeUrl, dynamicBadgeUrl, + rasterRedirectUrl, } diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index 4c2f99a4baf274a7f0a5cf64cb2360adf7003424..0a9297fca5b992bb4991967b6972ab2ed3f0fa63 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -316,7 +316,7 @@ describe('BaseService', function() { }) describe('ScoutCamp integration', function() { - const expectedRouteRegex = /^\/foo\/([^/]+?)\.(svg|png|gif|jpg|json)$/ + const expectedRouteRegex = /^\/foo\/([^/]+?)\.(svg|json)$/ let mockCamp let mockHandleRequest diff --git a/core/base-service/legacy-request-handler.js b/core/base-service/legacy-request-handler.js index 30980f77083aae35b77352138ece79a8068d1ac6..0639c098d59463bdf68a488f836c2e0558d4ccc0 100644 --- a/core/base-service/legacy-request-handler.js +++ b/core/base-service/legacy-request-handler.js @@ -4,7 +4,6 @@ const domain = require('domain') const request = require('request') const queryString = require('query-string') -const LruCache = require('../../gh-badges/lib/lru-cache') const makeBadge = require('../../gh-badges/lib/make-badge') const log = require('../server/log') const { setCacheHeaders } = require('./cache-headers') @@ -14,6 +13,7 @@ const { ShieldsRuntimeError, } = require('./errors') const { makeSend } = require('./legacy-result-sender') +const LruCache = require('./lru-cache') const coalesceBadge = require('./coalesce-badge') // We avoid calling the vendor's server for computation of the information in a diff --git a/core/base-service/legacy-result-sender.js b/core/base-service/legacy-result-sender.js index 8b733eb0fab6600566491b7e06998dd7f552d1da..01ffc800cc6d9c7427b74796070101fd90eeea7f 100644 --- a/core/base-service/legacy-result-sender.js +++ b/core/base-service/legacy-result-sender.js @@ -1,15 +1,6 @@ 'use strict' -const fs = require('fs') -const path = require('path') const stream = require('stream') -const svg2img = require('../../gh-badges/lib/svg-to-img') -const log = require('../server/log') - -const internalError = fs.readFileSync( - path.resolve(__dirname, '..', 'server', 'error-pages', '500.html'), - 'utf-8' -) function streamFromString(str) { const newStream = new stream.Readable() @@ -25,21 +16,6 @@ function sendSVG(res, askres, end) { end(null, { template: streamFromString(res) }) } -function sendOther(format, res, askres, end) { - askres.setHeader('Content-Type', `image/${format}`) - svg2img(res, format) - // This interacts with callback code and can't use async/await. - // eslint-disable-next-line promise/prefer-await-to-then - .then(data => { - end(null, { template: streamFromString(data) }) - }) - .catch(err => { - // This emits status code 200, though 500 would be preferable. - log.error('svg2img error', err) - end(internalError) - }) -} - function sendJSON(res, askres, end) { askres.setHeader('Content-Type', 'application/json') askres.setHeader('Access-Control-Allow-Origin', '*') @@ -52,7 +28,7 @@ function makeSend(format, askres, end) { } else if (format === 'json') { return res => sendJSON(res, askres, end) } else { - return res => sendOther(format, res, askres, end) + throw Error(`Unrecognized format: ${format}`) } } diff --git a/gh-badges/lib/lru-cache.js b/core/base-service/lru-cache.js similarity index 100% rename from gh-badges/lib/lru-cache.js rename to core/base-service/lru-cache.js diff --git a/gh-badges/lib/lru-cache.spec.js b/core/base-service/lru-cache.spec.js similarity index 100% rename from gh-badges/lib/lru-cache.spec.js rename to core/base-service/lru-cache.spec.js diff --git a/core/base-service/redirector.js b/core/base-service/redirector.js index ab4879faa26687512e8500d0c768efb4f5b1ab4b..d0a5d15c5166d88afe10128a8a85004dc037514b 100644 --- a/core/base-service/redirector.js +++ b/core/base-service/redirector.js @@ -62,8 +62,11 @@ module.exports = function redirector(attrs) { return route } - static register({ camp, requestCounter }) { - const { regex, captureNames } = prepareRoute(this.route) + static register({ camp, requestCounter }, { rasterUrl }) { + const { regex, captureNames } = prepareRoute({ + ...this.route, + withPng: Boolean(rasterUrl), + }) const serviceRequestCounter = this._createServiceRequestCounter({ requestCounter, @@ -104,7 +107,9 @@ module.exports = function redirector(attrs) { // The final capture group is the extension. const format = match.slice(-1)[0] - const redirectUrl = `${targetPath}.${format}${urlSuffix}` + const redirectUrl = `${ + format === 'png' ? rasterUrl : '' + }${targetPath}.${format}${urlSuffix}` trace.logTrace('outbound', emojic.shield, 'Redirect URL', redirectUrl) ask.res.statusCode = 301 diff --git a/core/base-service/redirector.spec.js b/core/base-service/redirector.spec.js index 1bd4ff9d7fe07e7d013ba20e3d2ee6f518949c1c..6c83f8b54ca696ed334bb256dfa8d463aa79c8ed 100644 --- a/core/base-service/redirector.spec.js +++ b/core/base-service/redirector.spec.js @@ -75,7 +75,10 @@ describe('Redirector', function() { transformPath, dateAdded, }) - ServiceClass.register({ camp }, {}) + ServiceClass.register( + { camp }, + { rasterUrl: 'http://raster.example.test' } + ) }) it('should redirect as configured', async function() { @@ -90,7 +93,7 @@ describe('Redirector', function() { expect(headers.location).to.equal('/new/service/hello-world.svg') }) - it('should preserve the extension', async function() { + it('should redirect raster extensions to the canonical path as configured', async function() { const { statusCode, headers } = await got( `${baseUrl}/very/old/service/hello-world.png`, { @@ -99,7 +102,9 @@ describe('Redirector', function() { ) expect(statusCode).to.equal(301) - expect(headers.location).to.equal('/new/service/hello-world.png') + expect(headers.location).to.equal( + 'http://raster.example.test/new/service/hello-world.png' + ) }) it('should forward the query params', async function() { diff --git a/core/base-service/route.js b/core/base-service/route.js index 0192243017b8993cff2f1be18dbde7dbcccde25a..3bdf15de7c7c2553f036d4131cb1f643b5137979 100644 --- a/core/base-service/route.js +++ b/core/base-service/route.js @@ -26,18 +26,16 @@ function assertValidRoute(route, message = undefined) { Joi.assert(route, isValidRoute, message) } -function prepareRoute({ base, pattern, format, capture }) { +function prepareRoute({ base, pattern, format, capture, withPng }) { + const extensionRegex = ['svg', 'json'] + .concat(withPng ? ['png'] : []) + .join('|') let regex, captureNames if (pattern === undefined) { - regex = new RegExp( - `^${makeFullUrl(base, format)}\\.(svg|png|gif|jpg|json)$` - ) + regex = new RegExp(`^${makeFullUrl(base, format)}\\.(${extensionRegex})$`) captureNames = capture || [] } else { - const fullPattern = `${makeFullUrl( - base, - pattern - )}.:ext(svg|png|gif|jpg|json)` + const fullPattern = `${makeFullUrl(base, pattern)}.:ext(${extensionRegex})` const keys = [] regex = pathToRegexp(fullPattern, keys, { strict: true, diff --git a/core/base-service/route.spec.js b/core/base-service/route.spec.js index 8eda13d66669d5887d7c274b5cb70e06d843d55a..5b5331f8da9747f029caa855900ff1b8d7126c73 100644 --- a/core/base-service/route.spec.js +++ b/core/base-service/route.spec.js @@ -33,9 +33,6 @@ describe('Route helpers', function() { test(namedParams, () => { forCases([ given('/foo/bar.bar.bar.svg'), - given('/foo/bar.bar.bar.png'), - given('/foo/bar.bar.bar.gif'), - given('/foo/bar.bar.bar.jpg'), given('/foo/bar.bar.bar.json'), ]).expect({ namedParamA: 'bar.bar.bar' }) }) @@ -64,9 +61,6 @@ describe('Route helpers', function() { test(namedParams, () => { forCases([ given('/foo/bar.bar.bar.svg'), - given('/foo/bar.bar.bar.png'), - given('/foo/bar.bar.bar.gif'), - given('/foo/bar.bar.bar.jpg'), given('/foo/bar.bar.bar.json'), ]).expect({ namedParamA: 'bar.bar.bar' }) }) @@ -83,9 +77,6 @@ describe('Route helpers', function() { test(namedParams, () => { forCases([ given('/foo/bar.bar.bar.svg'), - given('/foo/bar.bar.bar.png'), - given('/foo/bar.bar.bar.gif'), - given('/foo/bar.bar.bar.jpg'), given('/foo/bar.bar.bar.json'), ]).expect({}) }) diff --git a/core/server/server.js b/core/server/server.js index 1fb650c908642ad831190b73f67996763d2c3987..c8000c115c7d9494ecc3ad4591d1e15795e581de 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -16,7 +16,10 @@ const { clearRequestCache, } = require('../base-service/legacy-request-handler') const { clearRegularUpdateCache } = require('../legacy/regular-update') -const { staticBadgeUrl } = require('../badge-urls/make-badge-url') +const { + staticBadgeUrl, + rasterRedirectUrl, +} = require('../badge-urls/make-badge-url') const log = require('./log') const sysMonitor = require('./monitor') const PrometheusMetrics = require('./prometheus-metrics') @@ -52,6 +55,7 @@ const publicConfigSchema = Joi.object({ cert: Joi.string(), }, redirectUrl: optionalUrl, + rasterUrl: optionalUrl, cors: { allowedOrigin: Joi.array() .items(optionalUrl) @@ -183,16 +187,43 @@ module.exports = class Server { * Set up Scoutcamp routes for 404/not found responses */ registerErrorHandlers() { - const { camp } = this + const { camp, config } = this + const { + public: { rasterUrl }, + } = config - camp.notfound(/\.(svg|png|gif|jpg|json)/, (query, match, end, request) => { + camp.notfound(/\.(gif|jpg)$/, (query, match, end, request) => { const [, format] = match - const svg = makeBadge({ - text: ['404', 'badge not found'], - color: 'red', - format, + makeSend('svg', request.res, end)( + makeBadge({ + text: ['410', `${format} no longer available`], + color: 'lightgray', + format: 'svg', + }) + ) + }) + + if (!rasterUrl) { + camp.notfound(/\.png$/, (query, match, end, request) => { + makeSend('svg', request.res, end)( + makeBadge({ + text: ['404', 'raster badges not available'], + color: 'lightgray', + format: 'svg', + }) + ) }) - makeSend(format, request.res, end)(svg) + } + + camp.notfound(/\.(svg|json)$/, (query, match, end, request) => { + const [, format] = match + makeSend(format, request.res, end)( + makeBadge({ + text: ['404', 'badge not found'], + color: 'red', + format, + }) + ) }) camp.notfound(/.*/, (query, match, end, request) => { @@ -219,6 +250,7 @@ module.exports = class Server { cacheHeaders: config.public.cacheHeaders, profiling: config.public.profiling, fetchLimitBytes: bytes(config.public.fetchLimit), + rasterUrl: config.public.rasterUrl, } ) ) @@ -234,34 +266,57 @@ module.exports = class Server { */ registerRedirects() { const { config, camp } = this + const { + public: { rasterUrl, redirectUrl }, + } = config + + if (rasterUrl) { + // Any badge, old version. + camp.route(/^\/([^/]+)\/(.+).png$/, (queryParams, match, end, ask) => { + const [, label, message] = match + const { color } = queryParams + + const redirectUrl = staticBadgeUrl({ + baseUrl: rasterUrl, + label, + message, + // Fixes https://github.com/badges/shields/issues/3260 + color: color ? color.toString() : undefined, + format: 'png', + }) + + ask.res.statusCode = 301 + ask.res.setHeader('Location', redirectUrl) + + // The redirect is permanent. + const cacheDuration = (365 * 24 * 3600) | 0 // 1 year + ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`) - // Any badge, old version. This route must be registered last. - camp.route(/^\/([^/]+)\/(.+).png$/, (queryParams, match, end, ask) => { - const [, label, message] = match - const { color } = queryParams - - const redirectUrl = staticBadgeUrl({ - label, - message, - // Fixes https://github.com/badges/shields/issues/3260 - color: color ? color.toString() : undefined, - format: 'png', + ask.res.end() }) - ask.res.statusCode = 301 - ask.res.setHeader('Location', redirectUrl) + // Redirect to the raster server for raster versions of modern badges. + camp.route(/\.png$/, (queryParams, match, end, ask) => { + ask.res.statusCode = 301 + ask.res.setHeader( + 'Location', + rasterRedirectUrl({ rasterUrl }, ask.req.url) + ) - // The redirect is permanent. - const cacheDuration = (365 * 24 * 3600) | 0 // 1 year - ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`) + // The redirect is permanent, though let's start off with a shorter + // cache time in case we've made mistakes. + // const cacheDuration = (365 * 24 * 3600) | 0 // 1 year + const cacheDuration = 3600 | 0 // 1 hour + ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`) - ask.res.end() - }) + ask.res.end() + }) + } - if (config.public.redirectUrl) { + if (redirectUrl) { camp.route(/^\/$/, (data, match, end, ask) => { ask.res.statusCode = 302 - ask.res.setHeader('Location', config.public.redirectUrl) + ask.res.setHeader('Location', redirectUrl) ask.res.end() }) } diff --git a/core/server/server.spec.js b/core/server/server.spec.js index 52d21891be20e2ab38099f3ff2d9fb86b7965f7b..a3c8b81839cbbc484027774d6844fa0097c84965 100644 --- a/core/server/server.spec.js +++ b/core/server/server.spec.js @@ -1,13 +1,8 @@ 'use strict' -const fs = require('fs') -const path = require('path') const { expect } = require('chai') -const isPng = require('is-png') const isSvg = require('is-svg') -const sinon = require('sinon') const portfinder = require('portfinder') -const svg2img = require('../../gh-badges/lib/svg-to-img') const got = require('../got-test-client') const { createTestServer } = require('./in-process-server-test-helpers') @@ -37,12 +32,17 @@ describe('The server', function() { .and.to.include('apple') }) - it('should produce colorscheme PNG badges', async function() { - const { statusCode, body } = await got(`${baseUrl}:fruit-apple-green.png`, { - encoding: null, - }) - expect(statusCode).to.equal(200) - expect(body).to.satisfy(isPng) + it('should redirect colorscheme PNG badges as configured', async function() { + const { statusCode, headers } = await got( + `${baseUrl}:fruit-apple-green.png`, + { + followRedirect: false, + } + ) + expect(statusCode).to.equal(301) + expect(headers.location).to.equal( + 'http://raster.example.com/:fruit-apple-green.png' + ) }) it('should preserve label case', async function() { @@ -118,28 +118,14 @@ describe('The server', function() { expect(headers.location).to.equal('http://badge-server.example.com') }) - context('with svg2img error', function() { - const expectedError = fs.readFileSync( - path.resolve(__dirname, 'error-pages', '500.html') - ) - - let toBufferStub - beforeEach(function() { - toBufferStub = sinon - .stub(svg2img._imageMagick.prototype, 'toBuffer') - .callsArgWith(1, Error('whoops')) - }) - afterEach(function() { - toBufferStub.restore() - }) - - it('should emit the 500 message', async function() { - const { statusCode, body } = await got( - `${baseUrl}:some_new-badge-green.png` - ) - // This emits status code 200, though 500 would be preferable. - expect(statusCode).to.equal(200) - expect(body).to.include(expectedError) + it('should return the 410 badge for obsolete formats', async function() { + const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, { + throwHttpErrors: false, }) + expect(statusCode).to.equal(404) + expect(body) + .to.satisfy(isSvg) + .and.to.include('410') + .and.to.include('jpg no longer available') }) }) diff --git a/doc/TUTORIAL.md b/doc/TUTORIAL.md index bfb4667c83d19365c2821297242f0c33979f2393..54cb90e3c5ba4d957dc0d3971a6a02c5f3e4663b 100644 --- a/doc/TUTORIAL.md +++ b/doc/TUTORIAL.md @@ -42,11 +42,6 @@ install node and npm: https://nodejs.org/en/download/ In case you get the _"getaddrinfo ENOTFOUND localhost"_ error, visit [http://127.0.0.1:3000/](http://127.0.0.1:3000) instead or take a look at [this issue](https://github.com/angular/angular-cli/issues/2227#issuecomment-358036526). -You may also want to install -[ImageMagick](https://www.imagemagick.org/script/download.php). -This is an optional dependency needed for generating badges in raster format, -but you can get a dev copy running without it. - ## (3) Open an Issue Before you want to implement your service, you may want to [open an issue](https://github.com/badges/shields/issues/new?template=3_Badge_request.md) and describe what you have in mind: diff --git a/doc/production-hosting.md b/doc/production-hosting.md index e96241061b8a354d85d817ab8e18e1fc3b741057..973e7eaf44ec680f4d00c62b3959688bb0814577 100644 --- a/doc/production-hosting.md +++ b/doc/production-hosting.md @@ -73,12 +73,10 @@ Shields has mercifully little persistent state: inspectable. - The [request cache][] - The [regular-update cache][] - - The [raster cache][] [github auth admin endpoint]: https://github.com/badges/shields/blob/master/services/github/auth/admin.js [request cache]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js#L29-L30 [regular-update cache]: https://github.com/badges/shields/blob/master/core/legacy/regular-update.js -[raster cache]: https://github.com/badges/shields/blob/master/gh-badges/lib/svg-to-img.js#L9-L10 [oauth transfer]: https://developer.github.com/apps/managing-oauth-apps/transferring-ownership-of-an-oauth-app/ ## Configuration diff --git a/doc/self-hosting.md b/doc/self-hosting.md index 880d53e795c998ca04bb16e5ab4edd0160273d18..ae2ab7d48595efdabaf24fa01fb68a080cc29bd1 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -93,6 +93,27 @@ machine. [shields.example.env]: ../shields.example.env +## Raster server + +If you want to host PNG badges, you can also self-host a [raster server][] +which points to your badge server. It's designed as a web function which is +tested on Zeit Now, though you may be able to run it on AWS Lambda. It's +built on the [micro][] framework, and comes with a `start` script that allows +it to run as a standalone Node service. + +- In your raster instance, set `BASE_URL` to your Shields instance, e.g. + `https://shields.example.co`. +- Optionally, in your Shields, instance, configure `RASTER_URL` to the base + URL, e.g. `https://raster.example.co`. This will send 301 redirects + for the legacy raster URLs instead of 404's. + +If anyone has set this up, more documentation on how to do this would be +welcome! It would also be nice to ship a Docker image that includes a +preconfigured raster server. + +[raster server]: https://github.com/badges/svg-to-image-proxy +[micro]: https://github.com/zeit/micro + ## Zeit Now To deploy using Zeit Now: diff --git a/gh-badges/lib/svg-to-img.js b/gh-badges/lib/svg-to-img.js index 61c04b616e1be50aaea68e1029d89b22ab5737f0..a8d11b0ca0a724f2b575b42fd560a14f6c6873f3 100644 --- a/gh-badges/lib/svg-to-img.js +++ b/gh-badges/lib/svg-to-img.js @@ -2,20 +2,10 @@ const { promisify } = require('util') const gm = require('gm') -const LruCache = require('./lru-cache') const imageMagick = gm.subClass({ imageMagick: true }) -// The following is an arbitrary limit (~1.5MB, 1.5kB/image). -const imgCache = new LruCache(1000) - async function svgToImg(svg, format) { - const cacheIndex = `${format}${svg}` - - if (imgCache.has(cacheIndex)) { - return imgCache.get(cacheIndex) - } - const svgBuffer = Buffer.from( `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>${svg}` ) @@ -26,14 +16,10 @@ async function svgToImg(svg, format) { .flatten() const toBuffer = chain.toBuffer.bind(chain) - const data = await promisify(toBuffer)(format) - - imgCache.set(cacheIndex, data) - return data + return promisify(toBuffer)(format) } module.exports = svgToImg // To simplify testing. -module.exports._imgCache = imgCache module.exports._imageMagick = imageMagick diff --git a/gh-badges/lib/svg-to-img.spec.js b/gh-badges/lib/svg-to-img.spec.js index 3406d19db01bac5b0ac7f9060bcb7d6a34e1c656..cd46df659620c2d036a19d51cfb33f3119b64a31 100644 --- a/gh-badges/lib/svg-to-img.spec.js +++ b/gh-badges/lib/svg-to-img.spec.js @@ -2,32 +2,13 @@ const { expect } = require('chai') const isPng = require('is-png') -const sinon = require('sinon') const svg2img = require('./svg-to-img') const makeBadge = require('./make-badge') describe('The rasterizer', function() { - let cacheGet - beforeEach(function() { - cacheGet = sinon.spy(svg2img._imgCache, 'get') - }) - afterEach(function() { - cacheGet.restore() - }) - it('should produce PNG', async function() { const svg = makeBadge({ text: ['cactus', 'grown'], format: 'svg' }) const data = await svg2img(svg, 'png') expect(data).to.satisfy(isPng) }) - - it('should cache its results', async function() { - const svg = makeBadge({ text: ['will-this', 'be-cached?'], format: 'svg' }) - const data1 = await svg2img(svg, 'png') - expect(data1).to.satisfy(isPng) - expect(cacheGet).not.to.have.been.called - const data2 = await svg2img(svg, 'png') - expect(data2).to.satisfy(isPng) - expect(cacheGet).to.have.been.calledOnce - }) })