@adea-ai/plugins 3.0.10 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +18 -11
  2. package/dist/agent-plugins-catalog.d.ts +2 -3
  3. package/dist/agent-plugins-catalog.js +2 -10
  4. package/dist/agent-plugins-catalog.js.map +1 -1
  5. package/dist/catalog-store.d.ts +81 -0
  6. package/dist/catalog-store.js +487 -0
  7. package/dist/catalog-store.js.map +1 -0
  8. package/dist/consumer-index.d.ts +1 -1
  9. package/dist/consumer-index.js +17 -7
  10. package/dist/consumer-index.js.map +1 -1
  11. package/dist/display-name.js +3 -2
  12. package/dist/display-name.js.map +1 -1
  13. package/dist/harness.d.ts +50 -9
  14. package/dist/harness.js +190 -147
  15. package/dist/harness.js.map +1 -1
  16. package/dist/icon-mirror.d.ts +2 -2
  17. package/dist/icon-mirror.js +38 -25
  18. package/dist/icon-mirror.js.map +1 -1
  19. package/dist/icons.js +5 -27
  20. package/dist/icons.js.map +1 -1
  21. package/dist/index.d.ts +9 -3
  22. package/dist/index.js +103 -88
  23. package/dist/index.js.map +1 -1
  24. package/dist/publication.d.ts +8 -11
  25. package/dist/publication.js +8 -11
  26. package/dist/publication.js.map +1 -1
  27. package/dist/schema.d.ts +0 -40
  28. package/dist/schema.js +0 -36
  29. package/dist/schema.js.map +1 -1
  30. package/dist/site-icons.d.ts +2 -0
  31. package/dist/site-icons.js +46 -9
  32. package/dist/site-icons.js.map +1 -1
  33. package/dist/source-digest.d.ts +2 -0
  34. package/dist/source-digest.js +12 -0
  35. package/dist/source-digest.js.map +1 -0
  36. package/dist/sources.js +3 -3
  37. package/dist/sources.js.map +1 -1
  38. package/native/build-provenance.txt +5 -5
  39. package/native/staged-reader-win32-arm64.node +0 -0
  40. package/native/staged-reader-win32-x64.node +0 -0
  41. package/package.json +4 -9
  42. package/src/agent-plugins-catalog.ts +3 -10
  43. package/src/artifacts.test.ts +80 -0
  44. package/src/catalog-installation.test.ts +1 -1
  45. package/src/catalog-store.ts +546 -0
  46. package/src/consumer-index.test.ts +125 -0
  47. package/src/consumer-index.ts +23 -8
  48. package/src/default-head.test.ts +143 -0
  49. package/src/display-name.test.ts +8 -0
  50. package/src/display-name.ts +3 -2
  51. package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
  52. package/src/harness.ts +249 -170
  53. package/src/icon-mirror.ts +36 -23
  54. package/src/icons.test.ts +5 -0
  55. package/src/icons.ts +6 -29
  56. package/src/index.test.ts +272 -40
  57. package/src/index.ts +131 -94
  58. package/src/manifest-retry.test.ts +49 -0
  59. package/src/portable-catalog.test.ts +8 -5
  60. package/src/publication.ts +8 -11
  61. package/src/schema.ts +0 -40
  62. package/src/site-icon-fallback.test.ts +36 -19
  63. package/src/site-icons.test.ts +104 -0
  64. package/src/site-icons.ts +47 -9
  65. package/src/source-config-sync.test.ts +84 -0
  66. package/src/source-digest.test.ts +101 -0
  67. package/src/source-digest.ts +14 -0
  68. package/src/sources-json.test.ts +53 -0
  69. package/src/sources.ts +3 -3
  70. package/src/upstream-headers.test.ts +31 -7
  71. package/src/write-artifacts.test.ts +375 -0
  72. package/dist/agent-plugins-harness.d.ts +0 -54
  73. package/dist/agent-plugins-harness.js +0 -212
  74. package/dist/agent-plugins-harness.js.map +0 -1
  75. package/src/agent-plugins-harness.ts +0 -281
package/src/icons.ts CHANGED
@@ -73,42 +73,20 @@ const MARK_WORDS = new Set([
73
73
  'logomark',
74
74
  'wordmark',
75
75
  ])
