-
Notifications
You must be signed in to change notification settings - Fork 296
Expand file tree
/
Copy pathuv_test.go
More file actions
1756 lines (1453 loc) · 73.1 KB
/
uv_test.go
File metadata and controls
1756 lines (1453 loc) · 73.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
buildinfo "github.com/jfrog/build-info-go/entities"
biutils "github.com/jfrog/build-info-go/utils"
coreBuild "github.com/jfrog/jfrog-cli-core/v2/common/build"
artUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
coretests "github.com/jfrog/jfrog-cli-core/v2/utils/tests"
clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/jfrog/jfrog-cli/inttestutils"
"github.com/jfrog/jfrog-cli/utils/tests"
)
// uvIndexEnvName converts a uv index name to the UV env var suffix format:
// e.g. "jfrog-pypi-virtual" → "JFROG_PYPI_VIRTUAL"
func uvIndexEnvName(name string) string {
upper := strings.ToUpper(name)
return strings.NewReplacer("-", "_", ".", "_", " ", "_").Replace(upper)
}
// ---------------------------------------------------------------------------
// Init / cleanup
// ---------------------------------------------------------------------------
func initUvTest(t *testing.T) {
if !*tests.TestUv {
t.Skip("Skipping UV tests. To run UV tests add the '-test.uv=true' option.")
}
require.True(t, isRepoExist(tests.UvLocalRepo), "UV local repo does not exist: "+tests.UvLocalRepo)
require.True(t, isRepoExist(tests.UvRemoteRepo), "UV remote repo does not exist: "+tests.UvRemoteRepo)
require.True(t, isRepoExist(tests.UvVirtualRepo), "UV virtual repo does not exist: "+tests.UvVirtualRepo)
}
func cleanUvTest(_ *testing.T) {
inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, tests.UvBuildName, artHttpDetails)
// Remove local partial build-info files from the system temp dir so they
// don't bleed into the next test when jf rt bp merges all partial files.
_ = coreBuild.RemoveBuildDir(tests.UvBuildName, "1", "")
tests.CleanFileSystem()
}
// createUvProject copies a test UV project to a temp dir, injects Artifactory
// URLs into pyproject.toml, then generates a fresh uv.lock against the test
// Artifactory instance. The lock file is not committed to avoid embedding
// instance-specific URLs in source.
func createUvProject(t *testing.T, outputFolder, projectName string) string {
projectSrc := filepath.Join(filepath.FromSlash(tests.GetTestResourcesPath()), "uv", projectName)
tmpDir, cleanup := coretests.CreateTempDirWithCallbackAndAssert(t)
t.Cleanup(cleanup)
projectPath := filepath.Join(tmpDir, outputFolder)
assert.NoError(t, biutils.CopyDir(projectSrc, projectPath, true, nil))
// Configure the jf CLI home so that coreConfig.GetDefaultServerConf() inside
// the jf uv command returns the TEST server (ecosysjfrog), not the developer's
// personal default server. This ensures credential injection targets the correct
// Artifactory instance when jf uv runs natively.
// Pattern matches what Poetry native tests do with prepareHomeDir(t).
createJfrogHomeConfig(t, false)
// Patch pyproject.toml with real Artifactory URLs for this test run
patchUvPyprojectToml(t, projectPath)
// Generate uv.lock against the patched index so UV resolves through
// Artifactory (required for dependency checksum enrichment tests).
// Convert the index name to the UV env var suffix format:
// "jfrog-pypi-virtual" → "JFROG_PYPI_VIRTUAL"
indexEnvName := uvIndexEnvName("jfrog-pypi-virtual")
lockCmd := exec.Command("uv", "lock")
lockCmd.Dir = projectPath
lockCmd.Env = append(os.Environ(),
"UV_INDEX_"+indexEnvName+"_USERNAME="+*tests.JfrogUser,
"UV_INDEX_"+indexEnvName+"_PASSWORD="+*tests.JfrogPassword,
"UV_KEYRING_PROVIDER=disabled",
)
out, err := lockCmd.CombinedOutput()
require.NoError(t, err, "uv lock failed during test setup — all subsequent assertions will be invalid.\nOutput: %s", out)
return projectPath
}
// patchUvPyprojectToml replaces placeholder URLs in pyproject.toml with the
// test Artifactory instance URLs.
func patchUvPyprojectToml(t *testing.T, projectPath string) {
t.Helper()
pyprojectPath := filepath.Join(projectPath, "pyproject.toml")
data, err := os.ReadFile(pyprojectPath)
require.NoError(t, err, "failed to read pyproject.toml — missing from test data?")
indexURL := serverDetails.ArtifactoryUrl + "api/pypi/" + tests.UvVirtualRepo + "/simple"
publishURL := serverDetails.ArtifactoryUrl + "api/pypi/" + tests.UvLocalRepo
content := string(data)
content = strings.ReplaceAll(content, "ARTIFACTORY_INDEX_URL", indexURL)
content = strings.ReplaceAll(content, "ARTIFACTORY_PUBLISH_URL", publishURL)
require.NoError(t, os.WriteFile(filepath.Clean(pyprojectPath), []byte(content), 0644), "failed to write patched pyproject.toml") // #nosec G703 -- path built from filepath.Join, not user input
}
// runUvCmd changes to projectPath and runs `jf uv <args...>`.
func runUvCmd(t *testing.T, projectPath string, args ...string) error {
wd, err := os.Getwd()
assert.NoError(t, err)
chdirCallback := clientTestUtils.ChangeDirWithCallback(t, wd, projectPath)
defer chdirCallback()
jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "")
return jfrogCli.Exec(append([]string{"uv"}, args...)...)
}
// ---------------------------------------------------------------------------
// Helper: validate build properties stamped on artifacts
// ---------------------------------------------------------------------------
// validateUvBuildProperties verifies that every artifact in the published build-info
// has build.name, build.number, and build.timestamp properties set directly on the
// Artifactory file (not just in the build-info JSON).
func validateUvBuildProperties(t *testing.T, repo, buildName string) {
const buildNumber = "1"
t.Helper()
// Get published build-info to find artifact paths
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed for %s/%s", buildName, buildNumber)
require.True(t, found, "build info not found for %s/%s — was 'jf rt bp' called?", buildName, buildNumber)
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules, "published build-info has no modules")
serviceManager, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false)
require.NoError(t, err, "failed to create Artifactory service manager")
verified := 0
for _, module := range publishedBuildInfo.BuildInfo.Modules {
for _, artifact := range module.Artifacts {
if artifact.Name == "" {
continue
}
fullPath := repo + "/" + artifact.Path + "/" + artifact.Name
props, propErr := serviceManager.GetItemProps(fullPath)
require.NoError(t, propErr, "GetItemProps failed for artifact %s", fullPath)
require.NotNil(t, props, "properties nil for artifact %s — was it uploaded to %s?", artifact.Name, repo)
assert.Contains(t, props.Properties, "build.name",
"build.name property must be set on artifact %s", artifact.Name)
assert.Contains(t, props.Properties, "build.number",
"build.number property must be set on artifact %s", artifact.Name)
assert.Contains(t, props.Properties, "build.timestamp",
"build.timestamp property must be set on artifact %s", artifact.Name)
if vals, ok := props.Properties["build.name"]; ok {
assert.Contains(t, vals, buildName,
"build.name value %v should include %q on artifact %s", vals, buildName, artifact.Name)
}
verified++
}
}
assert.Greater(t, verified, 0, "no artifacts were found in build-info to validate properties on")
}
// ---------------------------------------------------------------------------
// P0 — Happy path tests (block release)
// ---------------------------------------------------------------------------
// TestUvBuild verifies that `jf uv build` succeeds and produces .whl + .tar.gz.
func TestUvBuild(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-build", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath,
"build",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
// dist/ must contain both wheel and sdist
distDir := filepath.Join(projectPath, "dist")
entries, err := os.ReadDir(distDir)
assert.NoError(t, err)
var whl, sdist bool
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".whl") {
whl = true
}
if strings.HasSuffix(e.Name(), ".tar.gz") {
sdist = true
}
}
assert.True(t, whl, "expected .whl in dist/")
assert.True(t, sdist, "expected .tar.gz in dist/")
// Build info records dependencies but no artifacts — artifacts are only
// recorded after jf uv publish (nothing is uploaded to Artifactory by build).
inttestutils.ValidateGeneratedBuildInfoModule(t, tests.UvBuildName, buildNumber, "",
[]string{getUvModuleID(t, projectPath)}, buildinfo.Uv)
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1, "expected 1 build info module")
assert.Empty(t, publishedBuildInfo.BuildInfo.Modules[0].Artifacts,
"build command should not record artifacts — nothing is uploaded to Artifactory by jf uv build")
}
// TestUvPublish verifies that `jf uv publish` uploads artifacts to the local
// repo, stamps build properties, and captures artifacts in build info.
func TestUvPublish(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-publish", "uvproject")
buildNumber := "1"
// Build first
assert.NoError(t, runUvCmd(t, projectPath, "build"))
// Publish with build info
assert.NoError(t, runUvCmd(t, projectPath,
"publish",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
// Build properties must be stamped
validateUvBuildProperties(t, tests.UvLocalRepo, tests.UvBuildName)
// Build info must have 2 artifacts with sha1+sha256
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1)
require.Len(t, publishedBuildInfo.BuildInfo.Modules[0].Artifacts, 2,
"publish should capture exactly .whl and .tar.gz in build info")
for _, a := range publishedBuildInfo.BuildInfo.Modules[0].Artifacts {
assert.NotEmpty(t, a.Sha1, "artifact %s missing sha1", a.Name)
assert.NotEmpty(t, a.Sha256, "artifact %s missing sha256", a.Name)
}
}
// TestUvSync verifies `jf uv sync` installs packages and captures dependencies.
func TestUvSync(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-sync", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath,
"sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1)
assert.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Dependencies,
"sync should capture at least one dependency")
}
// TestUvBuildInfoPublished is covered by TestUvBuild which also verifies build-info
// is published and retrievable. Removed to avoid duplication.
// TestUvNoBuildInfoWhenFlagsAbsent verifies no build info is created when
// --build-name and --build-number are both absent.
func TestUvNoBuildInfoWhenFlagsAbsent(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-no-bi", "uvproject")
// Build without build flags → should succeed but not create a build info module
assert.NoError(t, runUvCmd(t, projectPath, "build"))
// Verify nothing was written to local build-info storage
localBuilds, localErr := coreBuild.GetGeneratedBuildsInfo(tests.UvBuildName, "1", "")
assert.NoError(t, localErr)
assert.Empty(t, localBuilds, "no local build info should be stored when --build-name/--build-number are absent")
// Also verify nothing is on the server (no accidental bp was called)
_, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, "1")
require.NoError(t, err, "GetBuildInfo failed")
assert.False(t, found, "no build info should exist on server when flags are absent")
}
// TestUvBuildPropertiesOnArtifacts verifies build.name / build.number /
// build.timestamp are stamped on published files so the build browser shows paths.
func TestUvBuildPropertiesOnArtifacts(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-props", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "build"))
assert.NoError(t, runUvCmd(t, projectPath, "publish",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
validateUvBuildProperties(t, tests.UvLocalRepo, tests.UvBuildName)
}
// ---------------------------------------------------------------------------
// P0 — UV FlexPack correctness invariants
// ---------------------------------------------------------------------------
// TestUvDepIDIsNameVersion verifies dependency IDs in build info follow the
// "name:version" format (e.g. "certifi:2026.2.25") matching pip/pipenv canonical
// build-info format — NOT wheel/sdist filenames.
func TestUvDepIDIsNameVersion(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-dep-id", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules)
for _, dep := range publishedBuildInfo.BuildInfo.Modules[0].Dependencies {
// ID must be strictly "name:version" — exactly one colon, both parts non-empty,
// no filename extensions.
parts := strings.SplitN(dep.Id, ":", 2)
assert.Len(t, parts, 2, "dependency ID %q should contain exactly one colon", dep.Id)
if len(parts) == 2 {
assert.NotEmpty(t, parts[0], "dependency name part should not be empty in ID %q", dep.Id)
assert.NotEmpty(t, parts[1], "dependency version part should not be empty in ID %q", dep.Id)
assert.False(t, strings.HasSuffix(dep.Id, ".whl") || strings.HasSuffix(dep.Id, ".tar.gz"),
"dependency ID %q must not be a filename (should be name:version)", dep.Id)
}
// Type must be a file extension ("whl" or "tar.gz"), not "pypi"
assert.True(t, dep.Type == "whl" || dep.Type == "tar.gz",
"dependency type %q should be 'whl' or 'tar.gz', not 'pypi'", dep.Type)
}
}
// TestUvModuleTypeIsUv verifies the build info module type is "uv" (entities.Uv).
func TestUvModuleTypeIsUv(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-module-type", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules)
assert.Equal(t, string(buildinfo.Uv), string(publishedBuildInfo.BuildInfo.Modules[0].Type),
"module type should be 'uv'")
}
// TestUvArtifactTypeIsExtension verifies artifact types are "wheel" (for .whl)
// or "sdist" (for .tar.gz), as returned by getArtifactTypeFromName.
func TestUvArtifactTypeIsExtension(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-art-type", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "build",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules)
for _, a := range publishedBuildInfo.BuildInfo.Modules[0].Artifacts {
// getArtifactTypeFromName returns "wheel" for .whl files, "sdist" for .tar.gz files
assert.True(t, a.Type == "wheel" || a.Type == "sdist",
"artifact type %q should be 'wheel' (for .whl) or 'sdist' (for .tar.gz)", a.Type)
}
}
// ---------------------------------------------------------------------------
// P1 — Build flag combinations (table-driven)
// ---------------------------------------------------------------------------
func TestUvBuildFlags(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
cases := []struct {
name string
buildName string
buildNumber string
expectBI bool
expectErr bool // jfrog-cli errors if only one of name/number is set
}{
{"both-set", tests.UvBuildName, "1", true, false},
{"name-only", tests.UvBuildName, "", false, true}, // missing number → CLI error
{"number-only", "", "1", false, true}, // missing name → CLI error
{"neither", "", "", false, false}, // no flags → runs fine, no BI
}
projectPath := createUvProject(t, "uv-flags", "uvproject")
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
buildNumber := strconv.Itoa(i + 1)
args := []string{"build"}
if tc.buildName != "" {
args = append(args, "--build-name="+tc.buildName)
}
if tc.buildNumber != "" {
buildNumber = tc.buildNumber
args = append(args, "--build-number="+buildNumber)
}
err := runUvCmd(t, projectPath, args...)
if tc.expectErr {
assert.Error(t, err, "expected error when only one of build-name/number is set (%s)", tc.name)
return
}
assert.NoError(t, err)
if tc.expectBI {
require.NoError(t, artifactoryCli.Exec("bp", tc.buildName, buildNumber))
_, found, biErr := tests.GetBuildInfo(serverDetails, tc.buildName, buildNumber)
assert.NoError(t, biErr)
require.True(t, found, "build info should exist for case %s", tc.name)
inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, tc.buildName, artHttpDetails)
} else {
// Verify absence using local build-info storage, not server query.
// Server query with an empty build name is unreliable.
localBuilds, localErr := coreBuild.GetGeneratedBuildsInfo(tests.UvBuildName, buildNumber, "")
assert.NoError(t, localErr)
assert.Empty(t, localBuilds,
"no local build info should be stored when build flags are absent (%s)", tc.name)
}
})
}
}
// ---------------------------------------------------------------------------
// P1 — Module override
// ---------------------------------------------------------------------------
func TestUvCustomModule(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-module", "uvproject")
buildNumber := "1"
customModule := "my-custom-uv-module"
assert.NoError(t, runUvCmd(t, projectPath, "build",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
"--module="+customModule))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules)
assert.Equal(t, customModule, publishedBuildInfo.BuildInfo.Modules[0].Id)
}
// ---------------------------------------------------------------------------
// P1 — publish-url resolution
// ---------------------------------------------------------------------------
// TestUvPublishURLFromToml verifies publish-url is read from [tool.uv] in
// pyproject.toml automatically (no --publish-url flag required).
func TestUvPublishURLFromToml(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
// uvproject-publish-url has [tool.uv] publish-url set, no flag needed
projectPath := createUvProject(t, "uv-pub-url-toml", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "build"))
// No --publish-url flag — should read from pyproject.toml
assert.NoError(t, runUvCmd(t, projectPath, "publish",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
validateUvBuildProperties(t, tests.UvLocalRepo, tests.UvBuildName)
}
// TestUvPublishURLFlagOverridesToml verifies --publish-url flag takes priority
// over [tool.uv] publish-url in pyproject.toml.
func TestUvPublishURLFlagOverridesToml(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-pub-url-flag", "uvproject")
buildNumber := "1"
explicitURL := serverDetails.ArtifactoryUrl + "api/pypi/" + tests.UvLocalRepo
assert.NoError(t, runUvCmd(t, projectPath, "build"))
assert.NoError(t, runUvCmd(t, projectPath, "publish",
"--publish-url="+explicitURL,
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
validateUvBuildProperties(t, tests.UvLocalRepo, tests.UvBuildName)
}
// ---------------------------------------------------------------------------
// P1 — Dependency enrichment (path linking)
// ---------------------------------------------------------------------------
// TestUvSyncDepsEnrichedFromArtifactory verifies that after `jf uv sync`
// against a virtual repo, dependencies have sha1+md5 so the build browser
// can show Artifactory repo paths.
func TestUvSyncDepsEnrichedFromArtifactory(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-enrich", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules)
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Dependencies)
// Every dependency resolved from an Artifactory index should have sha1+md5 enriched.
// sha256 always comes from uv.lock; sha1+md5 require a successful Artifactory AQL query.
deps := publishedBuildInfo.BuildInfo.Modules[0].Dependencies
var missing []string
for _, dep := range deps {
if dep.Sha1 == "" || dep.Md5 == "" {
missing = append(missing, dep.Id)
}
}
assert.Empty(t, missing,
"all dependencies resolved from Artifactory virtual repo should have sha1+md5 enriched; missing: %v", missing)
}
// TestUvSyncNoIndexOnlySha256 verifies that without [[tool.uv.index]],
// dependencies get sha256 from uv.lock but no sha1 (no Artifactory enrichment).
func TestUvSyncNoIndexOnlySha256(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
// Purge any leftover partial build-info files before this test starts so that
// a previous test's enriched sha1 values cannot bleed in when jf rt bp merges.
_ = coreBuild.RemoveBuildDir(tests.UvBuildName, "1", "")
// uvproject-no-index has no [[tool.uv.index]] in pyproject.toml
projectPath := createUvProject(t, "uv-no-index", "uvproject-no-index")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
// Check the local build info directly — before publishing to Artifactory — so that
// partial files from previous tests cannot contaminate the result via jf rt bp merging.
localBuilds, err := coreBuild.GetGeneratedBuildsInfo(tests.UvBuildName, buildNumber, "")
require.NoError(t, err, "GetGeneratedBuildsInfo failed")
require.Len(t, localBuilds, 1, "expected exactly one local build-info partial")
require.NotEmpty(t, localBuilds[0].Modules,
"uv sync should produce at least one build-info module even without an Artifactory index")
for _, dep := range localBuilds[0].Modules[0].Dependencies {
assert.NotEmpty(t, dep.Sha256,
"sha256 from uv.lock should always be present for dep %s", dep.Id)
assert.Empty(t, dep.Sha1,
"sha1 should be absent when no Artifactory index is configured for dep %s", dep.Id)
}
}
// ---------------------------------------------------------------------------
// P1 — Local sources excluded
// ---------------------------------------------------------------------------
// TestUvEditableSourceExcluded verifies that editable/workspace packages
// (source = {editable="."}) are not included as dependencies in build info.
func TestUvEditableSourceExcluded(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-editable", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules)
projectName := getUvProjectName(t, projectPath)
for _, dep := range publishedBuildInfo.BuildInfo.Modules[0].Dependencies {
assert.False(t, strings.Contains(strings.ToLower(dep.Id), strings.ToLower(projectName)),
"project itself (%s) should not appear as a dependency, got: %s", projectName, dep.Id)
}
}
// ---------------------------------------------------------------------------
// P1 — Repo & server
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// P1 — lock command captures dependency build info
// ---------------------------------------------------------------------------
func TestUvLockCapturesDependencies(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-lock", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "lock",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1)
// lock is a dep command — artifacts list should be empty
assert.Empty(t, publishedBuildInfo.BuildInfo.Modules[0].Artifacts,
"lock command should not produce artifacts in build info")
// but dependencies should be captured
assert.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Dependencies,
"lock command should capture dependencies in build info")
}
// TestUvRoundTrip is covered by TestUvPublish (build→publish→validate props)
// and TestUvSyncThenPublishRoundTrip (full sync→build→publish→validate).
// Removed to avoid duplication.
// TestUvSyncThenPublishRoundTrip exercises the full workflow:
// sync (collect deps) → build → publish (collect artifacts) → verify both.
func TestUvSyncThenPublishRoundTrip(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-full-roundtrip", "uvproject")
buildNumber := "1"
// Step 1: sync — captures dependencies
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
// Step 2: build — captures artifacts
assert.NoError(t, runUvCmd(t, projectPath, "build",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
// Step 3: publish — uploads + stamps properties
assert.NoError(t, runUvCmd(t, projectPath, "publish",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber))
// Publish build info
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1)
module := publishedBuildInfo.BuildInfo.Modules[0]
assert.Len(t, module.Artifacts, 2, "publish should capture .whl and .tar.gz")
assert.NotEmpty(t, module.Dependencies, "sync should have captured at least one dependency")
}
// ---------------------------------------------------------------------------
// P1 — No pyproject.toml → clear error
// ---------------------------------------------------------------------------
func TestUvNoPyprojectToml(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
tmpDir, cleanup := coretests.CreateTempDirWithCallbackAndAssert(t)
t.Cleanup(cleanup)
// Empty directory — no pyproject.toml
err := runUvCmd(t, tmpDir, "sync",
"--build-name="+tests.UvBuildName,
"--build-number=1")
// uv itself may fail OR the FlexPack may fail — either way we expect an error
assert.Error(t, err, "should fail when no pyproject.toml is present")
}
// ---------------------------------------------------------------------------
// P1 — Dependency expected vs actual
// ---------------------------------------------------------------------------
// TestUvDependencyExpectedVsActual verifies that build info contains exactly
// the dependencies declared in pyproject.toml — no more, no less.
//
// The test project (uvproject) declares one direct dependency:
// certifi>=2024.0.0
//
// After `jf uv sync` the build info module must contain:
// - Exactly 1 dependency (certifi; project itself is excluded)
// - Dep ID is "name:version" (e.g. "certifi:2026.2.25") — pip canonical format
// - Dep type is file extension ("whl" or "tar.gz")
// - Dep sha256 is non-empty (from uv.lock)
// - No scopes (Python has no compile/runtime distinction — matches pip/pipenv)
// - Module ID matches <project-name>:<version> from pyproject.toml
func TestUvDependencyExpectedVsActual(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-dep-exact", "uvproject")
buildNumber := "1"
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1, "expected exactly 1 build-info module")
module := publishedBuildInfo.BuildInfo.Modules[0]
// Module ID must be "<project-name>:<version>" from pyproject.toml
projectName := getUvProjectName(t, projectPath)
projectVersion := getUvProjectVersion(t, projectPath)
assert.Equal(t, projectName+":"+projectVersion, module.Id,
"module ID should be <project-name>:<version> from pyproject.toml")
// Module type must be "uv"
assert.Equal(t, string(buildinfo.Uv), string(module.Type),
"module type should be 'uv'")
// The test project declares exactly one direct dependency: certifi.
// The project itself (editable source) must NOT appear.
require.Len(t, module.Dependencies, 1,
"uvproject has 1 declared dependency (certifi); project itself must be excluded")
dep := module.Dependencies[0]
// ID must be "name:version" format matching pip canonical spec (not a filename)
assert.True(t, strings.HasPrefix(strings.ToLower(dep.Id), "certifi:"),
"dependency ID should be 'certifi:<version>' (name:version format), got: %s", dep.Id)
assert.False(t, strings.HasSuffix(dep.Id, ".whl") || strings.HasSuffix(dep.Id, ".tar.gz"),
"dependency ID must NOT be a filename, got: %s", dep.Id)
// Type must be the file extension ("whl" for wheel, "tar.gz" for sdist)
assert.True(t, dep.Type == "whl" || dep.Type == "tar.gz",
"dependency type should be 'whl' or 'tar.gz', got: %s", dep.Type)
// SHA256 must be present (from uv.lock)
assert.NotEmpty(t, dep.Sha256,
"sha256 must be present from uv.lock for dep %s", dep.Id)
// No scopes — Python has no compile/runtime distinction (matches pip/pipenv canonical format)
assert.Empty(t, dep.Scopes,
"Python deps should have no scopes (matches pip/pipenv format), got: %v", dep.Scopes)
}
// ---------------------------------------------------------------------------
// P2 — Proxy
// ---------------------------------------------------------------------------
func TestUvWithProxy(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
proxyPort := os.Getenv(tests.HttpsProxyEnvVar)
if proxyPort == "" {
t.Skip("Skipping proxy test: set " + tests.HttpsProxyEnvVar + " env var to run.")
}
projectPath := createUvProject(t, "uv-proxy", "uvproject")
assert.NoError(t, runUvCmd(t, projectPath, "sync"))
}
// ---------------------------------------------------------------------------
// P1 — Repo & Server: invalid repo → error
// ---------------------------------------------------------------------------
// TestUvPublishToInvalidRepo verifies that publishing to a nonexistent repo
// results in a clear error (404/401 from Artifactory).
func TestUvPublishToInvalidRepo(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-invalid-repo", "uvproject")
// Build first so dist/ exists
assert.NoError(t, runUvCmd(t, projectPath, "build"))
// Publish to a repo that does not exist — UV should propagate the HTTP error.
bogusURL := serverDetails.ArtifactoryUrl + "api/pypi/nonexistent-uv-repo-xyz"
err := runUvCmd(t, projectPath, "publish",
"--publish-url="+bogusURL,
)
assert.Error(t, err, "publishing to a nonexistent repo should return an error")
}
// ---------------------------------------------------------------------------
// P1 — Repo & Server: --project scopes build info directory
// ---------------------------------------------------------------------------
// TestUvProjectFlag verifies that passing --project=<key> stores the build
// info under the project-key-aware local directory (SHA includes the project
// key), and does NOT appear under the empty-project directory.
func TestUvProjectFlag(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
buildName := tests.UvBuildName + "-project"
buildNumber := "1"
projectKey := "testprj"
projectPath := createUvProject(t, "uv-project-flag", "uvproject")
// Run with --project flag so build info is keyed to the project
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+buildName,
"--build-number="+buildNumber,
"--project="+projectKey,
))
// Verify local build info was stored under the project-key-aware directory
builds, err := coreBuild.GetGeneratedBuildsInfo(buildName, buildNumber, projectKey)
require.NoError(t, err)
require.Len(t, builds, 1, "expected 1 build info file stored with project key %q", projectKey)
assert.Equal(t, buildName, builds[0].Name)
assert.Equal(t, buildNumber, builds[0].Number)
// Verify the build is NOT visible under the empty-project directory
buildsNoProject, err := coreBuild.GetGeneratedBuildsInfo(buildName, buildNumber, "")
assert.NoError(t, err)
assert.Empty(t, buildsNoProject, "build info should NOT exist under empty project key directory")
// Cleanup local build dir
assert.NoError(t, coreBuild.RemoveBuildDir(buildName, buildNumber, projectKey))
}
// ---------------------------------------------------------------------------
// P1 — UV-specific: uv add captures dependency build info
// ---------------------------------------------------------------------------
// TestUvAddCapturesDependencies verifies that `jf uv add <pkg>` captures
// dependencies in build info (same enrichment path as sync/lock).
func TestUvAddCapturesDependencies(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-add", "uvproject")
buildNumber := "1"
// `uv add` resolves and pins a new package; build info should capture deps.
assert.NoError(t, runUvCmd(t, projectPath, "add", "certifi",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules,
"uv add should produce at least one build info module")
assert.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Dependencies,
"uv add should capture at least one dependency")
}
// ---------------------------------------------------------------------------
// P1 — UV-specific: uv run captures dependency build info
// ---------------------------------------------------------------------------
// TestUvRunCapturesDependencies verifies that `jf uv run python --version`
// triggers dependency build info collection (same enrichment path as sync).
func TestUvRunCapturesDependencies(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-run", "uvproject")
buildNumber := "1"
// `uv run` installs the project's environment before executing the command,
// so the lock file is consulted and deps are resolved — build info is captured.
assert.NoError(t, runUvCmd(t, projectPath, "run", "python", "--version",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules,
"uv run should produce at least one build info module")
assert.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Dependencies,
"uv run should capture at least one dependency")
}
// ---------------------------------------------------------------------------
// P1 — Credential priority: native env var takes precedence over jf config
// ---------------------------------------------------------------------------
// TestUvCredentialPriorityEnvVar verifies that when UV_INDEX_<NAME>_USERNAME is
// already set in the environment, jf uv uses those credentials rather than
// injecting its own from jf server config. We validate this by pre-setting the
// correct index credentials and confirming the command succeeds (i.e., the
// env-var path is live and functional).
func TestUvCredentialPriorityEnvVar(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-cred-priority", "uvproject")
buildNumber := "1"
// Derive the env var suffix UV expects for the "jfrog-pypi-virtual" index name.
indexEnvName := uvIndexEnvName("jfrog-pypi-virtual")
userKey := "UV_INDEX_" + indexEnvName + "_USERNAME"
passKey := "UV_INDEX_" + indexEnvName + "_PASSWORD"
// Pre-set credentials — this simulates the "native" path (user has already
// configured env vars). jf uv should detect them and skip its own injection.
t.Setenv(userKey, *tests.JfrogUser)
t.Setenv(passKey, *tests.JfrogPassword)
// The sync must succeed: if jf uv had tried to double-inject, credentials
// would conflict; the fact it completes validates the native-priority logic.
assert.NoError(t, runUvCmd(t, projectPath, "sync",
"--build-name="+tests.UvBuildName,
"--build-number="+buildNumber,
))
require.NoError(t, artifactoryCli.Exec("bp", tests.UvBuildName, buildNumber))
publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tests.UvBuildName, buildNumber)
require.NoError(t, err, "GetBuildInfo failed")
require.True(t, found, "build info not found — was jf rt bp successful?")
require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules,
"sync with native env var credentials should produce a build info module")
}
// ---------------------------------------------------------------------------
// P1 — uv remove captures dependencies in build info
// ---------------------------------------------------------------------------
// TestUvRemoveCapturesDependencies verifies that `jf uv remove <pkg>` triggers
// dependency build info collection (the same FlexPack path as sync/add/lock).
// After removing the only dependency the module should still exist in build info
// (with an empty dependency list), confirming the capture path ran.
func TestUvRemoveCapturesDependencies(t *testing.T) {
initUvTest(t)
defer cleanUvTest(t)
projectPath := createUvProject(t, "uv-remove", "uvproject")
buildNumber := "1"