diff --git a/.travis.yml b/.travis.yml index 757e5cbd..0a6a01d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,6 @@ addons: - google-chrome-stable language: node_js node_js: - - "4" - "6" cache: directories: diff --git a/appveyor.yml b/appveyor.yml index 582b44a6..995d7398 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,5 @@ environment: matrix: - - nodejs_version: "4" - nodejs_version: "6" # Install scripts. (runs after repo cloning) diff --git a/lib/dynamic.js b/lib/dynamic.js index 73052e53..0638bc38 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -54,7 +54,8 @@ export default function dynamicComponent (p, o) { } loadComponent () { - promise.then((AsyncComponent) => { + promise.then((m) => { + const AsyncComponent = m.default || m // Set a readable displayName for the wrapper component const asyncCompName = getDisplayName(AsyncComponent) if (asyncCompName) { @@ -65,7 +66,7 @@ export default function dynamicComponent (p, o) { this.setState({ AsyncComponent }) } else { if (this.isServer) { - registerChunk(AsyncComponent.__webpackChunkName) + registerChunk(m.__webpackChunkName) } this.state.AsyncComponent = AsyncComponent } @@ -100,9 +101,10 @@ export default function dynamicComponent (p, o) { const loadModule = (name) => { const promise = modulePromiseMap[name] - promise.then((Component) => { + promise.then((m) => { + const Component = m.default || m if (this.isServer) { - registerChunk(Component.__webpackChunkName) + registerChunk(m.__webpackChunkName) } moduleMap[name] = Component remainingPromises-- diff --git a/lib/link.js b/lib/link.js index ed123864..c7da8fa6 100644 --- a/lib/link.js +++ b/lib/link.js @@ -15,9 +15,12 @@ export default class Link extends Component { } static propTypes = exact({ - href: PropTypes.string, - as: PropTypes.string, + href: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + as: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), prefetch: PropTypes.bool, + replace: PropTypes.bool, + shallow: PropTypes.bool, + passHref: PropTypes.bool, children: PropTypes.oneOfType([ PropTypes.element, (props, propName) => { @@ -29,9 +32,7 @@ export default class Link extends Component { return null } - ]).isRequired, - shallow: PropTypes.bool, - passHref: PropTypes.bool + ]).isRequired }) componentWillReceiveProps (nextProps) { diff --git a/package.json b/package.json index 1e05937c..32203fe1 100644 --- a/package.json +++ b/package.json @@ -118,12 +118,14 @@ "coveralls": "2.13.1", "cross-env": "5.0.1", "express": "4.15.2", + "fkill": "^5.0.0", "husky": "0.14.3", "jest-cli": "20.0.4", "lint-staged": "^4.0.0", "node-fetch": "1.7.1", "node-notifier": "5.1.2", "nyc": "11.0.3", + "portfinder": "^1.0.13", "react": "15.5.4", "react-dom": "15.5.4", "standard": "9.0.2", diff --git a/server/build/babel/plugins/handle-import.js b/server/build/babel/plugins/handle-import.js index d1f661d7..930effa7 100644 --- a/server/build/babel/plugins/handle-import.js +++ b/server/build/babel/plugins/handle-import.js @@ -13,7 +13,6 @@ const buildImport = (args) => (template(` eval('require.ensure = function (deps, callback) { callback(require) }') require.ensure([], (require) => { let m = require(SOURCE) - m = m.default || m m.__webpackChunkName = '${args.name}.js' resolve(m); }, 'chunks/${args.name}.js'); @@ -23,13 +22,12 @@ const buildImport = (args) => (template(` const weakId = require.resolveWeak(SOURCE) try { const weakModule = __webpack_require__(weakId) - return resolve(weakModule.default || weakModule) + return resolve(weakModule) } catch (err) {} require.ensure([], (require) => { try { let m = require(SOURCE) - m = m.default || m resolve(m) } catch(error) { reject(error) diff --git a/test/integration/basic/pages/hmr/counter.js b/test/integration/basic/pages/hmr/counter.js new file mode 100644 index 00000000..26b3834b --- /dev/null +++ b/test/integration/basic/pages/hmr/counter.js @@ -0,0 +1,19 @@ +import React from 'react' + +export default class Counter extends React.Component { + state = { count: 0 } + + incr () { + const { count } = this.state + this.setState({ count: count + 1 }) + } + + render () { + return ( +
+

