paquette 0.1.0

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +10 -0
  3. data/README.md +90 -0
  4. data/lib/paquette/gem_server/directory_gem_repository.rb +154 -0
  5. data/lib/paquette/gem_server/gem_repacker.rb +170 -0
  6. data/lib/paquette/gem_server/gem_repository.rb +53 -0
  7. data/lib/paquette/gem_server/personalizer.rb +66 -0
  8. data/lib/paquette/gem_server/read_gated_repository.rb +74 -0
  9. data/lib/paquette/gem_server.rb +393 -0
  10. data/lib/paquette/npm_repacker.rb +153 -0
  11. data/lib/paquette/npm_server/directory_npm_repository.rb +127 -0
  12. data/lib/paquette/npm_server/gated_npm_repository.rb +66 -0
  13. data/lib/paquette/npm_server/npm_repository.rb +53 -0
  14. data/lib/paquette/npm_server.rb +98 -0
  15. data/lib/paquette/routes.rb +78 -0
  16. data/lib/paquette/subdomain_router.rb +59 -0
  17. data/lib/paquette/version.rb +3 -0
  18. data/lib/paquette.rb +12 -0
  19. data/test/fixtures/gems/minuscule_test/minuscule_test-0.1.0.gem +0 -0
  20. data/test/fixtures/gems/zip_kit/zip_kit-6.2.0.gem +0 -0
  21. data/test/fixtures/gems/zip_kit/zip_kit-6.2.1.gem +0 -0
  22. data/test/fixtures/npm/react-dropzone/react-dropzone-14.3.8.tgz +0 -0
  23. data/test/gem_server/bundler_install_test.rb +86 -0
  24. data/test/gem_server/directory_gem_repository_test.rb +208 -0
  25. data/test/gem_server/gem_repacker_test.rb +289 -0
  26. data/test/gem_server/personalizer_metadata_test.rb +49 -0
  27. data/test/gem_server/personalizer_test.rb +66 -0
  28. data/test/gem_server/read_gated_repository_test.rb +254 -0
  29. data/test/gem_server/repository_stack_test.rb +71 -0
  30. data/test/gem_server_test.rb +339 -0
  31. data/test/npm_repacker_sourcemap_test.rb +108 -0
  32. data/test/npm_repacker_test.rb +117 -0
  33. data/test/npm_server_test.rb +140 -0
  34. data/test/routes_test.rb +142 -0
  35. data/test/subdomain_router_integration_test.rb +126 -0
  36. data/test/subdomain_router_test.rb +125 -0
  37. data/test/test_helper.rb +12 -0
  38. metadata +117 -0
