Repository: badges/shields (js)

Issue:
badge request: Node LTS supported engines badge
Are you experiencing an issue with...

- [x] [shields.io](https://shields.io/#/)
- [ ] My own instance
- [ ] [gh-badges NPM package](https://www.npmjs.com/package/gh-badges)

:beetle: **Description**

When adding a badge for node supported engines like
If only puts a green color if the package supports node 13 which is wrong in my opinion.
Node odd versions are not stable and most libraries don't bother supporting them

:link: **Link to the badge**

![node](https://img.shields.io/node/v/node-upgrade-checker)

:bulb: **Possible Solution**

We need to figure out the latest LTS version of node and not the latest release which might be a non LTS version.
This should be done here:
https://github.com/badges/shields/blob/a2c35a69c0150a7e1d9df94ab14b94d0eb3af473/services/node/node-version-color.js#L9

I found out that you can get the latest version by parsing:
https://github.com/nodejs/Release/blob/master/schedule.json
I will try to work on a quick PR later on today


Pull request description:
fix: [node npm] service has bad colors #4809
Fix #4809

Patch:
diff --git a/services/node/node.service.js b/services/node/node-base.js
similarity index 82%
rename from services/node/node.service.js
rename to services/node/node-base.js
index 31cfef4b1c..2af26cb106 100644
--- a/services/node/node.service.js
+++ b/services/node/node-base.js
@@ -1,23 +1,24 @@
 'use strict'
 
 const NPMBase = require('../npm/npm-base')
-const { versionColorForRange } = require('./node-version-color')
 
 const keywords = ['npm']
 
-module.exports = class NodeVersion extends NPMBase {
+module.exports = class NodeVersionBase extends NPMBase {
   static get category() {
     return 'platform-support'
   }
 
   static get route() {
-    return this.buildRoute('node/v', { withTag: true })
+    return this.buildRoute(`node/${this.path}`, { withTag: true })
   }
 
   static get examples() {
+    const type = this.type
+    const prefix = `node-${type}`
     return [
       {
-        title: 'node',
+        title: `${prefix}`,
         pattern: ':packageName',
         namedParams: { packageName: 'passport' },
         staticPreview: this.renderStaticPreview({
@@ -26,7 +27,7 @@ module.exports = class NodeVersion extends NPMBase {
         keywords,
       },
       {
-        title: 'node (scoped)',
+        title: `${prefix} (scoped)`,
         pattern: '@:scope/:packageName',
         namedParams: { scope: 'stdlib', packageName: 'stdlib' },
         staticPreview: this.renderStaticPreview({
@@ -35,7 +36,7 @@ module.exports = class NodeVersion extends NPMBase {
         keywords,
       },
       {
-        title: 'node (tag)',
+        title: `${prefix} (tag)`,
         pattern: ':packageName/:tag',
         namedParams: { packageName: 'passport', tag: 'latest' },
         staticPreview: this.renderStaticPreview({
@@ -45,7 +46,7 @@ module.exports = class NodeVersion extends NPMBase {
         keywords,
       },
       {
-        title: 'node (scoped with tag)',
+        title: `${prefix} (scoped with tag)`,
         pattern: '@:scope/:packageName/:tag',
         namedParams: { scope: 'stdlib', packageName: 'stdlib', tag: 'latest' },
         staticPreview: this.renderStaticPreview({
@@ -55,7 +56,7 @@ module.exports = class NodeVersion extends NPMBase {
         keywords,
       },
       {
-        title: 'node (scoped with tag, custom registry)',
+        title: `${prefix} (scoped with tag, custom registry)`,
         pattern: '@:scope/:packageName/:tag',
         namedParams: { scope: 'stdlib', packageName: 'stdlib', tag: 'latest' },
         queryParams: { registry_uri: 'https://registry.npmjs.com' },
@@ -68,16 +69,12 @@ module.exports = class NodeVersion extends NPMBase {
     ]
   }
 
-  static get defaultBadgeData() {
-    return { label: 'node' }
-  }
-
   static renderStaticPreview({ tag, nodeVersionRange }) {
     // Since this badge has an async `render()` function, but `get examples()` has to
     // be synchronous, this method exists. It should return the same value as the
     // real `render()`. There's a unit test to check that.
     return {
-      label: tag ? `node@${tag}` : undefined,
+      label: tag ? `${this.defaultBadgeData.label}@${tag}` : undefined,
       message: nodeVersionRange,
       color: 'brightgreen',
     }
@@ -86,7 +83,7 @@ module.exports = class NodeVersion extends NPMBase {
   static async render({ tag, nodeVersionRange }) {
     // Atypically, the `render()` function of this badge is `async` because it needs to pull
     // data from the server.
-    const label = tag ? `node@${tag}` : undefined
+    const label = tag ? `${this.defaultBadgeData.label}@${tag}` : undefined
 
     if (nodeVersionRange === undefined) {
       return {
@@ -98,7 +95,7 @@ module.exports = class NodeVersion extends NPMBase {
       return {
         label,
         message: nodeVersionRange,
-        color: await versionColorForRange(nodeVersionRange),
+        color: await this.colorResolver(nodeVersionRange),
       }
     }
   }
diff --git a/services/node/node-current.service.js b/services/node/node-current.service.js
new file mode 100644
index 0000000000..27ace3ba4c
--- /dev/null
+++ b/services/node/node-current.service.js
@@ -0,0 +1,22 @@
+'use strict'
+
+const NodeVersionBase = require('./node-base')
+const { versionColorForRangeCurrent } = require('./node-version-color')
+
+module.exports = class NodeCurrentVersion extends NodeVersionBase {
+  static get path() {
+    return 'v'
+  }
+
+  static get defaultBadgeData() {
+    return { label: 'node' }
+  }
+
+  static get type() {
+    return 'current'
+  }
+
+  static get colorResolver() {
+    return versionColorForRangeCurrent
+  }
+}
diff --git a/services/node/node-current.spec.js b/services/node/node-current.spec.js
new file mode 100644
index 0000000000..a0a9e46e4b
--- /dev/null
+++ b/services/node/node-current.spec.js
@@ -0,0 +1,23 @@
+'use strict'
+
+const { test, given } = require('sazerac')
+const NodeVersion = require('./node-current.service')
+
+describe('node static renderStaticPreview', function() {
+  it('should have parity with render()', async function() {
+    const nodeVersionRange = '>= 6.0.0'
+
+    const expectedNoTag = await NodeVersion.renderStaticPreview({
+      nodeVersionRange,
+    })
+    const expectedLatestTag = await NodeVersion.renderStaticPreview({
+      nodeVersionRange,
+      tag: 'latest',
+    })
+
+    test(NodeVersion.renderStaticPreview.bind(NodeVersion), () => {
+      given({ nodeVersionRange }).expect(expectedNoTag)
+      given({ nodeVersionRange, tag: 'latest' }).expect(expectedLatestTag)
+    })
+  })
+})
diff --git a/services/node/node-lts.service.js b/services/node/node-lts.service.js
new file mode 100644
index 0000000000..73477191eb
--- /dev/null
+++ b/services/node/node-lts.service.js
@@ -0,0 +1,22 @@
+'use strict'
+
+const NodeVersionBase = require('./node-base')
+const { versionColorForRangeLts } = require('./node-version-color')
+
+module.exports = class NodeLtsVersion extends NodeVersionBase {
+  static get path() {
+    return 'v-lts'
+  }
+
+  static get defaultBadgeData() {
+    return { label: 'node-lts' }
+  }
+
+  static get type() {
+    return 'lts'
+  }
+
+  static get colorResolver() {
+    return versionColorForRangeLts
+  }
+}
diff --git a/services/node/node.spec.js b/services/node/node-lts.spec.js
similarity index 75%
rename from services/node/node.spec.js
rename to services/node/node-lts.spec.js
index 542b412adb..7dfc5ad0bc 100644
--- a/services/node/node.spec.js
+++ b/services/node/node-lts.spec.js
@@ -1,9 +1,9 @@
 'use strict'
 
 const { test, given } = require('sazerac')
-const NodeVersion = require('./node.service')
+const NodeVersion = require('./node-lts.service')
 
-describe('renderStaticPreview', function() {
+describe('node-lts renderStaticPreview', function() {
   it('should have parity with render()', async function() {
     const nodeVersionRange = '>= 6.0.0'
 
@@ -15,7 +15,7 @@ describe('renderStaticPreview', function() {
       tag: 'latest',
     })
 
-    test(NodeVersion.renderStaticPreview, () => {
+    test(NodeVersion.renderStaticPreview.bind(NodeVersion), () => {
       given({ nodeVersionRange }).expect(expectedNoTag)
       given({ nodeVersionRange, tag: 'latest' }).expect(expectedLatestTag)
     })
diff --git a/services/node/node-version-color.js b/services/node/node-version-color.js
index 3e0c5b128e..9208bcbce0 100644
--- a/services/node/node-version-color.js
+++ b/services/node/node-version-color.js
@@ -1,12 +1,19 @@
 'use strict'
 
 const { promisify } = require('util')
+const moment = require('moment')
 const semver = require('semver')
 const { regularUpdate } = require('../../core/legacy/regular-update')
 
-function getLatestVersion() {
+const dateFormat = 'YYYY-MM-DD'
+
+function getVersion(version) {
+  let semver = ``
+  if (version) {
+    semver = `-${version}.x`
+  }
   return promisify(regularUpdate)({
-    url: 'https://nodejs.org/dist/latest/SHASUMS256.txt',
+    url: `https://nodejs.org/dist/latest${semver}/SHASUMS256.txt`,
     intervalMillis: 24 * 3600 * 1000,
     json: false,
     scraper: shasums => {
@@ -14,14 +21,57 @@ function getLatestVersion() {
       const taris = shasums.indexOf('node-v')
       const tarie = shasums.indexOf('\n', taris)
       const tarball = shasums.slice(taris, tarie)
-      const version = tarball.split('-')[1]
-      return version
+      return tarball.split('-')[1]
     },
   })
 }
 
-async function versionColorForRange(range) {
-  const latestVersion = await getLatestVersion()
+function ltsVersionsScraper(versions) {
+  const currentDate = moment().format(dateFormat)
+  return Object.keys(versions).filter(function(version) {
+    const data = versions[version]
+    return data.lts && data.lts < currentDate && data.end > currentDate
+  })
+}
+
+async function getCurrentVersion() {
+  return getVersion()
+}
+
+async function getLtsVersions() {
+  const versions = await promisify(regularUpdate)({
+    url:
+      'https://raw.githubusercontent.com/nodejs/Release/master/schedule.json',
+    intervalMillis: 24 * 3600 * 1000,
+    json: true,
+    scraper: ltsVersionsScraper,
+  })
+  return Promise.all(versions.map(getVersion))
+}
+
+async function versionColorForRangeLts(range) {
+  const ltsVersions = await getLtsVersions()
+  try {
+    const matchesAll = ltsVersions.reduce(function(satisfies, version) {
+      return satisfies && semver.satisfies(version, range)
+    }, true)
+    const matchesSome = ltsVersions.reduce(function(satisfies, version) {
+      return satisfies || semver.satisfies(version, range)
+    }, false)
+    if (matchesAll) {
+      return 'brightgreen'
+    } else if (matchesSome) {
+      return 'yellow'
+    } else {
+      return 'orange'
+    }
+  } catch (e) {
+    return 'lightgray'
+  }
+}
+
+async function versionColorForRangeCurrent(range) {
+  const latestVersion = await getCurrentVersion()
   try {
     if (semver.satisfies(latestVersion, range)) {
       return 'brightgreen'
@@ -36,6 +86,6 @@ async function versionColorForRange(range) {
 }
 
 module.exports = {
-  getLatestVersion,
-  versionColorForRange,
+  versionColorForRangeCurrent,
+  versionColorForRangeLts,
 }
diff --git a/services/npm/npm-base.js b/services/npm/npm-base.js
index bded61a391..3ca60ccc52 100644
--- a/services/npm/npm-base.js
+++ b/services/npm/npm-base.js
@@ -98,11 +98,15 @@ module.exports = class NpmBase extends BaseJsonService {
   async fetchPackageData({ registryUrl, scope, packageName, tag }) {
     registryUrl = registryUrl || this.constructor.defaultRegistryUrl
     let url
-    if (scope === undefined) {
+    if (scope === undefined && tag === undefined) {
       // e.g. https://registry.npmjs.org/express/latest
       // Use this endpoint as an optimization. It covers the vast majority of
       // these badges, and the response is smaller.
       url = `${registryUrl}/${packageName}/latest`
+    } else if (scope === undefined && tag !== undefined) {
+      // e.g. https://registry.npmjs.org/express
+      // because https://registry.npmjs.org/express/canary does not work
+      url = `${registryUrl}/${packageName}`
     } else {
       // e.g. https://registry.npmjs.org/@cedx%2Fgulp-david
       // because https://registry.npmjs.org/@cedx%2Fgulp-david/latest does not work
@@ -120,7 +124,7 @@ module.exports = class NpmBase extends BaseJsonService {
     })
 
     let packageData
-    if (scope === undefined) {
+    if (scope === undefined && tag === undefined) {
       packageData = json
     } else {
       const registryTag = tag || 'latest'


Test patch:
diff --git a/services/node/node-current.tester.js b/services/node/node-current.tester.js
new file mode 100644
index 0000000000..b9ae1d9e55
--- /dev/null
+++ b/services/node/node-current.tester.js
@@ -0,0 +1,157 @@
+'use strict'
+
+const { expect } = require('chai')
+const { Range } = require('semver')
+const t = (module.exports = require('../tester').createServiceTester())
+const { mockPackageData, mockCurrentSha } = require('./testUtils/test-utils')
+
+function expectSemverRange(message) {
+  expect(() => new Range(message)).not.to.throw()
+}
+
+t.create('gets the node version of passport')
+  .get('/passport.json')
+  .expectBadge({ label: 'node' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies current node version')
+  .get('/passport.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'passport',
+      engines: '>=0.4.0',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({ label: 'node', message: `>=0.4.0`, color: `brightgreen` })
+
+t.create('engines does not satisfy current node version')
+  .get('/passport.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'passport',
+      engines: '12',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({ label: 'node', message: `12`, color: `yellow` })
+
+t.create('gets the node version of @stdlib/stdlib')
+  .get('/@stdlib/stdlib.json')
+  .expectBadge({ label: 'node' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies current node version - scoped')
+  .get('/@stdlib/stdlib.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'stdlib',
+      engines: '>=0.4.0',
+      scope: '@stdlib',
+      tag: '',
+      registry: '',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({ label: 'node', message: `>=0.4.0`, color: `brightgreen` })
+
+t.create('engines does not satisfy current node version - scoped')
+  .get('/@stdlib/stdlib.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'stdlib',
+      engines: '12',
+      scope: '@stdlib',
+      tag: '',
+      registry: '',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({ label: 'node', message: `12`, color: `yellow` })
+
+t.create("gets the tagged release's node version version of ionic")
+  .get('/ionic/testing.json')
+  .expectBadge({ label: 'node@testing' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies current node version - tagged')
+  .get('/ionic/testing.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'ionic',
+      engines: '>=0.4.0',
+      tag: 'testing',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({
+    label: 'node@testing',
+    message: `>=0.4.0`,
+    color: `brightgreen`,
+  })
+
+t.create('engines does not satisfy current node version - tagged')
+  .get('/ionic/testing.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'ionic',
+      engines: '12',
+      tag: 'testing',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({ label: 'node@testing', message: `12`, color: `yellow` })
+
+t.create("gets the tagged release's node version of @cycle/core")
+  .get('/@cycle/core/canary.json')
+  .expectBadge({ label: 'node@canary' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies current node version - scoped and tagged')
+  .get('/@cycle/core/canary.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'core',
+      engines: '>=0.4.0',
+      scope: '@cycle',
+      tag: 'canary',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({
+    label: 'node@canary',
+    message: `>=0.4.0`,
+    color: `brightgreen`,
+  })
+
+t.create('engines does not satisfy current node version - scoped and tagged')
+  .get('/@cycle/core/canary.json')
+  .intercept(
+    mockPackageData({
+      packageName: 'core',
+      engines: '12',
+      scope: '@cycle',
+      tag: 'canary',
+    })
+  )
+  .intercept(mockCurrentSha(13))
+  .expectBadge({ label: 'node@canary', message: `12`, color: `yellow` })
+
+t.create('gets the node version of passport from a custom registry')
+  .get('/passport.json?registry_uri=https://registry.npmjs.com')
+  .expectBadge({ label: 'node' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('invalid package name')
+  .get('/frodo-is-not-a-package.json')
+  .expectBadge({ label: 'node', message: 'package not found' })
diff --git a/services/node/node-lts.tester.js b/services/node/node-lts.tester.js
new file mode 100644
index 0000000000..f2d47331fe
--- /dev/null
+++ b/services/node/node-lts.tester.js
@@ -0,0 +1,217 @@
+'use strict'
+
+const { expect } = require('chai')
+const { Range } = require('semver')
+const t = (module.exports = require('../tester').createServiceTester())
+const {
+  mockPackageData,
+  mockReleaseSchedule,
+  mockVersionsSha,
+} = require('./testUtils/test-utils')
+
+function expectSemverRange(message) {
+  expect(() => new Range(message)).not.to.throw()
+}
+
+t.create('gets the node version of passport')
+  .get('/passport.json')
+  .expectBadge({ label: 'node-lts' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies all lts node versions')
+  .get('/passport.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'passport',
+      engines: '10 - 12',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts', message: `10 - 12`, color: `brightgreen` })
+
+t.create('engines does not satisfy all lts node versions')
+  .get('/passport.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'passport',
+      engines: '8',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts', message: `8`, color: `orange` })
+
+t.create('engines satisfies some lts node versions')
+  .get('/passport.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'passport',
+      engines: '10',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts', message: `10`, color: `yellow` })
+
+t.create('gets the node version of @stdlib/stdlib')
+  .get('/@stdlib/stdlib.json')
+  .expectBadge({ label: 'node-lts' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies all lts node versions - scoped')
+  .get('/@stdlib/stdlib.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'stdlib',
+      engines: '10 - 12',
+      scope: '@stdlib',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts', message: `10 - 12`, color: `brightgreen` })
+
+t.create('engines does not satisfy all lts node versions - scoped')
+  .get('/@stdlib/stdlib.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'stdlib',
+      engines: '8',
+      scope: '@stdlib',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts', message: `8`, color: `orange` })
+
+t.create('engines satisfies some lts node versions - scoped')
+  .get('/@stdlib/stdlib.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'stdlib',
+      engines: '10',
+      scope: '@stdlib',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts', message: `10`, color: `yellow` })
+
+t.create("gets the tagged release's node version version of ionic")
+  .get('/ionic/testing.json')
+  .expectBadge({ label: 'node-lts@testing' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies all lts node versions - tagged')
+  .get('/ionic/testing.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'ionic',
+      engines: '10 - 12',
+      tag: 'testing',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({
+    label: 'node-lts@testing',
+    message: `10 - 12`,
+    color: `brightgreen`,
+  })
+
+t.create('engines does not satisfy all lts node versions - tagged')
+  .get('/ionic/testing.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'ionic',
+      engines: '8',
+      tag: 'testing',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts@testing', message: `8`, color: `orange` })
+
+t.create('engines satisfies some lts node versions - tagged')
+  .get('/ionic/testing.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'ionic',
+      engines: '10',
+      tag: 'testing',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts@testing', message: `10`, color: `yellow` })
+
+t.create("gets the tagged release's node version of @cycle/core")
+  .get('/@cycle/core/canary.json')
+  .expectBadge({ label: 'node-lts@canary' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('engines satisfies all lts node versions - scoped and tagged')
+  .get('/@cycle/core/canary.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'core',
+      engines: '10 - 12',
+      scope: '@cycle',
+      tag: 'canary',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({
+    label: 'node-lts@canary',
+    message: `10 - 12`,
+    color: `brightgreen`,
+  })
+
+t.create('engines does not satisfy all lts node versions - scoped and tagged')
+  .get('/@cycle/core/canary.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'core',
+      engines: '8',
+      scope: '@cycle',
+      tag: 'canary',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts@canary', message: `8`, color: `orange` })
+
+t.create('engines satisfies some lts node versions - scoped and tagged')
+  .get('/@cycle/core/canary.json')
+  .intercept(mockReleaseSchedule())
+  .intercept(
+    mockPackageData({
+      packageName: 'core',
+      engines: '10',
+      scope: '@cycle',
+      tag: 'canary',
+    })
+  )
+  .intercept(mockVersionsSha())
+  .expectBadge({ label: 'node-lts@canary', message: `10`, color: `yellow` })
+
+t.create('gets the node version of passport from a custom registry')
+  .get('/passport.json?registry_uri=https://registry.npmjs.com')
+  .expectBadge({ label: 'node-lts' })
+  .afterJSON(json => {
+    expectSemverRange(json.message)
+  })
+
+t.create('invalid package name')
+  .get('/frodo-is-not-a-package.json')
+  .expectBadge({ label: 'node-lts', message: 'package not found' })
diff --git a/services/node/node.tester.js b/services/node/node.tester.js
deleted file mode 100644
index 52a756c46d..0000000000
--- a/services/node/node.tester.js
+++ /dev/null
@@ -1,48 +0,0 @@
-'use strict'
-
-const { expect } = require('chai')
-const { Range } = require('semver')
-const t = (module.exports = require('../tester').createServiceTester())
-
-function expectSemverRange(message) {
-  expect(() => new Range(message)).not.to.throw()
-}
-
-t.create('gets the node version of passport')
-  .get('/passport.json')
-  .expectBadge({ label: 'node' })
-  .afterJSON(json => {
-    expectSemverRange(json.message)
-  })
-
-t.create('gets the node version of @stdlib/stdlib')
-  .get('/@stdlib/stdlib.json')
-  .expectBadge({ label: 'node' })
-  .afterJSON(json => {
-    expectSemverRange(json.message)
-  })
-
-t.create("gets the tagged release's node version version of ionic")
-  .get('/ionic/next.json')
-  .expectBadge({ label: 'node@next' })
-  .afterJSON(json => {
-    expectSemverRange(json.message)
-  })
-
-t.create('gets the node version of passport from a custom registry')
-  .get('/passport.json?registry_uri=https://registry.npmjs.com')
-  .expectBadge({ label: 'node' })
-  .afterJSON(json => {
-    expectSemverRange(json.message)
-  })
-
-t.create("gets the tagged release's node version of @cycle/core")
-  .get('/@cycle/core/canary.json')
-  .expectBadge({ label: 'node@canary' })
-  .afterJSON(json => {
-    expectSemverRange(json.message)
-  })
-
-t.create('invalid package name')
-  .get('/frodo-is-not-a-package.json')
-  .expectBadge({ label: 'node', message: 'package not found' })
diff --git a/services/node/testUtils/packageJsonTemplate.json b/services/node/testUtils/packageJsonTemplate.json
new file mode 100644
index 0000000000..e69cdb52be
--- /dev/null
+++ b/services/node/testUtils/packageJsonTemplate.json
@@ -0,0 +1,11 @@
+{
+  "engines": {
+    "node": ">= 0.4.0"
+  },
+  "maintainers": [
+    {
+      "name": "jaredhanson",
+      "email": "jaredhanson@gmail.com"
+    }
+  ]
+}
diff --git a/services/node/testUtils/packageJsonVersionsTemplate.json b/services/node/testUtils/packageJsonVersionsTemplate.json
new file mode 100644
index 0000000000..818c864975
--- /dev/null
+++ b/services/node/testUtils/packageJsonVersionsTemplate.json
@@ -0,0 +1,29 @@
+{
+  "dist-tags": {
+    "latest": "0.0.91"
+  },
+  "versions": {
+    "0.0.90": {
+      "engines": {
+        "node": ">= 0.4.0"
+      },
+      "maintainers": [
+        {
+          "name": "jaredhanson",
+          "email": "jaredhanson@gmail.com"
+        }
+      ]
+    },
+    "0.0.91": {
+      "engines": {
+        "node": ">= 0.4.0"
+      },
+      "maintainers": [
+        {
+          "name": "jaredhanson",
+          "email": "jaredhanson@gmail.com"
+        }
+      ]
+    }
+  }
+}
diff --git a/services/node/testUtils/test-utils.js b/services/node/testUtils/test-utils.js
new file mode 100644
index 0000000000..bc0512539d
--- /dev/null
+++ b/services/node/testUtils/test-utils.js
@@ -0,0 +1,169 @@
+'use strict'
+
+const fs = require('fs')
+const path = require('path')
+const moment = require('moment')
+
+const dateFormat = 'YYYY-MM-DD'
+
+const templates = {
+  packageJsonVersionsTemplate: fs.readFileSync(
+    path.join(__dirname, `packageJsonVersionsTemplate.json`),
+    'utf-8'
+  ),
+  packageJsonTemplate: fs.readFileSync(
+    path.join(__dirname, `packageJsonTemplate.json`),
+    'utf-8'
+  ),
+}
+
+const getTemplate = template => JSON.parse(templates[template])
+
+const mockPackageData = ({ packageName, engines, scope, tag }) => nock => {
+  let packageJson
+  let urlPath
+  if (scope || tag) {
+    if (scope) {
+      urlPath = `/${scope}%2F${packageName}`
+    } else {
+      urlPath = `/${packageName}`
+    }
+    packageJson = getTemplate('packageJsonVersionsTemplate')
+    packageJson['dist-tags'][tag || 'latest'] = '0.0.91'
+    packageJson.versions['0.0.91'].engines.node = engines
+  } else {
+    urlPath = `/${packageName}/latest`
+    packageJson = getTemplate('packageJsonTemplate')
+    packageJson.engines.node = engines
+  }
+  return nock('https://registry.npmjs.org/')
+    .get(urlPath)
+    .reply(200, packageJson)
+}
+
+const mockCurrentSha = latestVersion => nock => {
+  const latestSha = `node-v${latestVersion}.12.0-aix-ppc64.tar.gz`
+  return nock('https://nodejs.org/dist/')
+    .get(`/latest/SHASUMS256.txt`)
+    .reply(200, latestSha)
+}
+
+const mockVersionsSha = () => nock => {
+  let scope = nock('https://nodejs.org/dist/')
+  for (const version of [10, 12]) {
+    const latestSha = `node-v${version}.12.0-aix-ppc64.tar.gz`
+    scope = scope
+      .get(`/latest-v${version}.x/SHASUMS256.txt`)
+      .reply(200, latestSha)
+  }
+  return scope
+}
+
+const mockReleaseSchedule = () => nock => {
+  const currentDate = moment()
+  const schedule = {
+    'v0.10': {
+      start: '2013-03-11',
+      end: '2016-10-31',
+    },
+    'v0.12': {
+      start: '2015-02-06',
+      end: '2016-12-31',
+    },
+    v4: {
+      start: '2015-09-08',
+      lts: '2015-10-12',
+      maintenance: '2017-04-01',
+      end: '2018-04-30',
+      codename: 'Argon',
+    },
+    v5: {
+      start: '2015-10-29',
+      maintenance: '2016-04-30',
+      end: '2016-06-30',
+    },
+    v6: {
+      start: '2016-04-26',
+      lts: '2016-10-18',
+      maintenance: '2018-04-30',
+      end: '2019-04-30',
+      codename: 'Boron',
+    },
+    v7: {
+      start: '2016-10-25',
+      maintenance: '2017-04-30',
+      end: '2017-06-30',
+    },
+    v8: {
+      start: '2017-05-30',
+      lts: '2017-10-31',
+      maintenance: '2019-01-01',
+      end: '2019-12-31',
+      codename: 'Carbon',
+    },
+    v9: {
+      start: '2017-10-01',
+      maintenance: '2018-04-01',
+      end: '2018-06-30',
+    },
+    v10: {
+      start: '2018-04-24',
+      lts: currentDate
+        .clone()
+        .subtract(6, 'month')
+        .format(dateFormat),
+      maintenance: '2020-04-30',
+      end: currentDate
+        .clone()
+        .add(1, 'month')
+        .format(dateFormat),
+      codename: 'Dubnium',
+    },
+    v11: {
+      start: '2018-10-23',
+      maintenance: '2019-04-22',
+      end: '2019-06-01',
+    },
+    v12: {
+      start: '2019-04-23',
+      lts: currentDate
+        .clone()
+        .subtract(1, 'month')
+        .format(dateFormat),
+      maintenance: '2020-10-20',
+      end: currentDate
+        .clone()
+        .add(6, 'month')
+        .format(dateFormat),
+      codename: 'Erbium',
+    },
+    v13: {
+      start: '2019-10-22',
+      maintenance: '2020-04-01',
+      end: '2020-06-01',
+    },
+    v14: {
+      start: '2020-04-21',
+      lts: currentDate
+        .clone()
+        .add(4, 'month')
+        .format(dateFormat),
+      maintenance: '2021-10-19',
+      end: currentDate
+        .clone()
+        .add(12, 'month')
+        .format(dateFormat),
+      codename: '',
+    },
+  }
+  return nock('https://raw.githubusercontent.com/')
+    .get(`/nodejs/Release/master/schedule.json`)
+    .reply(200, schedule)
+}
+
+module.exports = {
+  mockPackageData,
+  mockCurrentSha,
+  mockVersionsSha,
+  mockReleaseSchedule,
+}