1const t = require('tap') 2const mockNpm = require('../../fixtures/mock-npm') 3 4const version = '1.0.0' 5 6const funding = { 7 type: 'individual', 8 url: 'http://example.com/donate', 9} 10 11const maintainerOwnsAllDeps = { 12 'package.json': JSON.stringify({ 13 name: 'maintainer-owns-all-deps', 14 version, 15 funding, 16 dependencies: { 17 'dep-foo': '*', 18 'dep-bar': '*', 19 }, 20 }), 21 node_modules: { 22 'dep-foo': { 23 'package.json': JSON.stringify({ 24 name: 'dep-foo', 25 version, 26 funding, 27 dependencies: { 28 'dep-sub-foo': '*', 29 }, 30 }), 31 node_modules: { 32 'dep-sub-foo': { 33 'package.json': JSON.stringify({ 34 name: 'dep-sub-foo', 35 version, 36 funding, 37 }), 38 }, 39 }, 40 }, 41 'dep-bar': { 42 'package.json': JSON.stringify({ 43 name: 'dep-bar', 44 version, 45 funding, 46 }), 47 }, 48 }, 49} 50 51const nestedNoFundingPackages = { 52 'package.json': JSON.stringify({ 53 name: 'nested-no-funding-packages', 54 version, 55 dependencies: { 56 foo: '*', 57 }, 58 devDependencies: { 59 lorem: '*', 60 }, 61 }), 62 node_modules: { 63 foo: { 64 'package.json': JSON.stringify({ 65 name: 'foo', 66 version, 67 dependencies: { 68 bar: '*', 69 }, 70 }), 71 node_modules: { 72 bar: { 73 'package.json': JSON.stringify({ 74 name: 'bar', 75 version, 76 funding, 77 }), 78 node_modules: { 79 'sub-bar': { 80 'package.json': JSON.stringify({ 81 name: 'sub-bar', 82 version, 83 funding: 'https://example.com/sponsor', 84 }), 85 }, 86 }, 87 }, 88 }, 89 }, 90 lorem: { 91 'package.json': JSON.stringify({ 92 name: 'lorem', 93 version, 94 funding: { 95 url: 'https://example.com/lorem', 96 }, 97 }), 98 }, 99 }, 100} 101 102const nestedMultipleFundingPackages = { 103 'package.json': JSON.stringify({ 104 name: 'nested-multiple-funding-packages', 105 version, 106 funding: ['https://one.example.com', 'https://two.example.com'], 107 dependencies: { 108 foo: '*', 109 }, 110 devDependencies: { 111 bar: '*', 112 }, 113 }), 114 node_modules: { 115 foo: { 116 'package.json': JSON.stringify({ 117 name: 'foo', 118 version, 119 funding: [ 120 'http://example.com', 121 { url: 'http://sponsors.example.com/me' }, 122 'http://collective.example.com', 123 ], 124 }), 125 }, 126 bar: { 127 'package.json': JSON.stringify({ 128 name: 'bar', 129 version, 130 funding: ['http://collective.example.com', { url: 'http://sponsors.example.com/you' }], 131 }), 132 }, 133 }, 134} 135 136const conflictingFundingPackages = { 137 'package.json': JSON.stringify({ 138 name: 'conflicting-funding-packages', 139 version, 140 dependencies: { 141 foo: '1.0.0', 142 }, 143 devDependencies: { 144 bar: '1.0.0', 145 }, 146 }), 147 node_modules: { 148 foo: { 149 'package.json': JSON.stringify({ 150 name: 'foo', 151 version: '1.0.0', 152 funding: 'http://example.com/1', 153 }), 154 }, 155 bar: { 156 node_modules: { 157 foo: { 158 'package.json': JSON.stringify({ 159 name: 'foo', 160 version: '2.0.0', 161 funding: 'http://example.com/2', 162 }), 163 }, 164 }, 165 'package.json': JSON.stringify({ 166 name: 'bar', 167 version: '1.0.0', 168 dependencies: { 169 foo: '2.0.0', 170 }, 171 }), 172 }, 173 }, 174} 175 176const setup = async (t, { openUrl, ...opts } = {}) => { 177 const openedUrls = [] 178 179 const res = await mockNpm(t, { 180 ...opts, 181 mocks: { 182 '@npmcli/promise-spawn': { open: openUrl || (async url => openedUrls.push(url)) }, 183 pacote: { 184 manifest: arg => 185 arg.name === 'ntl' 186 ? Promise.resolve({ funding: 'http://example.com/pacote' }) 187 : Promise.reject(new Error('ERROR')), 188 }, 189 ...opts.mocks, 190 }, 191 }) 192 193 return { 194 ...res, 195 openedUrls: () => openedUrls, 196 fund: (...args) => res.npm.exec('fund', args), 197 } 198} 199 200t.test('fund with no package containing funding', async t => { 201 const { fund, joinedOutput } = await setup(t, { 202 prefixDir: { 203 'package.json': JSON.stringify({ 204 name: 'no-funding-package', 205 version: '0.0.0', 206 }), 207 }, 208 config: {}, 209 }) 210 211 await fund() 212 t.matchSnapshot(joinedOutput(), 'should print empty funding info') 213}) 214 215t.test('fund in which same maintainer owns all its deps', async t => { 216 const { fund, joinedOutput } = await setup(t, { 217 prefixDir: maintainerOwnsAllDeps, 218 config: {}, 219 }) 220 221 await fund() 222 t.matchSnapshot(joinedOutput(), 'should print stack packages together') 223}) 224 225t.test('fund in which same maintainer owns all its deps, using --json option', async t => { 226 const { fund, joinedOutput } = await setup(t, { 227 prefixDir: maintainerOwnsAllDeps, 228 config: { json: true }, 229 }) 230 231 await fund() 232 t.same( 233 JSON.parse(joinedOutput()), 234 { 235 length: 3, 236 name: 'maintainer-owns-all-deps', 237 version: '1.0.0', 238 funding: { type: 'individual', url: 'http://example.com/donate' }, 239 dependencies: { 240 'dep-bar': { 241 version: '1.0.0', 242 funding: { type: 'individual', url: 'http://example.com/donate' }, 243 }, 244 'dep-foo': { 245 version: '1.0.0', 246 funding: { type: 'individual', url: 'http://example.com/donate' }, 247 dependencies: { 248 'dep-sub-foo': { 249 version: '1.0.0', 250 funding: { type: 'individual', url: 'http://example.com/donate' }, 251 }, 252 }, 253 }, 254 }, 255 }, 256 'should print stack packages together' 257 ) 258}) 259 260t.test('fund containing multi-level nested deps with no funding', async t => { 261 const { fund, joinedOutput } = await setup(t, { 262 prefixDir: nestedNoFundingPackages, 263 config: {}, 264 }) 265 266 await fund() 267 t.matchSnapshot(joinedOutput(), 'should omit dependencies with no funding declared') 268}) 269 270t.test('fund containing multi-level nested deps with no funding, using --json option', async t => { 271 const { fund, joinedOutput } = await setup(t, { 272 prefixDir: nestedNoFundingPackages, 273 config: { json: true }, 274 }) 275 276 await fund() 277 t.same( 278 JSON.parse(joinedOutput()), 279 { 280 length: 2, 281 name: 'nested-no-funding-packages', 282 version: '1.0.0', 283 dependencies: { 284 lorem: { 285 version: '1.0.0', 286 funding: { url: 'https://example.com/lorem' }, 287 }, 288 bar: { 289 version: '1.0.0', 290 funding: { type: 'individual', url: 'http://example.com/donate' }, 291 }, 292 }, 293 }, 294 'should omit dependencies with no funding declared in json output' 295 ) 296}) 297 298t.test('fund containing multi-level nested deps with no funding, using --json option', async t => { 299 const { fund, joinedOutput } = await setup(t, { 300 prefixDir: nestedMultipleFundingPackages, 301 config: { json: true }, 302 }) 303 304 await fund() 305 t.same( 306 JSON.parse(joinedOutput()), 307 { 308 length: 2, 309 name: 'nested-multiple-funding-packages', 310 version: '1.0.0', 311 funding: [ 312 { 313 url: 'https://one.example.com', 314 }, 315 { 316 url: 'https://two.example.com', 317 }, 318 ], 319 dependencies: { 320 bar: { 321 version: '1.0.0', 322 funding: [ 323 { 324 url: 'http://collective.example.com', 325 }, 326 { 327 url: 'http://sponsors.example.com/you', 328 }, 329 ], 330 }, 331 foo: { 332 version: '1.0.0', 333 funding: [ 334 { 335 url: 'http://example.com', 336 }, 337 { 338 url: 'http://sponsors.example.com/me', 339 }, 340 { 341 url: 'http://collective.example.com', 342 }, 343 ], 344 }, 345 }, 346 }, 347 'should list multiple funding entries in json output' 348 ) 349}) 350 351t.test('fund does not support global', async t => { 352 const { fund } = await setup(t, { 353 config: { global: true }, 354 }) 355 356 await t.rejects(fund(), { code: 'EFUNDGLOBAL' }, 'should throw EFUNDGLOBAL error') 357}) 358 359t.test('fund using package argument', async t => { 360 const { fund, openedUrls, joinedOutput } = await setup(t, { 361 prefixDir: maintainerOwnsAllDeps, 362 config: {}, 363 }) 364 365 await fund('.') 366 t.equal(joinedOutput(), '') 367 t.strictSame(openedUrls(), ['http://example.com/donate'], 'should open funding url') 368}) 369 370t.test('fund does not support global, using --json option', async t => { 371 const { fund } = await setup(t, { 372 prefixDir: {}, 373 config: { global: true, json: true }, 374 }) 375 376 await t.rejects( 377 fund(), 378 { code: 'EFUNDGLOBAL', message: '`npm fund` does not support global packages' }, 379 'should use expected error msg' 380 ) 381}) 382 383t.test('fund using string shorthand', async t => { 384 const { fund, openedUrls } = await setup(t, { 385 prefixDir: { 386 'package.json': JSON.stringify({ 387 name: 'funding-string-shorthand', 388 version: '0.0.0', 389 funding: 'https://example.com/sponsor', 390 }), 391 }, 392 config: {}, 393 }) 394 395 await fund('.') 396 t.strictSame(openedUrls(), ['https://example.com/sponsor'], 'should open string-only url') 397}) 398 399t.test('fund using nested packages with multiple sources', async t => { 400 const { fund, joinedOutput } = await setup(t, { 401 prefixDir: nestedMultipleFundingPackages, 402 config: {}, 403 }) 404 405 await fund('.') 406 t.matchSnapshot(joinedOutput(), 'should prompt with all available URLs') 407}) 408 409t.test('fund using symlink ref', async t => { 410 const f = 'http://example.com/a' 411 const { fund, openedUrls } = await setup(t, { 412 prefixDir: { 413 'package.json': JSON.stringify({ 414 name: 'using-symlink-ref', 415 version: '1.0.0', 416 }), 417 a: { 418 'package.json': JSON.stringify({ 419 name: 'a', 420 version: '1.0.0', 421 funding: f, 422 }), 423 }, 424 node_modules: { 425 a: t.fixture('symlink', '../a'), 426 }, 427 }, 428 config: {}, 429 }) 430 431 // using symlinked ref 432 await fund('./node_modules/a') 433 t.strictSame(openedUrls(), [f], 'should retrieve funding url from symlink') 434 435 // using target ref 436 await fund('./a') 437 t.strictSame(openedUrls(), [f, f], 'should retrieve funding url from symlink target') 438}) 439 440t.test('fund using data from actual tree', async t => { 441 const { fund, openedUrls } = await setup(t, { 442 prefixDir: { 443 'package.json': JSON.stringify({ 444 name: 'using-actual-tree', 445 version: '1.0.0', 446 }), 447 node_modules: { 448 a: { 449 'package.json': JSON.stringify({ 450 name: 'a', 451 version: '1.0.0', 452 funding: 'http://example.com/a', 453 }), 454 }, 455 b: { 456 'package.json': JSON.stringify({ 457 name: 'a', 458 version: '1.0.0', 459 funding: 'http://example.com/b', 460 }), 461 node_modules: { 462 a: { 463 'package.json': JSON.stringify({ 464 name: 'a', 465 version: '1.0.1', 466 funding: 'http://example.com/_AAA', 467 }), 468 }, 469 }, 470 }, 471 }, 472 }, 473 config: {}, 474 }) 475 476 // using symlinked ref 477 await fund('a') 478 t.strictSame( 479 openedUrls(), 480 ['http://example.com/_AAA'], 481 'should retrieve fund info from actual tree, using greatest version found' 482 ) 483}) 484 485t.test('fund using nested packages with multiple sources, with a source number', async t => { 486 const { fund, openedUrls } = await setup(t, { 487 prefixDir: nestedMultipleFundingPackages, 488 config: { which: '1' }, 489 }) 490 491 await fund('.') 492 t.strictSame(openedUrls(), ['https://one.example.com'], 'should open the numbered URL') 493}) 494 495t.test('fund using pkg name while having conflicting versions', async t => { 496 const { fund, openedUrls } = await setup(t, { 497 prefixDir: conflictingFundingPackages, 498 config: { which: '1' }, 499 }) 500 501 await fund('foo') 502 t.strictSame(openedUrls(), ['http://example.com/2'], 'should open greatest version') 503}) 504 505t.test('fund using bad which value: index too high', async t => { 506 const { fund, joinedOutput } = await setup(t, { 507 prefixDir: nestedMultipleFundingPackages, 508 config: { which: '100' }, 509 }) 510 511 await fund('foo') 512 t.match(joinedOutput(), 'not a valid index') 513 t.matchSnapshot(joinedOutput(), 'should print message about invalid which') 514}) 515 516t.test('fund using package argument with no browser, using --json option', async t => { 517 const { fund, openedUrls, joinedOutput } = await setup(t, { 518 prefixDir: maintainerOwnsAllDeps, 519 config: { json: true }, 520 }) 521 522 await fund('.') 523 t.equal(joinedOutput(), '', 'no output') 524 t.same( 525 openedUrls(), 526 ['http://example.com/donate'], 527 'should open funding url using json output' 528 ) 529}) 530 531t.test('fund using package info fetch from registry', async t => { 532 const { fund, openedUrls } = await setup(t, { 533 prefixDir: {}, 534 config: {}, 535 }) 536 537 await fund('ntl') 538 t.match( 539 openedUrls(), 540 /http:\/\/example.com\/pacote/, 541 'should open funding url that was loaded from registry manifest' 542 ) 543}) 544 545t.test('fund tries to use package info fetch from registry but registry has nothing', async t => { 546 const { fund } = await setup(t, { 547 prefixDir: {}, 548 config: {}, 549 }) 550 551 await t.rejects( 552 fund('foo'), 553 { code: 'ENOFUND', message: 'No valid funding method available for: foo' }, 554 'should have no valid funding message' 555 ) 556}) 557 558t.test('fund but target module has no funding info', async t => { 559 const { fund } = await setup(t, { 560 prefixDir: nestedNoFundingPackages, 561 config: {}, 562 }) 563 564 await t.rejects( 565 fund('foo'), 566 { code: 'ENOFUND', message: 'No valid funding method available for: foo' }, 567 'should have no valid funding message' 568 ) 569}) 570 571t.test('fund using bad which value', async t => { 572 const { fund } = await setup(t, { 573 prefixDir: nestedMultipleFundingPackages, 574 config: { which: '0' }, 575 }) 576 577 await t.rejects( 578 fund('bar'), 579 { 580 code: 'EFUNDNUMBER', 581 message: /must be given a positive integer/, 582 }, 583 'should have bad which option error message' 584 ) 585}) 586 587t.test('fund pkg missing version number', async t => { 588 const { fund, joinedOutput } = await setup(t, { 589 prefixDir: { 590 'package.json': JSON.stringify({ 591 name: 'foo', 592 funding: 'http://example.com/foo', 593 }), 594 }, 595 config: {}, 596 }) 597 598 await fund() 599 t.matchSnapshot(joinedOutput(), 'should print name only') 600}) 601 602t.test('fund a package throws on openUrl', async t => { 603 const { fund } = await setup(t, { 604 prefixDir: { 605 'package.json': JSON.stringify({ 606 name: 'foo', 607 version: '1.0.0', 608 funding: 'http://npmjs.org', 609 }), 610 }, 611 config: {}, 612 openUrl: () => { 613 throw new Error('ERROR') 614 }, 615 }) 616 617 await t.rejects(fund('.'), { message: 'ERROR' }, 'should throw unknown error') 618}) 619 620t.test('fund a package with type and multiple sources', async t => { 621 const { fund, joinedOutput } = await setup(t, { 622 prefixDir: { 623 'package.json': JSON.stringify({ 624 name: 'foo', 625 funding: [ 626 { 627 type: 'Foo', 628 url: 'http://example.com/foo', 629 }, 630 { 631 type: 'Lorem', 632 url: 'http://example.com/foo-lorem', 633 }, 634 ], 635 }), 636 }, 637 config: {}, 638 }) 639 640 await fund('.') 641 t.matchSnapshot(joinedOutput(), 'should print prompt select message') 642}) 643 644t.test('fund colors', async t => { 645 const { fund, joinedOutput } = await setup(t, { 646 prefixDir: { 647 'package.json': JSON.stringify({ 648 name: 'test-fund-colors', 649 version: '1.0.0', 650 dependencies: { 651 a: '^1.0.0', 652 b: '^1.0.0', 653 c: '^1.0.0', 654 }, 655 }), 656 node_modules: { 657 a: { 658 'package.json': JSON.stringify({ 659 name: 'a', 660 version: '1.0.0', 661 funding: 'http://example.com/a', 662 }), 663 }, 664 b: { 665 'package.json': JSON.stringify({ 666 name: 'b', 667 version: '1.0.0', 668 funding: 'http://example.com/b', 669 dependencies: { 670 d: '^1.0.0', 671 e: '^1.0.0', 672 }, 673 }), 674 }, 675 c: { 676 'package.json': JSON.stringify({ 677 name: 'c', 678 version: '1.0.0', 679 funding: 'http://example.com/b', 680 }), 681 }, 682 d: { 683 'package.json': JSON.stringify({ 684 name: 'd', 685 version: '1.0.0', 686 funding: 'http://example.com/d', 687 }), 688 }, 689 e: { 690 'package.json': JSON.stringify({ 691 name: 'e', 692 version: '1.0.0', 693 funding: 'http://example.com/e', 694 }), 695 }, 696 }, 697 }, 698 config: { color: 'always' }, 699 }) 700 701 await fund() 702 t.matchSnapshot(joinedOutput(), 'should print output with color info') 703}) 704 705t.test('sub dep with fund info and a parent with no funding info', async t => { 706 const { fund, joinedOutput } = await setup(t, { 707 prefixDir: { 708 'package.json': JSON.stringify({ 709 name: 'test-multiple-funding-sources', 710 version: '1.0.0', 711 dependencies: { 712 a: '^1.0.0', 713 b: '^1.0.0', 714 }, 715 }), 716 node_modules: { 717 a: { 718 'package.json': JSON.stringify({ 719 name: 'a', 720 version: '1.0.0', 721 dependencies: { 722 c: '^1.0.0', 723 }, 724 }), 725 }, 726 b: { 727 'package.json': JSON.stringify({ 728 name: 'b', 729 version: '1.0.0', 730 funding: 'http://example.com/b', 731 }), 732 }, 733 c: { 734 'package.json': JSON.stringify({ 735 name: 'c', 736 version: '1.0.0', 737 funding: ['http://example.com/c', 'http://example.com/c-other'], 738 }), 739 }, 740 }, 741 }, 742 config: {}, 743 }) 744 745 await fund() 746 t.matchSnapshot(joinedOutput(), 'should nest sub dep as child of root') 747}) 748 749t.test('workspaces', async t => { 750 const wsPrefixDir = { 751 'package.json': JSON.stringify({ 752 name: 'workspaces-support', 753 version: '1.0.0', 754 workspaces: ['packages/*'], 755 dependencies: { 756 d: '^1.0.0', 757 }, 758 }), 759 node_modules: { 760 a: t.fixture('symlink', '../packages/a'), 761 b: t.fixture('symlink', '../packages/b'), 762 c: { 763 'package.json': JSON.stringify({ 764 name: 'c', 765 version: '1.0.0', 766 funding: ['http://example.com/c', 'http://example.com/c-other'], 767 }), 768 }, 769 d: { 770 'package.json': JSON.stringify({ 771 name: 'd', 772 version: '1.0.0', 773 funding: 'http://example.com/d', 774 }), 775 }, 776 }, 777 packages: { 778 a: { 779 'package.json': JSON.stringify({ 780 name: 'a', 781 version: '1.0.0', 782 funding: 'https://example.com/a', 783 dependencies: { 784 c: '^1.0.0', 785 }, 786 }), 787 }, 788 b: { 789 'package.json': JSON.stringify({ 790 name: 'b', 791 version: '1.0.0', 792 funding: 'http://example.com/b', 793 dependencies: { 794 d: '^1.0.0', 795 }, 796 }), 797 }, 798 }, 799 } 800 801 t.test('filter funding info by a specific workspace name', async t => { 802 const { fund, joinedOutput } = await setup(t, { 803 prefixDir: wsPrefixDir, 804 config: { 805 workspace: 'a', 806 }, 807 }) 808 809 await fund() 810 t.matchSnapshot(joinedOutput(), 'should display only filtered workspace name and its deps') 811 }) 812 813 t.test('filter funding info by a specific workspace path', async t => { 814 const { fund, joinedOutput } = await setup(t, { 815 prefixDir: wsPrefixDir, 816 config: { 817 workspace: './packages/a', 818 }, 819 }) 820 821 await fund() 822 t.matchSnapshot(joinedOutput(), 'should display only filtered workspace name and its deps') 823 }) 824}) 825