Skip to content

Commit b0a6f5b

Browse files
committed
Merge branch 'fix-static-resource-zip-files'
2 parents 0eb4360 + 43ce50a commit b0a6f5b

File tree

2 files changed

+327
-5
lines changed

2 files changed

+327
-5
lines changed

metadata/staticresources/staticresource.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"path/filepath"
1212
"strings"
13+
"time"
1314

1415
"github.com/ForceCLI/force-md/internal"
1516
"github.com/ForceCLI/force-md/metadata"
@@ -51,6 +52,9 @@ func zipDirectory(dirPath string) ([]byte, error) {
5152
zipWriter := zip.NewWriter(&buf)
5253
defer zipWriter.Close()
5354

55+
// Use a consistent timestamp for all files
56+
modTime := time.Now()
57+
5458
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
5559
if err != nil {
5660
return err
@@ -67,12 +71,26 @@ func zipDirectory(dirPath string) ([]byte, error) {
6771
return nil
6872
}
6973

70-
// Create header
71-
header, err := zip.FileInfoHeader(info)
72-
if err != nil {
73-
return err
74+
// Create header with explicit settings like force CLI does
75+
header := &zip.FileHeader{
76+
Name: filepath.ToSlash(relPath),
77+
Modified: modTime,
78+
}
79+
80+
// Set compression method
81+
if !info.IsDir() {
82+
header.Method = zip.Deflate
83+
}
84+
85+
// Handle directories properly
86+
if info.IsDir() {
87+
header.Name += "/"
88+
// Set external attributes for directory (0x10 for directory flag)
89+
header.SetMode(info.Mode())
90+
} else {
91+
// Set file mode for regular files
92+
header.SetMode(info.Mode())
7493
}
75-
header.Name = filepath.ToSlash(relPath)
7694

7795
// Write header
7896
writer, err := zipWriter.CreateHeader(header)

metadata/staticresources/staticresource_test.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package staticresource
22

33
import (
4+
"archive/zip"
5+
"bytes"
46
"io/ioutil"
57
"os"
68
"path/filepath"
79
"testing"
10+
"time"
811

912
"github.com/ForceCLI/force-md/metadata"
1013
"github.com/stretchr/testify/assert"
@@ -199,3 +202,304 @@ func TestStaticResourceDirectory(t *testing.T) {
199202
assert.True(t, foundMetadata, "Should have metadata file")
200203
assert.True(t, foundZip, "Should have zipped resource file")
201204
}
205+
206+
func TestZipDirectory(t *testing.T) {
207+
// Create a temporary directory structure
208+
tmpDir, err := ioutil.TempDir("", "zipdir-test")
209+
require.NoError(t, err)
210+
defer os.RemoveAll(tmpDir)
211+
212+
// Create test files and directories
213+
testFiles := map[string]string{
214+
"index.html": "<html><body>Test</body></html>",
215+
"css/styles.css": "body { margin: 0; }",
216+
"js/app.js": "console.log('test');",
217+
"images/logo.png": "fake png content",
218+
"nested/deep/file.txt": "nested content",
219+
}
220+
221+
for path, content := range testFiles {
222+
fullPath := filepath.Join(tmpDir, path)
223+
dir := filepath.Dir(fullPath)
224+
require.NoError(t, os.MkdirAll(dir, 0755))
225+
require.NoError(t, ioutil.WriteFile(fullPath, []byte(content), 0644))
226+
}
227+
228+
// Also create an empty directory
229+
emptyDir := filepath.Join(tmpDir, "empty")
230+
require.NoError(t, os.MkdirAll(emptyDir, 0755))
231+
232+
// Zip the directory
233+
zipContent, err := zipDirectory(tmpDir)
234+
require.NoError(t, err)
235+
require.NotNil(t, zipContent)
236+
require.True(t, len(zipContent) > 0)
237+
238+
// Verify the zip content
239+
reader, err := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent)))
240+
require.NoError(t, err)
241+
242+
// Check that all files are present in the zip
243+
foundFiles := make(map[string]bool)
244+
for _, file := range reader.File {
245+
foundFiles[file.Name] = true
246+
247+
// Verify compression method for files
248+
if !file.FileInfo().IsDir() {
249+
assert.Equal(t, zip.Deflate, file.Method, "File %s should use Deflate compression", file.Name)
250+
}
251+
252+
// Verify directory names end with /
253+
if file.FileInfo().IsDir() {
254+
assert.True(t, filepath.ToSlash(file.Name)[len(file.Name)-1] == '/', "Directory %s should end with /", file.Name)
255+
}
256+
257+
// Verify content for files
258+
if !file.FileInfo().IsDir() {
259+
rc, err := file.Open()
260+
require.NoError(t, err)
261+
content, err := ioutil.ReadAll(rc)
262+
rc.Close()
263+
require.NoError(t, err)
264+
265+
// Check if this file's content matches what we expect
266+
for originalPath, expectedContent := range testFiles {
267+
if filepath.ToSlash(originalPath) == file.Name {
268+
assert.Equal(t, expectedContent, string(content), "Content mismatch for %s", file.Name)
269+
break
270+
}
271+
}
272+
}
273+
}
274+
275+
// Check that all expected files are in the zip
276+
for path := range testFiles {
277+
slashPath := filepath.ToSlash(path)
278+
assert.True(t, foundFiles[slashPath], "Expected file %s not found in zip", slashPath)
279+
}
280+
281+
// Check that expected directories are in the zip
282+
expectedDirs := []string{"css/", "js/", "images/", "nested/", "nested/deep/", "empty/"}
283+
for _, dir := range expectedDirs {
284+
assert.True(t, foundFiles[dir], "Expected directory %s not found in zip", dir)
285+
}
286+
}
287+
288+
func TestZipDirectoryWithSingleFile(t *testing.T) {
289+
// Test zipping a directory with just one file
290+
tmpDir, err := ioutil.TempDir("", "zipdir-single-test")
291+
require.NoError(t, err)
292+
defer os.RemoveAll(tmpDir)
293+
294+
testFile := filepath.Join(tmpDir, "single.txt")
295+
testContent := "single file content"
296+
require.NoError(t, ioutil.WriteFile(testFile, []byte(testContent), 0644))
297+
298+
zipContent, err := zipDirectory(tmpDir)
299+
require.NoError(t, err)
300+
301+
// Verify the zip content
302+
reader, err := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent)))
303+
require.NoError(t, err)
304+
305+
assert.Equal(t, 1, len(reader.File), "Expected 1 file in zip")
306+
307+
file := reader.File[0]
308+
assert.Equal(t, "single.txt", file.Name)
309+
assert.Equal(t, zip.Deflate, file.Method, "File should use Deflate compression")
310+
311+
// Verify content
312+
rc, err := file.Open()
313+
require.NoError(t, err)
314+
defer rc.Close()
315+
316+
content, err := ioutil.ReadAll(rc)
317+
require.NoError(t, err)
318+
assert.Equal(t, testContent, string(content))
319+
}
320+
321+
func TestZipDirectoryEmptyDir(t *testing.T) {
322+
// Test zipping an empty directory
323+
tmpDir, err := ioutil.TempDir("", "zipdir-empty-test")
324+
require.NoError(t, err)
325+
defer os.RemoveAll(tmpDir)
326+
327+
// Create just an empty subdirectory
328+
emptyDir := filepath.Join(tmpDir, "empty")
329+
require.NoError(t, os.MkdirAll(emptyDir, 0755))
330+
331+
zipContent, err := zipDirectory(tmpDir)
332+
require.NoError(t, err)
333+
334+
// Verify the zip content
335+
reader, err := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent)))
336+
require.NoError(t, err)
337+
338+
// Should contain just the empty directory
339+
assert.Equal(t, 1, len(reader.File), "Expected 1 entry in zip")
340+
341+
file := reader.File[0]
342+
assert.Equal(t, "empty/", file.Name, "Expected directory name 'empty/'")
343+
assert.True(t, file.FileInfo().IsDir(), "Expected entry to be a directory")
344+
}
345+
346+
func TestZipDirectoryTimestamps(t *testing.T) {
347+
// Test that all files have consistent timestamps
348+
tmpDir, err := ioutil.TempDir("", "zipdir-timestamp-test")
349+
require.NoError(t, err)
350+
defer os.RemoveAll(tmpDir)
351+
352+
// Create test files with different actual timestamps
353+
file1 := filepath.Join(tmpDir, "file1.txt")
354+
require.NoError(t, ioutil.WriteFile(file1, []byte("content1"), 0644))
355+
356+
// Sleep to ensure different filesystem timestamp
357+
time.Sleep(10 * time.Millisecond)
358+
359+
file2 := filepath.Join(tmpDir, "file2.txt")
360+
require.NoError(t, ioutil.WriteFile(file2, []byte("content2"), 0644))
361+
362+
// Zip the directory
363+
beforeZip := time.Now().Add(-1 * time.Second) // Allow 1 second buffer
364+
zipContent, err := zipDirectory(tmpDir)
365+
require.NoError(t, err)
366+
afterZip := time.Now().Add(1 * time.Second) // Allow 1 second buffer
367+
368+
// Verify the zip content
369+
reader, err := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent)))
370+
require.NoError(t, err)
371+
372+
assert.Equal(t, 2, len(reader.File), "Expected 2 files in zip")
373+
374+
// Check that both files have the same timestamp
375+
var firstTimestamp time.Time
376+
for i, file := range reader.File {
377+
if i == 0 {
378+
firstTimestamp = file.Modified
379+
// Verify timestamp is reasonable (within the test execution window)
380+
assert.True(t, !firstTimestamp.Before(beforeZip) && !firstTimestamp.After(afterZip),
381+
"File timestamp %v should be between %v and %v", firstTimestamp, beforeZip, afterZip)
382+
} else {
383+
// All files should have the same timestamp
384+
assert.True(t, file.Modified.Equal(firstTimestamp),
385+
"File %s has different timestamp %v, expected %v", file.Name, file.Modified, firstTimestamp)
386+
}
387+
}
388+
}
389+
390+
func TestZipDirectoryCompression(t *testing.T) {
391+
// Test that files are actually compressed
392+
tmpDir, err := ioutil.TempDir("", "zipdir-compression-test")
393+
require.NoError(t, err)
394+
defer os.RemoveAll(tmpDir)
395+
396+
// Create a file with repetitive content that should compress well
397+
testFile := filepath.Join(tmpDir, "compressible.txt")
398+
// Create 10KB of repeated text
399+
repeatText := "This is a test sentence that will be repeated many times. "
400+
var content bytes.Buffer
401+
for i := 0; i < 170; i++ { // ~10KB
402+
content.WriteString(repeatText)
403+
}
404+
405+
require.NoError(t, ioutil.WriteFile(testFile, content.Bytes(), 0644))
406+
407+
originalSize := content.Len()
408+
409+
// Zip the directory
410+
zipContent, err := zipDirectory(tmpDir)
411+
require.NoError(t, err)
412+
413+
// The zip should be significantly smaller than the original due to compression
414+
zipSize := len(zipContent)
415+
compressionRatio := float64(zipSize) / float64(originalSize)
416+
417+
// With repetitive text, we should get at least 50% compression
418+
assert.True(t, compressionRatio < 0.5,
419+
"Compression ratio too low: %.2f (zip size: %d, original: %d)", compressionRatio, zipSize, originalSize)
420+
421+
// Verify the content is correct when decompressed
422+
reader, err := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent)))
423+
require.NoError(t, err)
424+
425+
assert.Equal(t, 1, len(reader.File), "Expected 1 file in zip")
426+
427+
file := reader.File[0]
428+
rc, err := file.Open()
429+
require.NoError(t, err)
430+
defer rc.Close()
431+
432+
decompressed, err := ioutil.ReadAll(rc)
433+
require.NoError(t, err)
434+
435+
assert.Equal(t, content.Bytes(), decompressed, "Decompressed content should match original")
436+
}
437+
438+
func TestZipDirectorySalesforceCompliance(t *testing.T) {
439+
// Test that the zip file format is compatible with Salesforce requirements
440+
tmpDir, err := ioutil.TempDir("", "zipdir-salesforce-test")
441+
require.NoError(t, err)
442+
defer os.RemoveAll(tmpDir)
443+
444+
// Create a typical static resource structure
445+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "css"), 0755))
446+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "js"), 0755))
447+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "img"), 0755))
448+
449+
files := map[string]string{
450+
"css/main.css": "body { font-family: Arial; }",
451+
"js/app.js": "function init() { console.log('ready'); }",
452+
"img/logo.svg": "<svg></svg>",
453+
"index.html": "<html><head></head><body></body></html>",
454+
}
455+
456+
for path, content := range files {
457+
fullPath := filepath.Join(tmpDir, path)
458+
require.NoError(t, ioutil.WriteFile(fullPath, []byte(content), 0644))
459+
}
460+
461+
// Zip the directory
462+
zipContent, err := zipDirectory(tmpDir)
463+
require.NoError(t, err)
464+
465+
// Verify the zip has proper structure for Salesforce
466+
reader, err := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent)))
467+
require.NoError(t, err)
468+
469+
// Check all expected files and directories are present
470+
expectedEntries := map[string]bool{
471+
"css/": true, // directory
472+
"css/main.css": false, // file
473+
"js/": true, // directory
474+
"js/app.js": false, // file
475+
"img/": true, // directory
476+
"img/logo.svg": false, // file
477+
"index.html": false, // file
478+
}
479+
480+
foundEntries := make(map[string]bool)
481+
for _, file := range reader.File {
482+
foundEntries[file.Name] = true
483+
484+
isDir, expectedIsDir := expectedEntries[file.Name]
485+
if expectedIsDir {
486+
assert.Equal(t, isDir, file.FileInfo().IsDir(),
487+
"Entry %s directory status mismatch", file.Name)
488+
}
489+
490+
// Verify all files use Deflate compression
491+
if !file.FileInfo().IsDir() {
492+
assert.Equal(t, zip.Deflate, file.Method,
493+
"File %s should use Deflate compression for Salesforce compatibility", file.Name)
494+
}
495+
496+
// Verify no file has zero timestamp (Salesforce requirement)
497+
assert.False(t, file.Modified.IsZero(),
498+
"File %s should have a valid timestamp for Salesforce", file.Name)
499+
}
500+
501+
// Verify all expected entries were found
502+
for entry := range expectedEntries {
503+
assert.True(t, foundEntries[entry], "Expected entry %s not found in zip", entry)
504+
}
505+
}

0 commit comments

Comments
 (0)