76
- const VARIANT_WORDS = new Set([
77
- 'large',
78
- 'small',
79
- 'light',
80
- 'dark',
81
- 'mono',
82
- 'white',
83
- 'black',
84
- 'positive',
85
- 'negative',
86
- 'color',
87
- 'colour',
88
- 'primary',
89
- 'secondary',
90
- 'horizontal',
91
- 'vertical',
92
- 'square',
93
- 'flat',
94
- 'glyph',
95
- ])
96
-
97
76
  /**
98
77
  * Splits a file name into mark tokens. Separators are normalised because
99
78
  * vendors write marks as `logo.svg`, `atlan-logo.png`, `Dynatrace_Logo_color_positive.png`
100
79
  * or `cs logo big.png` alike.
101
80
  */
102
- function splitName(basename: string): { stems: string[]; marks: boolean; size: number } {
81
+ function splitName(basename: string): { stemRank: number | undefined; size: number } {
103
82
  const withoutExtension = basename.slice(0, basename.lastIndexOf('.'))
104
83
  const words = withoutExtension
105
84
  .split(/[\s._-]+/)
106
85
  .filter(Boolean)
107
86
  .map((word) => word.toLowerCase())
108
87
  const size = words.some((word) => word === 'small') ? 1 : 0
109
- const marks = words.some((word) => MARK_WORDS.has(word))
110
- const stems = words.filter((word) => !MARK_WORDS.has(word) && !VARIANT_WORDS.has(word))
111
- return { stems, marks, size }
88
+ const ranks = words.filter((word) => MARK_WORDS.has(word)).map((word) => STEM_RANK[word] ?? 0)
89
+ return { stemRank: ranks.length > 0 ? Math.min(...ranks) : undefined, size }
112
90
  }
113
91
 
114
92
  /**
@@ -133,9 +111,7 @@ export function selectIconPath(fileIndex: readonly string[]): string | null {
133
111
  const directories = segments.map((segment) => segment.toLowerCase())
134
112
  if (directories.some((segment) => EXCLUDED.test(segment))) continue
135
113
  if (directories.some((segment) => EXCLUDED_DIRECTORIES.includes(segment))) continue
136
- const { stems, marks, size } = splitName(basename)
137
- const single = stems.length === 1 ? stems[0]! : undefined
138
- const stemRank = marks ? (single ? (STEM_RANK[single] ?? 0) : 0) : undefined
114
+ const { stemRank, size } = splitName(basename)
139
115
  const nested = directories.filter((segment) => DIRECTORY_RANK[segment] !== undefined)
140
116
  // A mark named as one — `logo.svg`, `atlan-logo.png`, `cs_logo_large.png` —
141
117
  // is accepted anywhere, including the plugin root. An unnamed image must sit
@@ -149,7 +125,8 @@ export function selectIconPath(fileIndex: readonly string[]): string | null {
149
125
  : 0
150
126
  if (!Number.isFinite(directoryRank)) continue
151
127
  const score = [stemRank ?? 5, directoryRank, size, EXTENSION_RANK[extension] ?? 9, path.length]
152
- if (!best || compareScore(score, best.score) < 0) best = { path, score }
128
+ const comparison = best ? compareScore(score, best.score) : -1
129
+ if (!best || comparison < 0 || (comparison === 0 && path < best.path)) best = { path, score }
153
130
  }
154
131
  return best ? best.path : null
155
132
  }
package/src/index.test.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
1
+ import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import { execFileSync } from 'node:child_process'
2
3
  import { readdirSync, readFileSync } from 'node:fs'
3
4
  import { gzipSync } from 'node:zlib'
4
5
  import { pack } from 'tar-stream'
@@ -21,6 +22,7 @@ import {
21
22
  type CategoryMap,
22
23
  type ProductAliases,
23
24
  type ResolvedSource,
25
+ type GeneratedArtifacts,
24
26
  } from './index.js'
25
27
  import {
26
28
  parseJsonDocument,
@@ -28,7 +30,6 @@ import {
28
30
  parseSourceSpec,
29
31
  type SourceConfig,
30
32
  } from './sources.js'
31
- import { createMaterializationPlan, supportedHarnesses } from './harness.js'
32
33
 
33
34
  const sources: SourceConfig[] = [
34
35
  {
@@ -409,6 +410,232 @@ describe('deterministic catalog synchronization', () => {
409
410
  ).rejects.toThrow('SOURCE_LOCK_PLUGIN_PIN_MISMATCH: pin-test:remote')
410
411
  })
411
412
 
413
+ test('refreshes explicit external revisions and repositories outside lock replay', async () => {
414
+ const fixtureRoot = await mkdtemp(join(tmpdir(), 'plugin-pin-refresh-'))
415
+ try {
416
+ await mkdir(join(fixtureRoot, 'pin-test'), { recursive: true })
417
+ const manifestPath = join(fixtureRoot, 'pin-test', 'marketplace.json')
418
+ const writeManifest = (sha: string, url = 'https://github.com/acme/remote-plugin') =>
419
+ writeFile(
420
+ manifestPath,
421
+ JSON.stringify({
422
+ plugins: [
423
+ {
424
+ name: 'remote',
425
+ description: 'Remote fixture plugin.',
426
+ source: { source: 'git', url, sha },
427
+ },
428
+ ],
429
+ })
430
+ )
431
+ const source: SourceConfig = {
432
+ ...sources[0]!,
433
+ sourceId: 'pin-test',
434
+ manifestPath: 'marketplace.json',
435
+ fixturePath: 'pin-test',
436
+ fixtureCommitSha: '5'.repeat(40),
437
+ }
438
+ const options = {
439
+ sources: [source],
440
+ categoryMap,
441
+ productAliases,
442
+ policy,
443
+ mode: 'offline' as const,
444
+ fixtureRoot,
445
+ snapshotLoader: {
446
+ load: async () => ({
447
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
448
+ symlinks: [],
449
+ }),
450
+ },
451
+ }
452
+ await writeManifest('6'.repeat(40))
453
+ const first = await synchronize(options)
454
+ await writeManifest('7'.repeat(40))
455
+ const refreshed = await synchronize({ ...options, existingLock: first.lock! })
456
+ expect(refreshed.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('7'.repeat(40))
457
+ expect(refreshed.catalog?.plugins[0]?.availableReleases[0]?.resolvedCommitSha).toBe(
458
+ '7'.repeat(40)
459
+ )
460
+ await writeManifest('8'.repeat(40), 'https://github.com/acme/replacement-plugin')
461
+ const replaced = await synchronize({ ...options, existingLock: refreshed.lock! })
462
+ expect(replaced.lock?.sources[0]?.pluginPins[0]?.repositoryUrl).toBe(
463
+ 'https://github.com/acme/replacement-plugin'
464
+ )
465
+ expect(replaced.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('8'.repeat(40))
466
+ } finally {
467
+ await rm(fixtureRoot, { recursive: true, force: true })
468
+ }
469
+ })
470
+
471
+ test.skipIf(process.platform === 'win32')(
472
+ 'pins annotated and lightweight tags to the target commit',
473
+ async () => {
474
+ const root = await mkdtemp(join(tmpdir(), 'plugin-tag-pin-'))
475
+ const repo = join(root, 'repository')
476
+ const bin = join(root, 'bin')
477
+ const originalPath = process.env.PATH
478
+ const originalGit = process.env.ADEA_TEST_REAL_GIT
479
+ const originalRepo = process.env.ADEA_TEST_TAG_REPO
480
+ const originalFetch = globalThis.fetch
481
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
482
+ const git = (...args: string[]) =>
483
+ execFileSync(realGit, ['-C', repo, ...args], { encoding: 'utf8' }).trim()
484
+ try {
485
+ await mkdir(repo)
486
+ await mkdir(bin)
487
+ git('init', '-q', '-b', 'main')
488
+ git(
489
+ '-c',
490
+ 'user.name=Fixture',
491
+ '-c',
492
+ 'user.email=fixture@example.invalid',
493
+ 'commit',
494
+ '--allow-empty',
495
+ '-qm',
496
+ 'fixture'
497
+ )
498
+ git(
499
+ '-c',
500
+ 'user.name=Fixture',
501
+ '-c',
502
+ 'user.email=fixture@example.invalid',
503
+ 'tag',
504
+ '-a',
505
+ 'annotated',
506
+ '-m',
507
+ 'fixture'
508
+ )
509
+ git('tag', 'lightweight')
510
+ const commit = git('rev-parse', 'HEAD')
511
+ expect(git('rev-parse', 'refs/tags/annotated')).not.toBe(commit)
512
+ const sourceSha = '5'.repeat(40)
513
+ const source: SourceConfig = {
514
+ ...sources[0]!,
515
+ sourceId: 'tag-fixture',
516
+ repositoryUrl: 'https://github.com/acme/marketplace',
517
+ manifestPath: 'marketplace.json',
518
+ }
519
+ const script = join(bin, 'git')
520
+ await writeFile(
521
+ script,
522
+ `#!/bin/sh\nif [ "$2" = "${source.repositoryUrl}" ]; then\n printf '%s\\t%s\\n' '${sourceSha}' "$3"\nelse\n shift 2\n exec "$ADEA_TEST_REAL_GIT" ls-remote "$ADEA_TEST_TAG_REPO" "$@"\nfi\n`
523
+ )
524
+ await chmod(script, 0o700)
525
+ process.env.ADEA_TEST_REAL_GIT = realGit
526
+ process.env.ADEA_TEST_TAG_REPO = repo
527
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
528
+ for (const ref of ['refs/tags/annotated', 'refs/tags/lightweight', 'refs/heads/main']) {
529
+ const manifest = JSON.stringify({
530
+ plugins: [
531
+ {
532
+ name: 'remote',
533
+ source: { source: 'git', url: 'https://github.com/acme/remote-plugin', ref },
534
+ },
535
+ ],
536
+ })
537
+ globalThis.fetch = (async (_url: unknown) => new Response(manifest)) as typeof fetch
538
+ const loaded: string[] = []
539
+ const result = await synchronize({
540
+ sources: [source],
541
+ categoryMap,
542
+ productAliases,
543
+ policy,
544
+ mode: 'live',
545
+ snapshotLoader: {
546
+ load: async (_url, sha) => {
547
+ loaded.push(sha)
548
+ return {
549
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
550
+ symlinks: [],
551
+ }
552
+ },
553
+ },
554
+ })
555
+ expect(result.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe(commit)
556
+ expect(loaded).toEqual([commit])
557
+ }
558
+ } finally {
559
+ globalThis.fetch = originalFetch
560
+ if (originalPath === undefined) delete process.env.PATH
561
+ else process.env.PATH = originalPath
562
+ if (originalGit === undefined) delete process.env.ADEA_TEST_REAL_GIT
563
+ else process.env.ADEA_TEST_REAL_GIT = originalGit
564
+ if (originalRepo === undefined) delete process.env.ADEA_TEST_TAG_REPO
565
+ else process.env.ADEA_TEST_TAG_REPO = originalRepo
566
+ await rm(root, { recursive: true, force: true })
567
+ }
568
+ }
569
+ )
570
+
571
+ test.skipIf(process.platform === 'win32')(
572
+ 'refreshes floating external refs even when the marketplace head is unchanged',
573
+ async () => {
574
+ const bin = await mkdtemp(join(tmpdir(), 'plugin-live-pin-'))
575
+ const originalPath = process.env.PATH
576
+ const originalFetch = globalThis.fetch
577
+ const sourceSha = '5'.repeat(40)
578
+ const source: SourceConfig = {
579
+ ...sources[0]!,
580
+ sourceId: 'live-pin-test',
581
+ repositoryUrl: 'https://github.com/acme/marketplace',
582
+ manifestPath: 'marketplace.json',
583
+ }
584
+ const manifest = JSON.stringify({
585
+ plugins: [
586
+ {
587
+ name: 'remote',
588
+ description: 'Remote plugin.',
589
+ source: { source: 'git', url: 'https://github.com/acme/remote-plugin', ref: 'main' },
590
+ },
591
+ ],
592
+ })
593
+ const gitPath = join(bin, 'git')
594
+ const writeGit = async (pluginSha: string) => {
595
+ await writeFile(
596
+ gitPath,
597
+ `#!/bin/sh\nif [ "$2" = "${source.repositoryUrl}" ]; then\n printf '%s\\t%s\\n' '${sourceSha}' "$3"\nelse\n printf '%s\\t%s\\n' '${pluginSha}' "$3"\nfi\n`
598
+ )
599
+ await chmod(gitPath, 0o700)
600
+ }
601
+ try {
602
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
603
+ globalThis.fetch = (async (url) => {
604
+ expect(String(url)).toBe(
605
+ `https://raw.githubusercontent.com/acme/marketplace/${sourceSha}/marketplace.json`
606
+ )
607
+ return new Response(manifest)
608
+ }) as typeof fetch
609
+ const options = {
610
+ sources: [source],
611
+ categoryMap,
612
+ productAliases,
613
+ policy,
614
+ mode: 'live' as const,
615
+ snapshotLoader: {
616
+ load: async () => ({
617
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
618
+ symlinks: [],
619
+ }),
620
+ },
621
+ }
622
+ await writeGit('6'.repeat(40))
623
+ const first = await synchronize(options)
624
+ await writeGit('7'.repeat(40))
625
+ const next = await synchronize({ ...options, existingLock: first.lock! })
626
+ expect(next.changed).toBe(true)
627
+ expect(next.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('7'.repeat(40))
628
+ const replay = await synchronize({ ...options, fromLock: first.lock! })
629
+ expect(replay.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe('6'.repeat(40))
630
+ } finally {
631
+ globalThis.fetch = originalFetch
632
+ if (originalPath === undefined) delete process.env.PATH
633
+ else process.env.PATH = originalPath
634
+ await rm(bin, { recursive: true, force: true })
635
+ }
636
+ }
637
+ )
638
+
412
639
  test('skips apollo-skills symlinks deterministically without dropping safe plugins', async () => {
413
640
  const fixtureRoot = await mkdtemp(join(tmpdir(), 'apollo-symlink-'))
414
641
  await mkdir(join(fixtureRoot, 'apollo'), { recursive: true })
@@ -477,6 +704,26 @@ describe('deterministic catalog synchronization', () => {
477
704
  verifyArtifacts(result.artifacts!)
478
705
  })
479
706
 
707
+ test('preserves an existing recovery directory when writing artifacts', async () => {
708
+ const parent = await mkdtemp(join(tmpdir(), 'plugin-recovery-'))
709
+ const output = join(parent, 'generated')
710
+ const backup = `${output}.previous`
711
+ try {
712
+ await mkdir(backup)
713
+ await writeFile(join(backup, 'keep.txt'), 'recover me')
714
+ const artifacts = { 'catalog.v1.json': '{}' } as unknown as GeneratedArtifacts
715
+ await expect(writeArtifacts(output, artifacts)).rejects.toThrow('CATALOG_RECOVERY_REQUIRED')
716
+ expect(await readFile(join(backup, 'keep.txt'), 'utf8')).toBe('recover me')
717
+ await mkdir(output)
718
+ await writeFile(join(output, 'current.txt'), 'current')
719
+ await expect(writeArtifacts(output, artifacts)).rejects.toThrow('CATALOG_RECOVERY_REQUIRED')
720
+ expect(await readFile(join(backup, 'keep.txt'), 'utf8')).toBe('recover me')
721
+ expect(await readFile(join(output, 'current.txt'), 'utf8')).toBe('current')
722
+ } finally {
723
+ await rm(parent, { recursive: true, force: true })
724
+ }
725
+ })
726
+
480
727
  test('keeps a failed build from producing a new artifact set', async () => {
481
728
  const first = await synchronize({
482
729
  sources,
@@ -537,37 +784,6 @@ describe('identity, classification, and harness contracts', () => {
537
784
  )
538
785
  expect(normalizeCategory('Developer Tools', categoryMap)).toBe('developer-tools')
539
786
  })
540
-
541
- test('generates a deterministic non-installing plan for every harness', async () => {
542
- const result = await synchronize({
543
- sources,
544
- categoryMap,
545
- productAliases,
546
- policy,
547
- mode: 'offline',
548
- fixtureRoot: join(process.cwd(), 'fixtures'),
549
- })
550
- const catalog = result.catalog!
551
- for (const harness of supportedHarnesses()) {
552
- const plan = createMaterializationPlan({
553
- catalog,
554
- pluginId: 'plugin:openai-official:linear',
555
- harness,
556
- })
557
- expect(plan.pluginId).toBe('plugin:openai-official:linear')
558
- expect(plan.configuration.executeInstallation).toBe(false)
559
- expect(JSON.stringify(plan)).toBe(
560
- JSON.stringify(
561
- createMaterializationPlan({
562
- catalog,
563
- pluginId: plan.pluginId,
564
- harness,
565
- releaseId: plan.releaseId,
566
- })
567
- )
568
- )
569
- }
570
- })
571
787
  })
572
788
 
573
789
  describe('filesystem safety', () => {
@@ -699,9 +915,13 @@ describe('upstream fetch quarantine', () => {
699
915
  })
700
916
 
701
917
  test('quarantines a plugin when the archive keeps failing', async () => {
702
- const stub = stubFetch((url) => {
918
+ let recovered = false
919
+ const stub = stubFetch(async (url) => {
703
920
  if (url === treeUrl) return jsonResponse(treePayload(['plugins/demo/plugin.json']))
704
- if (isArchive(url)) return new Response('broken', { status: 500 })
921
+ if (isArchive(url))
922
+ return recovered
923
+ ? tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
924
+ : new Response('broken', { status: 500 })
705
925
  return new Response('unexpected', { status: 200 })
706
926
  })
707
927
  try {
@@ -712,6 +932,9 @@ describe('upstream fetch quarantine', () => {
712
932
  )
713
933
  expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
714
934
  expect(stub.calls.filter(isArchive)).toHaveLength(3)
935
+ recovered = true
936
+ expect((await loader.load(repo, sha, 'plugins/demo')).files.has('plugin.json')).toBe(true)
937
+ expect(stub.calls.filter(isArchive)).toHaveLength(4)
715
938
  } finally {
716
939
  stub.restore()
717
940
  }
@@ -745,11 +968,15 @@ describe('upstream fetch quarantine', () => {
745
968
 
746
969
  test('does not retry a forbidden tree without rate-limit signals', async () => {
747
970
  let treeCalls = 0
748
- const stub = stubFetch((url) => {
971
+ const stub = stubFetch(async (url) => {
749
972
  if (url === treeUrl) {
750
973
  treeCalls += 1
751
- return new Response('denied', { status: 403 })
974
+ return treeCalls === 1
975
+ ? new Response('denied', { status: 403 })
976
+ : jsonResponse(treePayload(['plugins/demo/plugin.json']))
752
977
  }
978
+ if (isArchive(url))
979
+ return tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
753
980
  return new Response('unexpected', { status: 500 })
754
981
  })
755
982
  try {
@@ -760,6 +987,8 @@ describe('upstream fetch quarantine', () => {
760
987
  )
761
988
  expect(error?.message).toMatch(/^PLUGIN_SOURCE_UNAVAILABLE:/)
762
989
  expect(treeCalls).toBe(1)
990
+ expect((await loader.load(repo, sha, 'plugins/demo')).files.has('plugin.json')).toBe(true)
991
+ expect(treeCalls).toBe(2)
763
992
  } finally {
764
993
  stub.restore()
765
994
  }
@@ -779,11 +1008,14 @@ describe('upstream fetch quarantine', () => {
779
1008
  })
780
1009
  try {
781
1010
  const loader = new NetworkSnapshotLoader(policy)
782
- const first = await loader.load(repo, sha, 'plugins/one')
783
- const second = await loader.load(repo, sha, 'plugins/two')
1011
+ const [first, second] = await Promise.all([
1012
+ loader.load(repo, sha, 'plugins/one'),
1013
+ loader.load(repo, sha, 'plugins/two'),
1014
+ ])
784
1015
  expect([...first.files.keys()]).toEqual(['plugin.json'])
785
1016
  expect([...second.files.keys()].toSorted()).toEqual(['plugin.json', 'run.sh'])
786
- // The whole point: three files, and the archive is fetched once.
1017
+ // Concurrent plugins share both requests, not only the finished cache.
1018
+ expect(stub.calls.filter((url) => url === treeUrl)).toHaveLength(1)
787
1019
  expect(stub.calls.filter(isArchive)).toHaveLength(1)
788
1020
  expect(stub.calls.filter(isRawGitHubHost)).toHaveLength(0)
789
1021
  } finally {