COUNT: {this.state.count}

+ +
+ ) + } +} diff --git a/test/integration/basic/test/hmr.js b/test/integration/basic/test/hmr.js index 79935c2c..c416b73e 100644 --- a/test/integration/basic/test/hmr.js +++ b/test/integration/basic/test/hmr.js @@ -4,6 +4,16 @@ import { readFileSync, writeFileSync, renameSync } from 'fs' import { join } from 'path' import { waitFor } from 'next-test-utils' +async function check (contentFn, regex) { + while (true) { + try { + const newContent = await contentFn() + if (regex.test(newContent)) break + await waitFor(1000) + } catch (ex) {} + } +} + export default (context, render) => { describe('Hot Module Reloading', () => { describe('syntax error', () => { @@ -21,18 +31,45 @@ export default (context, render) => { // change the content writeFileSync(aboutPagePath, erroredContent, 'utf8') - const errorMessage = await browser - .waitForElementByCss('pre') - .elementByCss('pre').text() - expect(errorMessage.includes('Unterminated JSX contents')).toBeTruthy() + await check( + () => browser.elementByCss('body').text(), + /Unterminated JSX contents/ + ) // add the original content writeFileSync(aboutPagePath, originalContent, 'utf8') - const newContent = await browser - .waitForElementByCss('.hmr-about-page') - .elementByCss('p').text() - expect(newContent).toBe('This is the about page.') + await check( + () => browser.elementByCss('body').text(), + /This is the about page/ + ) + + browser.close() + }) + + it('should show the error on all pages', async () => { + const aboutPagePath = join(__dirname, '../', 'pages', 'hmr', 'about.js') + + const originalContent = readFileSync(aboutPagePath, 'utf8') + const erroredContent = originalContent.replace('', 'div') + + // change the content + writeFileSync(aboutPagePath, erroredContent, 'utf8') + + const browser = await webdriver(context.appPort, '/hmr/contact') + + await check( + () => browser.elementByCss('body').text(), + /Unterminated JSX contents/ + ) + + // add the original content + writeFileSync(aboutPagePath, originalContent, 'utf8') + + await check( + () => browser.elementByCss('body').text(), + /This is the contact page/ + ) browser.close() }) @@ -52,27 +89,81 @@ export default (context, render) => { renameSync(contactPagePath, newContactPagePath) // wait until the 404 page comes - while (true) { - try { - const pageContent = await browser.elementByCss('body').text() - if (/This page could not be found/.test(pageContent)) break - } catch (ex) {} - - await waitFor(1000) - } + await check( + () => browser.elementByCss('body').text(), + /This page could not be found/ + ) // Rename the file back to the original filename renameSync(newContactPagePath, contactPagePath) // wait until the page comes back - while (true) { - try { - const pageContent = await browser.elementByCss('body').text() - if (/This is the contact page/.test(pageContent)) break - } catch (ex) {} + await check( + () => browser.elementByCss('body').text(), + /This is the contact page/ + ) - await waitFor(1000) - } + browser.close() + }) + }) + + describe('editing a page', () => { + it('should detect the changes and display it', async () => { + const browser = await webdriver(context.appPort, '/hmr/about') + const text = await browser + .elementByCss('p').text() + expect(text).toBe('This is the about page.') + + const aboutPagePath = join(__dirname, '../', 'pages', 'hmr', 'about.js') + + const originalContent = readFileSync(aboutPagePath, 'utf8') + const editedContent = originalContent.replace('This is the about page', 'COOL page') + + // change the content + writeFileSync(aboutPagePath, editedContent, 'utf8') + + await check( + () => browser.elementByCss('body').text(), + /COOL page/ + ) + + // add the original content + writeFileSync(aboutPagePath, originalContent, 'utf8') + + await check( + () => browser.elementByCss('body').text(), + /This is the about page/ + ) + + browser.close() + }) + + it('should not reload unrelated pages', async () => { + const browser = await webdriver(context.appPort, '/hmr/counter') + const text = await browser + .elementByCss('button').click() + .elementByCss('button').click() + .elementByCss('p').text() + expect(text).toBe('COUNT: 2') + + const aboutPagePath = join(__dirname, '../', 'pages', 'hmr', 'about.js') + + const originalContent = readFileSync(aboutPagePath, 'utf8') + const editedContent = originalContent.replace('This is the about page', 'COOL page') + + // Change the about.js page + writeFileSync(aboutPagePath, editedContent, 'utf8') + + // wait for 5 seconds + await waitFor(5000) + + // Check whether the this page has reloaded or not. + const newText = await browser + .elementByCss('p').text() + expect(newText).toBe('COUNT: 2') + + // restore the about page content. + writeFileSync(aboutPagePath, originalContent, 'utf8') browser.close() }) diff --git a/test/integration/basic/test/index.test.js b/test/integration/basic/test/index.test.js index ebd156b1..5ff68a11 100644 --- a/test/integration/basic/test/index.test.js +++ b/test/integration/basic/test/index.test.js @@ -2,34 +2,25 @@ import { join } from 'path' import { - nextServer, - renderViaAPI, renderViaHTTP, - startApp, - stopApp + findPort, + launchApp, + killApp } from 'next-test-utils' // test suits -import xPoweredBy from './xpowered-by' import rendering from './rendering' -import misc from './misc' import clientNavigation from './client-navigation' import hmr from './hmr' import dynamic from './dynamic' const context = {} -context.app = nextServer({ - dir: join(__dirname, '../'), - dev: true, - quiet: true -}) - jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 describe('Basic Features', () => { beforeAll(async () => { - context.server = await startApp(context.app) - context.appPort = context.server.address().port + context.appPort = await findPort() + context.server = await launchApp(join(__dirname, '../'), context.appPort, true) // pre-build all pages at the start await Promise.all([ @@ -55,15 +46,16 @@ describe('Basic Features', () => { renderViaHTTP(context.appPort, '/nav/as-path'), renderViaHTTP(context.appPort, '/nav/as-path-using-router'), - renderViaHTTP(context.appPort, '/nested-cdm/index') + renderViaHTTP(context.appPort, '/nested-cdm/index'), + + renderViaHTTP(context.appPort, '/hmr/about'), + renderViaHTTP(context.appPort, '/hmr/contact'), + renderViaHTTP(context.appPort, '/hmr/counter') ]) }) - afterAll(() => stopApp(context.server)) + afterAll(() => killApp(context.server)) - rendering(context, 'Rendering via API', (p, q) => renderViaAPI(context.app, p, q)) rendering(context, 'Rendering via HTTP', (p, q) => renderViaHTTP(context.appPort, p, q)) - xPoweredBy(context) - misc(context) clientNavigation(context, (p, q) => renderViaHTTP(context.appPort, p, q)) dynamic(context, (p, q) => renderViaHTTP(context.appPort, p, q)) hmr(context, (p, q) => renderViaHTTP(context.appPort, p, q)) diff --git a/test/integration/basic/test/misc.js b/test/integration/basic/test/misc.js deleted file mode 100644 index 29926790..00000000 --- a/test/integration/basic/test/misc.js +++ /dev/null @@ -1,16 +0,0 @@ -/* global describe, test, expect */ - -export default function (context) { - describe('Misc', () => { - test('finishes response', async () => { - const res = { - finished: false, - end () { - this.finished = true - } - } - const html = await context.app.renderToHTML({}, res, '/finish-response', {}) - expect(html).toBeFalsy() - }) - }) -} diff --git a/test/integration/basic/test/xpowered-by.js b/test/integration/basic/test/xpowered-by.js deleted file mode 100644 index 253fbc1d..00000000 --- a/test/integration/basic/test/xpowered-by.js +++ /dev/null @@ -1,37 +0,0 @@ -/* global describe, test, expect */ -import { pkg } from 'next-test-utils' - -export default function ({ app }) { - describe('X-Powered-By header', () => { - test('set it by default', async () => { - const req = { url: '/stateless', headers: {} } - const headers = {} - const res = { - setHeader (key, value) { - headers[key] = value - }, - end () {} - } - - await app.render(req, res, req.url) - expect(headers['X-Powered-By']).toEqual(`Next.js ${pkg.version}`) - }) - - test('do not set it when poweredByHeader==false', async () => { - const req = { url: '/stateless', headers: {} } - const originalConfigValue = app.config.poweredByHeader - app.config.poweredByHeader = false - const res = { - setHeader (key, value) { - if (key === 'X-Powered-By') { - throw new Error('Should not set the X-Powered-By header') - } - }, - end () {} - } - - await app.render(req, res, req.url) - app.config.poweredByHeader = originalConfigValue - }) - }) -} diff --git a/test/integration/basic/pages/finish-response.js b/test/integration/production/pages/finish-response.js similarity index 100% rename from test/integration/basic/pages/finish-response.js rename to test/integration/production/pages/finish-response.js diff --git a/test/integration/production/test/index.test.js b/test/integration/production/test/index.test.js index b14e3fbf..17ec832a 100644 --- a/test/integration/production/test/index.test.js +++ b/test/integration/production/test/index.test.js @@ -2,6 +2,7 @@ import { join } from 'path' import { + pkg, nextServer, nextBuild, startApp, @@ -89,5 +90,51 @@ describe('Production Usage', () => { }) }) + describe('Misc', () => { + it('should handle already finished responses', async () => { + const res = { + finished: false, + end () { + this.finished = true + } + } + const html = await app.renderToHTML({}, res, '/finish-response', {}) + expect(html).toBeFalsy() + }) + }) + + describe('X-Powered-By header', () => { + it('should set it by default', async () => { + const req = { url: '/stateless', headers: {} } + const headers = {} + const res = { + setHeader (key, value) { + headers[key] = value + }, + end () {} + } + + await app.render(req, res, req.url) + expect(headers['X-Powered-By']).toEqual(`Next.js ${pkg.version}`) + }) + + it('should not set it when poweredByHeader==false', async () => { + const req = { url: '/stateless', headers: {} } + const originalConfigValue = app.config.poweredByHeader + app.config.poweredByHeader = false + const res = { + setHeader (key, value) { + if (key === 'X-Powered-By') { + throw new Error('Should not set the X-Powered-By header') + } + }, + end () {} + } + + await app.render(req, res, req.url) + app.config.poweredByHeader = originalConfigValue + }) + }) + dynamicImportTests(context, (p, q) => renderViaHTTP(context.appPort, p, q)) }) diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index 7e81275c..0b4aa26e 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -2,6 +2,10 @@ import fetch from 'node-fetch' import qs from 'querystring' import http from 'http' import express from 'express' +import path from 'path' +import portfinder from 'portfinder' +import { spawn } from 'child_process' +import fkill from 'fkill' import server from '../../dist/server/next' import build from '../../dist/server/build' @@ -23,6 +27,48 @@ export function renderViaHTTP (appPort, pathname, query = {}) { return fetch(url).then((res) => res.text()) } +export function findPort () { + portfinder.basePort = 20000 + Math.ceil(Math.random() * 10000) + return portfinder.getPortPromise() +} + +// Launch the app in dev mode. +export function launchApp (dir, port) { + const cwd = path.resolve(__dirname, '../../') + return new Promise((resolve, reject) => { + const instance = spawn('node', ['dist/bin/next', dir, '-p', port], { cwd }) + + function handleStdout (data) { + const message = data.toString() + if (/> Ready on/.test(message)) { + resolve(instance) + } + process.stdout.write(message) + } + + function handleStderr (data) { + process.stderr.write(data.toString()) + } + + instance.stdout.on('data', handleStdout) + instance.stderr.on('data', handleStderr) + + instance.on('close', () => { + instance.stdout.removeListener('data', handleStdout) + instance.stderr.removeListener('data', handleStderr) + }) + + instance.on('error', (err) => { + reject(err) + }) + }) +} + +// Kill a launched app +export async function killApp (instance) { + await fkill(instance.pid) +} + export async function startApp (app) { await app.prepare() const handler = app.getRequestHandler() diff --git a/yarn.lock b/yarn.lock index a734ed53..fb8f2aa1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -74,6 +74,13 @@ acorn@^5.0.0, acorn@^5.0.1: version "5.1.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.0.tgz#e468bf609b0672700e02f878ae2f1b360fe24b4f" +aggregate-error@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-1.0.0.tgz#888344dad0220a72e3af50906117f48771925fac" + dependencies: + clean-stack "^1.0.0" + indent-string "^3.0.0" + ajv-keywords@^1.0.0: version "1.5.1" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c" @@ -290,7 +297,7 @@ async@2.0.1: dependencies: lodash "^4.8.0" -async@^1.4.0: +async@^1.4.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -1298,6 +1305,10 @@ circular-json@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d" +clean-stack@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-1.3.0.tgz#9e821501ae979986c46b1d66d2d432db2fd4ae31" + cli-cursor@^1.0.1, cli-cursor@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" @@ -1514,6 +1525,13 @@ cross-env@5.0.1: cross-spawn "^5.1.0" is-windows "^1.0.0" +cross-spawn-async@^2.1.1: + version "2.2.5" + resolved "https://registry.yarnpkg.com/cross-spawn-async/-/cross-spawn-async-2.2.5.tgz#845ff0c0834a3ded9d160daca6d390906bb288cc" + dependencies: + lru-cache "^4.0.0" + which "^1.2.8" + cross-spawn@5.1.0, cross-spawn@^5.0.1, cross-spawn@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -2096,6 +2114,14 @@ exec-sh@^0.2.0: dependencies: merge "^1.1.3" +execa@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.1.1.tgz#b09c2a9309bc0ef0501479472db3180f8d4c3edd" + dependencies: + cross-spawn-async "^2.1.1" + object-assign "^4.0.1" + strip-eof "^1.0.0" + execa@^0.5.0: version "0.5.1" resolved "https://registry.yarnpkg.com/execa/-/execa-0.5.1.tgz#de3fb85cb8d6e91c85bcbceb164581785cb57b36" @@ -2108,6 +2134,18 @@ execa@^0.5.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.6.3.tgz#57b69a594f081759c69e5370f0d17b9cb11658fe" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" @@ -2321,6 +2359,15 @@ find-up@^2.0.0, find-up@^2.1.0: dependencies: locate-path "^2.0.0" +fkill@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/fkill/-/fkill-5.0.0.tgz#642f746aa631c965afc76c0d56dcc492a1ee504f" + dependencies: + aggregate-error "^1.0.0" + arrify "^1.0.0" + execa "^0.6.3" + taskkill "^2.0.0" + flat-cache@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.2.2.tgz#fa86714e72c21db88601761ecf2f555d1abc6b96" @@ -3599,7 +3646,7 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: dependencies: js-tokens "^3.0.0" -lru-cache@^4.0.1: +lru-cache@^4.0.0, lru-cache@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55" dependencies: @@ -3764,7 +3811,7 @@ mkdirp@0.5.0: dependencies: minimist "0.0.8" -"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" dependencies: @@ -4291,6 +4338,14 @@ pluralize@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" +portfinder@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.13.tgz#bb32ecd87c27104ae6ee44b5a3ccbf0ebb1aede9" + dependencies: + async "^1.5.2" + debug "^2.2.0" + mkdirp "0.5.x" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -4371,11 +4426,11 @@ public-encrypt@^4.0.0: parse-asn1 "^5.0.0" randombytes "^2.0.1" -punycode@1.3.2: +punycode@1.3.2, punycode@^1.2.4: version "1.3.2" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" -punycode@^1.2.4, punycode@^1.4.1: +punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" @@ -5181,6 +5236,13 @@ tar@^2.2.1: fstream "^1.0.2" inherits "2" +taskkill@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/taskkill/-/taskkill-2.0.0.tgz#a354305702a964357033027aa949eaed5331b784" + dependencies: + arrify "^1.0.0" + execa "^0.1.1" + taskr@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/taskr/-/taskr-1.0.6.tgz#6ba5b671f51703f780fb6335d3dc792cfcf9c192" @@ -5539,7 +5601,7 @@ which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" -which@^1.2.10, which@^1.2.12, which@^1.2.4, which@^1.2.9: +which@^1.2.10, which@^1.2.12, which@^1.2.4, which@^1.2.8, which@^1.2.9: version "1.2.14" resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5" dependencies: