From a620996912f5793e759addb9dfb9cbacf1314adf Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 4 Dec 2023 14:00:25 +0700 Subject: [PATCH] chore: mock http calls in tests --- .vscode/settings.json | 4 + jest.config.ts | 3 + package.json | 1 + setupJest.ts | 3 + src/lightning-address.test.ts | 451 ++++++++++++++++++++++++---------- src/lightning-address.ts | 2 +- yarn.lock | 45 ++++ 7 files changed, 380 insertions(+), 129 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 setupJest.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1b6457c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/jest.config.ts b/jest.config.ts index b413e10..8ffd480 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -2,4 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + setupFiles: [ + "./setupJest.ts" + ] }; \ No newline at end of file diff --git a/package.json b/package.json index 3b9d8f7..6ad6853 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "eslint-config-prettier": "^9.0.0", "husky": "^8.0.3", "jest": "^29.5.0", + "jest-fetch-mock": "^3.0.3", "lint-staged": "^14.0.0", "microbundle": "^0.15.1", "nostr-tools": "^1.17.0", diff --git a/setupJest.ts b/setupJest.ts new file mode 100644 index 0000000..79e0bb2 --- /dev/null +++ b/setupJest.ts @@ -0,0 +1,3 @@ +import jestFetchMock from 'jest-fetch-mock'; + +jestFetchMock.enableMocks(); \ No newline at end of file diff --git a/src/lightning-address.test.ts b/src/lightning-address.test.ts index 52f75ba..146b23c 100644 --- a/src/lightning-address.test.ts +++ b/src/lightning-address.test.ts @@ -2,6 +2,7 @@ import { WebLNProvider } from "@webbtc/webln-types"; import LightningAddress, { DEFAULT_PROXY } from "./lightning-address"; import { Event, NostrProvider } from "./types"; import { finishEvent, generatePrivateKey, getPublicKey } from "nostr-tools"; +import fetchMock from "jest-fetch-mock"; const dummyWebLN: WebLNProvider = { enable: () => Promise.resolve(), @@ -50,7 +51,244 @@ const nostrProvider: NostrProvider = { }, }; -const SPEC_TIMEOUT = 10000; +// TODO: refactor tests to set their own mocks rather than using conditionals and loops +fetchMock.mockIf(/.*/, (req) => { + if ( + req.url.startsWith( + "https://api.getalby.com/lnurl/generate-invoice?ln=hello%40getalby.com&amount=1000", + ) + ) { + return Promise.resolve( + JSON.stringify({ + invoice: { + pr: "lnbc10u1pjk6mhgpp5zj5mn43uz96y94vevla98990gtkm0fa5jysvfl2wx6q2lllkyd9shp56x0knvgt833500x88k786uqc7nqpa563vgzt5e9c7srg4h8vqf2qcqzzsxqyz5vqsp5h8crvhl0etrgc3jfwwqypmckvp5szw8a8mhnzw0xk6ru5anyak6q9qyyssq0dcsf56fhdwjd4adwlljetpkhdanckgxgwx49fu49h9hxjj0haq9tg867x6acudjraxvwuuq033004jy8fwx98hd69c9z3az2qhv3wsq6wwe8p", + routes: [], + status: "OK", + successAction: { + message: "Thanks, sats received!", + tag: "message", + }, + verify: + "https://getalby.com/lnurlp/hello/verify/bAJyn6sqHgWhStciroY4yy8t", + }, + }), + ); + } else if ( + req.url.startsWith("https://getalby.com/lnurlp/hello/callback?amount=1000") + ) { + return Promise.resolve( + JSON.stringify({ + pr: "lnbc10n1pjk6m6spp57n44qespk3z2hjfc6wxmyqjy8aprdz5k6jflu9q5gw6wqm3y0ssqhp50kncf9zk35xg4lxewt4974ry6mudygsztsz8qn3ar8pn3mtpe50scqzzsxqyz5vqsp5creygvh0cmhjnqsvq7c9k5w9fpe4yy0sw025msv7ut09krp9g5ds9qyyssqlpl0539nx0rhmtltzzaeznnt967msvnuqe7k9mhld8xvs032ysy5697hsene3xat2ujxahfe63c6ces82jd2hcv2dmuynkf2p7cttuqpm6qdfm", + routes: [], + status: "OK", + successAction: { + message: "Thanks, sats received!", + tag: "message", + }, + verify: + "https://getalby.com/lnurlp/hello/verify/7wMPUUqFfaUaM6sG6GwFFSTq", + }), + ); + } else if (req.url.endsWith("ln=hello%40getalby.com")) { + return Promise.resolve( + JSON.stringify({ + lnurlp: { + allowsNostr: true, + callback: "https://getalby.com/lnurlp/hello/callback", + commentAllowed: 255, + maxSendable: 11000000000, + metadata: + '[["text/identifier","hello@getalby.com"],["text/plain","Sats for Alby"]]', + minSendable: 1000, + nostrPubkey: + "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432", + payerData: { + email: { + mandatory: false, + }, + name: { + mandatory: false, + }, + pubkey: { + mandatory: false, + }, + }, + status: "OK", + tag: "payRequest", + }, + keysend: { + customData: [ + { + customKey: "696969", + customValue: "017rsl75kNnSke4mMHYE", + }, + ], + pubkey: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + status: "OK", + tag: "keysend", + }, + nostr: { + names: { + hello: + "4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e", + }, + }, + }), + ); + } else if (req.url.indexOf("ln=wintertree4%40getalby.com") > -1) { + return Promise.resolve( + JSON.stringify({ + lnurlp: { + allowsNostr: true, + callback: "https://getalby.com/lnurlp/hello/callback", + commentAllowed: 255, + maxSendable: 11000000000, + metadata: + '[["text/identifier","hello@getalby.com"],["text/plain","Sats for Alby"]]', + minSendable: 1000, + nostrPubkey: + "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432", + payerData: { + email: { + mandatory: false, + }, + name: { + mandatory: false, + }, + pubkey: { + mandatory: false, + }, + }, + status: "OK", + tag: "payRequest", + }, + keysend: { + customData: [ + { + customKey: "696969", + customValue: "017rsl75kNnSke4mMHYE", + }, + ], + pubkey: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + status: "OK", + tag: "keysend", + }, + nostr: null, + }), + ); + } else if (req.url.indexOf("ln=hrf%40btcpay.hrf.org") > -1) { + return Promise.resolve( + JSON.stringify({ + lnurlp: { + callback: + "https://btcpay.hrf.org/BTC/UILNURL/pay/i/CTx9XVtkW5QuYvMXhDPsfP", + commentAllowed: 0, + maxSendable: 612000000000, + metadata: + '[["text/plain","Paid to Donate to HRF v2 (Order ID: )"],["text/identifier","hrf@btcpay.hrf.org"]]', + minSendable: 1000, + tag: "payRequest", + }, + keysend: null, + nostr: null, + }), + ); + } else if (req.url.indexOf("ln=hellononexistentaddress%40getalby.com") > -1) { + return Promise.resolve( + JSON.stringify({ + lnurlp: null, + keysend: null, + nostr: null, + }), + ); + } else if ( + req.url.endsWith("lnurlp/hello") || + req.url.endsWith("lnurlp/wintertree4") + ) { + return Promise.resolve( + JSON.stringify({ + allowsNostr: true, + callback: "https://getalby.com/lnurlp/hello/callback", + commentAllowed: 255, + maxSendable: 11000000000, + metadata: + '[["text/identifier","hello@getalby.com"],["text/plain","Sats for Alby"]]', + minSendable: 1000, + nostrPubkey: + "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432", + payerData: { + email: { + mandatory: false, + }, + name: { + mandatory: false, + }, + pubkey: { + mandatory: false, + }, + }, + status: "OK", + tag: "payRequest", + }), + ); + } else if (req.url === "https://btcpay.hrf.org/.well-known/lnurlp/hrf") { + return Promise.resolve( + JSON.stringify({ + callback: + "https://btcpay.hrf.org/BTC/UILNURL/pay/i/8NTBF2qoCHBNSF49hqot4j", + metadata: + '[["text/plain","Paid to Donate to HRF v2 (Order ID: )"],["text/identifier","hrf@btcpay.hrf.org"]]', + tag: "payRequest", + minSendable: 1000, + maxSendable: 612000000000, + commentAllowed: 0, + }), + ); + } else if (req.url.endsWith("/hellononexistentaddress")) { + return Promise.resolve({ + status: 404, + }); + } else if ( + req.url.endsWith("keysend/hello") || + req.url.endsWith("keysend/hrf") || + req.url.endsWith("keysend/wintertree4") + ) { + return Promise.resolve( + JSON.stringify({ + customData: [ + { + customKey: "696969", + customValue: "017rsl75kNnSke4mMHYE", + }, + ], + pubkey: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + status: "OK", + tag: "keysend", + }), + ); + } else if (req.url.endsWith("nostr.json?name=hello")) { + return Promise.resolve( + JSON.stringify({ + names: { + hello: + "4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e", + }, + }), + ); + } else if ( + req.url.endsWith("nostr.json?name=hellononexistentaddress") || + req.url.endsWith("nostr.json?name=wintertree4") || + req.url.endsWith("nostr.json?name=hrf") + ) { + return Promise.resolve({ + status: 404, + }); + } + throw new Error("Unmocked request: " + req.url); +}); for (const proxy of [DEFAULT_PROXY, false] as const) { describe("with proxy: " + proxy, () => { @@ -66,17 +304,12 @@ for (const proxy of [DEFAULT_PROXY, false] as const) { ); }); - it( - "generates an invoice ", - async () => { - // TODO: consider mocking responses - const ln = new LightningAddress("hello@getalby.com", { proxy }); - await ln.fetch(); - const invoice = await ln.requestInvoice({ satoshi: 1 }); - expect(invoice.paymentRequest).toContain("lnbc"); - }, - SPEC_TIMEOUT, - ); + it("generates an invoice ", async () => { + const ln = new LightningAddress("hello@getalby.com", { proxy }); + await ln.fetch(); + const invoice = await ln.requestInvoice({ satoshi: 1 }); + expect(invoice.paymentRequest).toContain("lnbc"); + }); }); describe("boost", () => { @@ -104,31 +337,27 @@ for (const proxy of [DEFAULT_PROXY, false] as const) { ); }); - it( - "successful boost returns preimage", - async () => { - const ln = new LightningAddress("hello@getalby.com", { - proxy, - webln: dummyWebLN, - }); - await ln.fetch(); - const result = await ln.boost({ - action: "boost", - value_msat: 21000, - value_msat_total: 21000, - app_name: "Podcastr", - app_version: "v2.1", - feedId: "21", - podcast: "random podcast", - episode: "1", - ts: 2121, - name: "Satoshi", - sender_name: "Alby", - }); - expect(result.preimage).toBe("dummy"); // from dummyWebLN - }, - SPEC_TIMEOUT, - ); + it("successful boost returns preimage", async () => { + const ln = new LightningAddress("hello@getalby.com", { + proxy, + webln: dummyWebLN, + }); + await ln.fetch(); + const result = await ln.boost({ + action: "boost", + value_msat: 21000, + value_msat_total: 21000, + app_name: "Podcastr", + app_version: "v2.1", + feedId: "21", + podcast: "random podcast", + episode: "1", + ts: 2121, + name: "Satoshi", + sender_name: "Alby", + }); + expect(result.preimage).toBe("dummy"); // from dummyWebLN + }); }); describe("zap", () => { @@ -154,105 +383,71 @@ for (const proxy of [DEFAULT_PROXY, false] as const) { ); }); - it( - "successful zap returns preimage", - async () => { - const ln = new LightningAddress("hello@getalby.com", { - proxy, - webln: dummyWebLN, - }); - await ln.fetch(); - const result = await ln.zap( - { - satoshi: 1000, - comment: "Awesome post", - relays: ["wss://relay.damus.io"], - e: "44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - }, - { - nostr: nostrProvider, - }, - ); - expect(result.preimage).toBe("dummy"); // from dummyWebLN - }, - SPEC_TIMEOUT, - ); + it("successful zap returns preimage", async () => { + const ln = new LightningAddress("hello@getalby.com", { + proxy, + webln: dummyWebLN, + }); + await ln.fetch(); + const result = await ln.zap( + { + satoshi: 1000, + comment: "Awesome post", + relays: ["wss://relay.damus.io"], + e: "44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + }, + { + nostr: nostrProvider, + }, + ); + expect(result.preimage).toBe("dummy"); // from dummyWebLN + }); }); describe("fetch", () => { - it( - "retrieves lnurlp data for lightning address without keysend or nostr", - async () => { - // TODO: consider mocking responses - const ln = new LightningAddress("hrf@btcpay.hrf.org", { proxy }); - await ln.fetch(); - expect(ln.lnurlpData?.max).toBe(612000000000); - }, - SPEC_TIMEOUT, - ); + it("retrieves lnurlp data for lightning address without keysend or nostr", async () => { + const ln = new LightningAddress("hrf@btcpay.hrf.org", { proxy }); + await ln.fetch(); + expect(ln.lnurlpData?.max).toBe(612000000000); + }); - it( - "retrieves lnurlp data", - async () => { - // TODO: consider mocking responses - const ln = new LightningAddress("hello@getalby.com", { proxy }); - await ln.fetch(); - expect(ln.lnurlpData?.max).toBe(11000000000); - }, - SPEC_TIMEOUT, - ); + it("retrieves lnurlp data", async () => { + const ln = new LightningAddress("hello@getalby.com", { proxy }); + await ln.fetch(); + expect(ln.lnurlpData?.max).toBe(11000000000); + }); - it( - "retrieves keysend data", - async () => { - // TODO: consider mocking responses - const ln = new LightningAddress("hello@getalby.com", { proxy }); - await ln.fetch(); - expect(ln.keysendData?.destination).toBe( - "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", - ); - }, - SPEC_TIMEOUT, - ); + it("retrieves keysend data", async () => { + const ln = new LightningAddress("hello@getalby.com", { proxy }); + await ln.fetch(); + expect(ln.keysendData?.destination).toBe( + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + ); + }); - it( - "retrieves nostr data", - async () => { - // TODO: consider mocking responses - const ln = new LightningAddress("hello@getalby.com", { proxy }); - await ln.fetch(); - expect(ln.nostrData?.names.hello).toEqual( - "4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e", - ); - }, - SPEC_TIMEOUT, - ); + it("retrieves nostr data", async () => { + const ln = new LightningAddress("hello@getalby.com", { proxy }); + await ln.fetch(); + expect(ln.nostrData?.names.hello).toEqual( + "4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e", + ); + }); - it( - "can fetch existing lightning address without nostr configuration", - async () => { - // TODO: consider mocking responses - const ln = new LightningAddress("wintertree4@getalby.com", { proxy }); - await ln.fetch(); - expect(ln.nostrData).toBeUndefined(); - }, - SPEC_TIMEOUT, - ); + it("can fetch existing lightning address without nostr configuration", async () => { + const ln = new LightningAddress("wintertree4@getalby.com", { proxy }); + await ln.fetch(); + expect(ln.nostrData).toBeUndefined(); + }); - it( - "does not throw error when requesting non-existing lightning address", - async () => { - const ln = new LightningAddress( - "hellononexistentaddress@getalby.com", - { proxy }, - ); - await ln.fetch(); - expect(ln.lnurlpData).toBeUndefined(); - expect(ln.keysendData).toBeUndefined(); - expect(ln.nostrData).toBeUndefined(); - }, - SPEC_TIMEOUT, - ); + it("does not throw error when requesting non-existing lightning address", async () => { + const ln = new LightningAddress("hellononexistentaddress@getalby.com", { + proxy, + }); + await ln.fetch(); + expect(ln.lnurlpData).toBeUndefined(); + expect(ln.keysendData).toBeUndefined(); + expect(ln.nostrData).toBeUndefined(); + }); }); }); } diff --git a/src/lightning-address.ts b/src/lightning-address.ts index 02eabf1..3d019c4 100644 --- a/src/lightning-address.ts +++ b/src/lightning-address.ts @@ -133,7 +133,7 @@ export default class LightningAddress { throw new Error("Valid callback does not exist in lnurlpData"); const callbackUrl = new URL(this.lnurlpData.callback); callbackUrl.search = new URLSearchParams(params).toString(); - const invoiceResult = await fetch(callbackUrl); + const invoiceResult = await fetch(callbackUrl.toString()); data = await invoiceResult.json(); } diff --git a/yarn.lock b/yarn.lock index d54da00..0d94405 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3033,6 +3033,13 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-fetch@^3.0.4: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -4613,6 +4620,14 @@ jest-environment-node@^29.5.0: jest-mock "^29.5.0" jest-util "^29.5.0" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^29.4.3: version "29.4.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" @@ -5377,6 +5392,13 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -6018,6 +6040,11 @@ process@~0.11.0: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== +promise-polyfill@^8.1.3: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + promise.series@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/promise.series/-/promise.series-0.2.0.tgz#2cc7ebe959fc3a6619c04ab4dbdc9e452d864bbd" @@ -6926,6 +6953,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + trim-newlines@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" @@ -7170,6 +7202,19 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"