@@ -0,0 +1,71 @@
1
+ require_relative "../test_helper"
2
+
3
+ class RepositoryStackTest < Minitest::Test
4
+ def setup
5
+ @gems_dir = FIXTURE_GEMS_DIR
6
+ @dir_repository = Paquette::GemServer::DirectoryGemRepository.new(@gems_dir)
7
+ end
8
+
9
+ def test_repository_stack_setup
10
+ # Test the exact stack setup as used in GemServer
11
+ personalized_repository = Paquette::GemServer::Personalizer.new(@dir_repository,
12
+ license_key: "TEST-LICENSE-123",
13
+ magic_comment_replacements: {"# paquette_license_info" => "TEST-LICENSE-123"})
14
+ gated_repository = Paquette::GemServer::ReadGatedRepository.new(personalized_repository) { |name:, version: nil| true }
15
+
16
+ # Verify the stack works end-to-end
17
+ assert gated_repository.gem_names.include?("minuscule_test")
18
+ assert gated_repository.versions_for_gem("minuscule_test").include?("0.1.0")
19
+ assert gated_repository.gem_exists?("minuscule_test", "0.1.0")
20
+
21
+ # Test that personalization happens through the stack
22
+ skip "Test gem not found" unless gated_repository.gem_exists?("minuscule_test", "0.1.0")
23
+
24
+ gem_path = gated_repository.gem_file_path("minuscule_test", "0.1.0")
25
+ assert File.exist?(gem_path)
26
+
27
+ # Should be a personalized gem (different from original)
28
+ original_path = @dir_repository.gem_file_path("minuscule_test", "0.1.0")
29
+ refute_equal original_path, gem_path
30
+
31
+ # Should have different checksums
32
+ require "digest"
33
+ original_checksum = Digest::SHA256.file(original_path).hexdigest
34
+ personalized_checksum = Digest::SHA256.file(gem_path).hexdigest
35
+ refute_equal original_checksum, personalized_checksum
36
+ end
37
+
38
+ def test_stack_delegation
39
+ # Test that all methods are properly delegated through the stack
40
+ personalized_repository = Paquette::GemServer::Personalizer.new(@dir_repository,
41
+ license_key: "TEST-LICENSE-123",
42
+ magic_comment_replacements: {"# paquette_license_info" => "TEST-LICENSE-123"})
43
+ gated_repository = Paquette::GemServer::ReadGatedRepository.new(personalized_repository) { |name:, version: nil| true }
44
+
45
+ # Test that methods are delegated correctly
46
+ assert_equal @dir_repository.gem_names, gated_repository.gem_names
47
+ assert_equal @dir_repository.gem_versions, gated_repository.gem_versions
48
+
49
+ # Test that personalization is applied
50
+ skip "Test gem not found" unless gated_repository.gem_exists?("minuscule_test", "0.1.0")
51
+
52
+ # compact_info should use personalized checksums
53
+ personalized_info = gated_repository.compact_info("minuscule_test")
54
+ original_info = @dir_repository.compact_info("minuscule_test")
55
+
56
+ assert_equal original_info.length, personalized_info.length
57
+
58
+ # But checksums should be different
59
+ original_checksum = extract_checksum(original_info.first)
60
+ personalized_checksum = extract_checksum(personalized_info.first)
61
+ refute_equal original_checksum, personalized_checksum
62
+ end
63
+
64
+ private
65
+
66
+ def extract_checksum(info_line)
67
+ if (match = info_line.match(/checksum:([a-f0-9]+)/))
68
+ match[1]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,339 @@
1
+ require_relative "test_helper"
2
+
3
+ class GemServerTest < Minitest::Test
4
+ include Rack::Test::Methods
5
+
6
+ MINUSCULE_FIXTURE = File.join(FIXTURE_GEMS_DIR, "minuscule_test", "minuscule_test-0.1.0.gem")
7
+
8
+ def setup
9
+ repo = Paquette::GemServer::DirectoryGemRepository.new(FIXTURE_GEMS_DIR)
10
+ @app = Paquette::GemServer.new(repo)
11
+ end
12
+
13
+ attr_reader :app
14
+
15
+ def test_root_endpoint
16
+ get "/"
17
+ assert_equal 200, last_response.status
18
+ assert_equal "Paquette RubyGems Repository", last_response.body
19
+ end
20
+
21
+ def test_names_endpoint
22
+ get "/api/v1/names"
23
+ assert_equal 200, last_response.status
24
+ assert_equal "application/json", last_response.content_type
25
+
26
+ names = JSON.parse(last_response.body)
27
+ assert_equal ["minuscule_test", "zip_kit"], names.sort
28
+ end
29
+
30
+ def test_versions_endpoint
31
+ get "/api/v1/versions"
32
+ assert_equal 200, last_response.status
33
+ assert_equal "application/json", last_response.content_type
34
+
35
+ versions = JSON.parse(last_response.body)
36
+ assert_equal 3, versions.length
37
+
38
+ zip_kit_versions = versions.select { |v| v["name"] == "zip_kit" }
39
+ assert_equal 2, zip_kit_versions.length
40
+ assert_includes zip_kit_versions.map { |v| v["number"] }, "6.2.0"
41
+ assert_includes zip_kit_versions.map { |v| v["number"] }, "6.2.1"
42
+
43
+ minuscule_versions = versions.select { |v| v["name"] == "minuscule_test" }
44
+ assert_equal 1, minuscule_versions.length
45
+ assert_includes minuscule_versions.map { |v| v["number"] }, "0.1.0"
46
+ end
47
+
48
+ def test_specs_endpoint
49
+ get "/specs.4.8"
50
+ assert_equal 200, last_response.status
51
+ assert_equal "application/octet-stream", last_response.content_type
52
+
53
+ specs = Marshal.load(last_response.body)
54
+ assert specs.is_a?(Array)
55
+ assert_equal 3, specs.length
56
+
57
+ gem_names = specs.map { |spec| spec[0] }.sort.uniq
58
+ assert_equal ["minuscule_test", "zip_kit"], gem_names
59
+ end
60
+
61
+ def test_latest_specs_endpoint
62
+ get "/latest_specs.4.8"
63
+ assert_equal 200, last_response.status
64
+ assert_equal "application/octet-stream", last_response.content_type
65
+
66
+ specs = Marshal.load(last_response.body)
67
+ assert specs.is_a?(Array)
68
+ assert_equal 2, specs.length
69
+
70
+ gem_names = specs.map { |spec| spec[0] }.sort
71
+ assert_equal ["minuscule_test", "zip_kit"], gem_names
72
+ end
73
+
74
+ def test_gem_download
75
+ get "/gems/zip_kit-6.2.0.gem"
76
+ assert_equal 200, last_response.status
77
+ assert_equal "application/octet-stream", last_response.content_type
78
+ assert last_response.body.length > 0
79
+ end
80
+
81
+ def test_search_endpoint
82
+ get "/api/v1/search.json", query: "zip"
83
+ assert_equal 200, last_response.status
84
+ assert_equal "application/json", last_response.content_type
85
+
86
+ results = JSON.parse(last_response.body)
87
+ assert results.is_a?(Array)
88
+ assert results.any? { |r| r["name"] == "zip_kit" }
89
+ end
90
+
91
+ # Test compact index endpoints
92
+ def test_compact_index_names
93
+ get "/names"
94
+ assert_equal 200, last_response.status
95
+ assert_equal "text/plain", last_response.content_type
96
+
97
+ names = last_response.body.split("\n")
98
+ assert_equal ["minuscule_test", "zip_kit"], names.sort
99
+ end
100
+
101
+ def test_compact_index_versions
102
+ get "/versions"
103
+ assert_equal 200, last_response.status
104
+ assert_equal "text/plain", last_response.content_type
105
+
106
+ # Check for checksum header
107
+ assert last_response.headers["X-Checksum-Sha256"]
108
+ refute_nil last_response.headers["X-Checksum-Sha256"]
109
+
110
+ lines = last_response.body.split("\n")
111
+
112
+ # Check for timestamp header
113
+ assert lines[0].start_with?("created_at:")
114
+ assert lines[1] == "---"
115
+
116
+ # Check gem lines (skip timestamp and separator)
117
+ gem_lines = lines[2..]
118
+ assert_equal 2, gem_lines.length
119
+
120
+ # Check that each gem line has the format "gem_name versions checksum"
121
+ gem_lines.each do |line|
122
+ parts = line.split(" ")
123
+ assert parts.length >= 3, "Each gem line should have at least 3 parts: #{line}"
124
+ assert_match(/^[a-f0-9]{32}$/, parts[-1], "Checksum should be 32 hex characters: #{line}")
125
+ end
126
+
127
+ gem_names = gem_lines.map { |line| line.split(" ")[0] }
128
+ assert_equal ["minuscule_test", "zip_kit"], gem_names.sort
129
+ end
130
+
131
+ def test_compact_index_info
132
+ get "/info/zip_kit"
133
+ assert_equal 200, last_response.status
134
+ assert_equal "text/plain", last_response.content_type
135
+
136
+ info_lines = last_response.body.split("\n")
137
+ assert_equal 2, info_lines.length
138
+
139
+ # Check that each line has the format "version |checksum:sha256_checksum,ruby:required_ruby_version"
140
+ info_lines.each do |line|
141
+ assert_match(/^\S+\s+\|checksum:[a-f0-9]{64},ruby:.+$/, line, "Line should match compact index format: #{line}")
142
+
143
+ version, rest = line.split(" ", 2)
144
+ assert version.match?(/^\d+\.\d+\.\d+/), "Version should be in semver format: #{version}"
145
+
146
+ assert rest.start_with?("|checksum:"), "Should have checksum prefix: #{rest}"
147
+ checksum_part, ruby_part = rest[1..].split(",", 2)
148
+ assert checksum_part.start_with?("checksum:"), "Should have checksum: #{checksum_part}"
149
+ checksum = checksum_part.split(":", 2)[1]
150
+ assert_match(/^[a-f0-9]{64}$/, checksum, "Checksum should be 64 hex characters (SHA256): #{checksum}")
151
+
152
+ assert ruby_part.start_with?("ruby:"), "Should have ruby version requirement: #{ruby_part}"
153
+ end
154
+
155
+ version_lines = info_lines.map { |line| line.split(" ")[0] }
156
+ assert_includes version_lines, "6.2.0"
157
+ assert_includes version_lines, "6.2.1"
158
+ end
159
+
160
+ def test_compact_index_info_nonexistent
161
+ get "/info/nonexistent"
162
+ assert_equal 404, last_response.status
163
+ end
164
+
165
+ def test_specs_gz_endpoint
166
+ get "/specs.4.8.gz"
167
+ assert_equal 200, last_response.status
168
+ assert_equal "application/x-gzip", last_response.content_type
169
+
170
+ decompressed = Zlib::GzipReader.new(StringIO.new(last_response.body)).read
171
+ specs = Marshal.load(decompressed)
172
+ assert specs.is_a?(Array)
173
+ assert_equal 3, specs.length
174
+
175
+ gem_names = specs.map { |spec| spec[0] }.sort.uniq
176
+ assert_equal ["minuscule_test", "zip_kit"], gem_names
177
+ end
178
+
179
+ def test_latest_specs_gz_endpoint
180
+ get "/latest_specs.4.8.gz"
181
+ assert_equal 200, last_response.status
182
+ assert_equal "application/x-gzip", last_response.content_type
183
+
184
+ decompressed = Zlib::GzipReader.new(StringIO.new(last_response.body)).read
185
+ specs = Marshal.load(decompressed)
186
+ assert specs.is_a?(Array)
187
+ assert_equal 2, specs.length
188
+
189
+ gem_names = specs.map { |spec| spec[0] }.sort
190
+ assert_equal ["minuscule_test", "zip_kit"], gem_names
191
+ end
192
+
193
+ def test_quick_gemspec_endpoint
194
+ get "/quick/Marshal.4.8/zip_kit-6.2.0.gemspec.rz"
195
+ assert_equal 200, last_response.status
196
+ assert_equal "application/octet-stream", last_response.content_type
197
+
198
+ decompressed = Zlib::Inflate.inflate(last_response.body)
199
+ spec = Marshal.load(decompressed)
200
+ assert_equal "zip_kit", spec.name
201
+ assert_equal "6.2.0", spec.version.to_s
202
+ end
203
+
204
+ def test_quick_gemspec_nonexistent
205
+ get "/quick/Marshal.4.8/nonexistent-1.0.0.gemspec.rz"
206
+ assert_equal 404, last_response.status
207
+ end
208
+
209
+ def test_push_publishes_gem
210
+ Dir.mktmpdir do |dir|
211
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
212
+ app_with_writable_dir = Paquette::GemServer.new(repo)
213
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
214
+
215
+ binary = File.binread(MINUSCULE_FIXTURE)
216
+
217
+ session.post "/api/v1/gems", binary, "CONTENT_TYPE" => "application/octet-stream"
218
+
219
+ assert_equal 200, session.last_response.status
220
+ assert_equal "text/plain", session.last_response.content_type
221
+ assert_equal "Successfully registered gem: minuscule_test-0.1.0", session.last_response.body
222
+
223
+ persisted = File.join(dir, "minuscule_test", "minuscule_test-0.1.0.gem")
224
+ assert File.exist?(persisted)
225
+ assert_equal binary, File.binread(persisted)
226
+ end
227
+ end
228
+
229
+ def test_push_rejects_duplicate
230
+ Dir.mktmpdir do |dir|
231
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
232
+ app_with_writable_dir = Paquette::GemServer.new(repo)
233
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
234
+
235
+ binary = File.binread(MINUSCULE_FIXTURE)
236
+
237
+ session.post "/api/v1/gems", binary, "CONTENT_TYPE" => "application/octet-stream"
238
+ assert_equal 200, session.last_response.status
239
+
240
+ session.post "/api/v1/gems", binary, "CONTENT_TYPE" => "application/octet-stream"
241
+ assert_equal 409, session.last_response.status
242
+ assert_match(/already exists/, session.last_response.body)
243
+ end
244
+ end
245
+
246
+ def test_push_rejects_invalid_payload
247
+ Dir.mktmpdir do |dir|
248
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
249
+ app_with_writable_dir = Paquette::GemServer.new(repo)
250
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
251
+
252
+ session.post "/api/v1/gems", "not a real gem", "CONTENT_TYPE" => "application/octet-stream"
253
+ assert_equal 400, session.last_response.status
254
+ end
255
+ end
256
+
257
+ def test_yank_removes_gem_from_listings
258
+ Dir.mktmpdir do |dir|
259
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
260
+ app_with_writable_dir = Paquette::GemServer.new(repo)
261
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
262
+
263
+ binary = File.binread(MINUSCULE_FIXTURE)
264
+ session.post "/api/v1/gems", binary, "CONTENT_TYPE" => "application/octet-stream"
265
+
266
+ session.delete "/api/v1/gems/yank", {gem_name: "minuscule_test", version: "0.1.0"}
267
+ assert_equal 200, session.last_response.status
268
+ assert_equal "Successfully yanked gem: minuscule_test-0.1.0", session.last_response.body
269
+
270
+ tomb = File.join(dir, "minuscule_test", "minuscule_test-0.1.0.gem.tomb")
271
+ assert File.exist?(tomb)
272
+ refute File.exist?(File.join(dir, "minuscule_test", "minuscule_test-0.1.0.gem"))
273
+
274
+ # The gem disappears from version listings and compact info
275
+ session.get "/api/v1/versions"
276
+ versions = JSON.parse(session.last_response.body)
277
+ assert_empty versions.select { |v| v["name"] == "minuscule_test" }
278
+
279
+ session.get "/info/minuscule_test"
280
+ assert_equal 404, session.last_response.status
281
+ end
282
+ end
283
+
284
+ def test_yank_nonexistent_returns_404
285
+ Dir.mktmpdir do |dir|
286
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
287
+ app_with_writable_dir = Paquette::GemServer.new(repo)
288
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
289
+
290
+ session.delete "/api/v1/gems/yank", {gem_name: "nope", version: "1.0.0"}
291
+ assert_equal 404, session.last_response.status
292
+ end
293
+ end
294
+
295
+ def test_yank_missing_params_returns_400
296
+ Dir.mktmpdir do |dir|
297
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
298
+ app_with_writable_dir = Paquette::GemServer.new(repo)
299
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
300
+
301
+ session.delete "/api/v1/gems/yank", {gem_name: "foo"}
302
+ assert_equal 400, session.last_response.status
303
+ end
304
+ end
305
+
306
+ def test_push_refuses_tombed_gem
307
+ Dir.mktmpdir do |dir|
308
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
309
+ app_with_writable_dir = Paquette::GemServer.new(repo)
310
+ session = Rack::Test::Session.new(Rack::MockSession.new(app_with_writable_dir))
311
+
312
+ binary = File.binread(MINUSCULE_FIXTURE)
313
+
314
+ session.post "/api/v1/gems", binary, "CONTENT_TYPE" => "application/octet-stream"
315
+ session.delete "/api/v1/gems/yank", {gem_name: "minuscule_test", version: "0.1.0"}
316
+
317
+ session.post "/api/v1/gems", binary, "CONTENT_TYPE" => "application/octet-stream"
318
+ assert_equal 403, session.last_response.status
319
+ assert_match(/yanked/, session.last_response.body)
320
+ end
321
+ end
322
+
323
+ def test_push_and_yank_return_403_through_read_gated_repository
324
+ Dir.mktmpdir do |dir|
325
+ repo = Paquette::GemServer::DirectoryGemRepository.new(dir)
326
+ gated = Paquette::GemServer::ReadGatedRepository.new(repo) { |**| true }
327
+ read_only_app = Paquette::GemServer.new(gated)
328
+ session = Rack::Test::Session.new(Rack::MockSession.new(read_only_app))
329
+
330
+ session.post "/api/v1/gems", "anything", "CONTENT_TYPE" => "application/octet-stream"
331
+ assert_equal 403, session.last_response.status
332
+ assert_match(/not allowed/, session.last_response.body)
333
+
334
+ session.delete "/api/v1/gems/yank", {gem_name: "foo", version: "1.0.0"}
335
+ assert_equal 403, session.last_response.status
336
+ assert_match(/not allowed/, session.last_response.body)
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,108 @@
1
+ require_relative "test_helper"
2
+
3
+ class NpmRepackerSourcemapTest < Minitest::Test
4
+ def setup
5
+ @temp_dir = Dir.mktmpdir("npm_repacker_sourcemap_test")
6
+ @package_dir = File.join(@temp_dir, "package")
7
+ FileUtils.mkdir_p(@package_dir)
8
+
9
+ # Create a test package with JS file and sourcemap
10
+ create_test_package
11
+ end
12
+
13
+ def teardown
14
+ FileUtils.rm_rf(@temp_dir)
15
+ end
16
+
17
+ def test_repack_with_sourcemap_updates
18
+ # Create a temporary NPM package
19
+ package_path = create_npm_package
20
+
21
+ # Repack the package with text replacement
22
+ new_package_path = Paquette::NpmRepacker.repack(package_path) do |input_file, output_file, file_path|
23
+ unless File.extname(file_path).match?(/\.(ts|js|mjs|jsx|tsx)$/)
24
+ IO.copy_stream(input_file, output_file)
25
+ next
26
+ end
27
+
28
+ content = input_file.read
29
+ # Add some text to change the file length (simulating a real transformation)
30
+ content = content.gsub("function test()", 'function test() { console.log("modified");')
31
+ output_file.write(content)
32
+ end
33
+
34
+ assert File.exist?(new_package_path)
35
+
36
+ # Unpack and verify the sourcemap was updated
37
+ verify_sourcemap_update(new_package_path)
38
+
39
+ # Clean up
40
+ File.delete(package_path) if File.exist?(package_path)
41
+ File.delete(new_package_path) if File.exist?(new_package_path)
42
+ end
43
+
44
+ private
45
+
46
+ def create_test_package
47
+ # Create a simple JS file
48
+ js_content = <<~JS
49
+ function test() {
50
+ return "hello world";
51
+ }
52
+ //# sourceMappingURL=test.js.map
53
+ JS
54
+
55
+ # Create a simple sourcemap
56
+ sourcemap_content = {
57
+ version: 3,
58
+ sources: ["test.js"],
59
+ names: ["test"],
60
+ mappings: "AAAA,SAAS,SAAS,SAAS,CAAC",
61
+ file: "test.js"
62
+ }.to_json
63
+
64
+ File.write(File.join(@package_dir, "test.js"), js_content)
65
+ File.write(File.join(@package_dir, "test.js.map"), sourcemap_content)
66
+ end
67
+
68
+ def create_npm_package
69
+ package_path = File.join(@temp_dir, "test-package.tgz")
70
+ result = system("cd #{@temp_dir} && tar -czf #{package_path} package/")
71
+ raise "Failed to create test package" unless result
72
+ package_path
73
+ end
74
+
75
+ def verify_sourcemap_update(package_path)
76
+ # Unpack the repacked package
77
+ temp_unpack_dir = Dir.mktmpdir("verify_sourcemap")
78
+ begin
79
+ result = system("tar -xzf #{package_path} -C #{temp_unpack_dir}")
80
+ assert result, "Failed to unpack the repacked package"
81
+
82
+ # Find the unpacked package directory
83
+ package_dir = Dir.glob(File.join(temp_unpack_dir, "*")).find { |path| File.directory?(path) }
84
+ assert package_dir, "Could not find unpacked package directory"
85
+
86
+ # Check that the JS file was modified
87
+ js_file = File.join(package_dir, "test.js")
88
+ assert File.exist?(js_file), "JS file not found"
89
+
90
+ js_content = File.read(js_file)
91
+ assert js_content.include?('console.log("modified")'), "JS file was not modified"
92
+
93
+ # Check that the sourcemap was updated
94
+ sourcemap_file = File.join(package_dir, "test.js.map")
95
+ if File.exist?(sourcemap_file)
96
+ sourcemap_content = File.read(sourcemap_file)
97
+ sourcemap = JSON.parse(sourcemap_content)
98
+
99
+ # The sourcemap should have been updated (we can't easily verify the exact mapping changes
100
+ # without a more complex test, but we can verify it's still valid JSON)
101
+ assert sourcemap.is_a?(Hash), "Sourcemap is not valid JSON"
102
+ assert sourcemap["version"], "Sourcemap missing version"
103
+ end
104
+ ensure
105
+ FileUtils.rm_rf(temp_unpack_dir)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,117 @@
1
+ require_relative "test_helper"
2
+
3
+ class NpmRepackerTest < Minitest::Test
4
+ def setup
5
+ @test_package_path = File.join(FIXTURE_NPM_DIR, "react-dropzone", "react-dropzone-14.3.8.tgz")
6
+ end
7
+
8
+ def test_repack_with_text_replacement
9
+ skip "Test package not found" unless File.exist?(@test_package_path)
10
+
11
+ # Replace the first occurrence of 'Dropzone' with 'Liftzone' in JS/TS files
12
+ new_package_path = Paquette::NpmRepacker.repack(@test_package_path) do |input_file, output_file, file_path|
13
+ unless File.extname(file_path).match?(/\.(ts|js|mjs|jsx|tsx)$/)
14
+ IO.copy_stream(input_file, output_file)
15
+ next
16
+ end
17
+
18
+ # Read the entire content
19
+ content = input_file.read
20
+
21
+ # Replace ALL occurrences of 'Dropzone' with 'Liftzone'
22
+ if content.include?("Dropzone")
23
+ content = content.gsub("Dropzone", "Liftzone")
24
+ end
25
+
26
+ # Write the processed content
27
+ output_file.write(content)
28
+ end
29
+
30
+ assert File.exist?(new_package_path)
31
+ assert new_package_path.end_with?("-repacked.tgz")
32
+
33
+ # Unpack the produced package and verify the replacement
34
+ verify_repacking(new_package_path)
35
+
36
+ # Clean up
37
+ File.delete(new_package_path) if File.exist?(new_package_path)
38
+ end
39
+
40
+ def test_repack_requires_block
41
+ skip "Test package not found" unless File.exist?(@test_package_path)
42
+
43
+ assert_raises(ArgumentError) do
44
+ Paquette::NpmRepacker.repack(@test_package_path)
45
+ end
46
+ end
47
+
48
+ def test_repack_with_nonexistent_package
49
+ nonexistent_path = "/nonexistent/path.tgz"
50
+
51
+ assert_raises(ArgumentError) do
52
+ Paquette::NpmRepacker.repack(nonexistent_path) { |input_file, output_file, file_path| }
53
+ end
54
+ end
55
+
56
+ def test_repack_creates_new_package_with_different_checksum
57
+ skip "Test package not found" unless File.exist?(@test_package_path)
58
+
59
+ original_checksum = Digest::SHA256.file(@test_package_path).hexdigest
60
+
61
+ new_package_path = Paquette::NpmRepacker.repack(@test_package_path) do |input_file, output_file, file_path|
62
+ unless File.extname(file_path).match?(/\.(ts|js|mjs|jsx|tsx)$/)
63
+ IO.copy_stream(input_file, output_file)
64
+ next
65
+ end
66
+
67
+ content = input_file.read
68
+ content = content.gsub("Dropzone", "Liftzone") if content.include?("Dropzone")
69
+ output_file.write(content)
70
+ end
71
+
72
+ new_checksum = Digest::SHA256.file(new_package_path).hexdigest
73
+
74
+ assert File.exist?(new_package_path)
75
+ refute_equal original_checksum, new_checksum
76
+
77
+ # Clean up
78
+ File.delete(new_package_path) if File.exist?(new_package_path)
79
+ end
80
+
81
+ private
82
+
83
+ def verify_repacking(package_path)
84
+ # Create a temporary directory to unpack the new package
85
+ temp_dir = Dir.mktmpdir("verify_npm_repacking")
86
+ unpacked_dir = File.join(temp_dir, "unpacked")
87
+ FileUtils.mkdir_p(unpacked_dir)
88
+
89
+ begin
90
+ # Unpack the new package
91
+ result = system("tar -xzf #{package_path} -C #{unpacked_dir}")
92
+ assert result, "Failed to unpack the repacked package"
93
+
94
+ # Find the unpacked package directory
95
+ package_dir = Dir.glob(File.join(unpacked_dir, "*")).find { |path| File.directory?(path) }
96
+ assert package_dir, "Could not find unpacked package directory"
97
+
98
+ # Check JS/TS files for the replacement
99
+ found_replacement = false
100
+ extensions = %w[.ts .js .mjs .jsx .tsx]
101
+ extensions.each do |ext|
102
+ Dir.glob(File.join(package_dir, "**", "*#{ext}")).each do |js_file|
103
+ content = File.read(js_file)
104
+ if content.include?("Liftzone")
105
+ found_replacement = true
106
+ end
107
+ # Ensure the original word is gone (all occurrences)
108
+ refute content.include?("Dropzone"), "Original word 'Dropzone' still present in #{js_file}"
109
+ end
110
+ end
111
+
112
+ assert found_replacement, "Replacement word 'Liftzone' not found in any JS/TS file"
113
+ ensure
114
+ FileUtils.rm_rf(temp_dir)
115
+ end
116
+ end
117
+ end