diff --git a/.github/actions/coverage/action.yml b/.github/actions/coverage/action.yml new file mode 100644 index 0000000..c7cdc5e --- /dev/null +++ b/.github/actions/coverage/action.yml @@ -0,0 +1,10 @@ +name: Test Coverage +description: Send coverage to Coveralls + +runs: + using: "composite" + steps: + - name: Send coverage + uses: shogo82148/actions-goveralls@v1 + with: + path-to-profile: cover.out diff --git a/.github/actions/go-setup/action.yml b/.github/actions/go-setup/action.yml new file mode 100644 index 0000000..648179e --- /dev/null +++ b/.github/actions/go-setup/action.yml @@ -0,0 +1,15 @@ +name: Go Setup +description: Setup Go and install dependencies + +runs: + using: "composite" + steps: + - uses: actions/setup-go@v6 + with: + go-version: "1.25.7" + + - name: Setup dependencies + shell: bash + run: | + go install github.com/magefile/mage + mage -v bootstrap diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml new file mode 100644 index 0000000..b781f68 --- /dev/null +++ b/.github/actions/test/action.yml @@ -0,0 +1,15 @@ +name: Test +description: Run Go tests + +runs: + using: "composite" + steps: + - name: Run Tests + shell: bash + run: mage -v test + + - name: Run Build + shell: bash + run: | + mage -v build:release + ls -la bin diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml new file mode 100644 index 0000000..e325437 --- /dev/null +++ b/.github/workflows/test-dev.yml @@ -0,0 +1,21 @@ +name: Test Dev + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + name: Run Tests + + steps: + - uses: actions/checkout@v6 + - name: Go Setup + uses: ./.github/actions/go-setup + + - name: Run Tests + uses: ./.github/actions/test diff --git a/.github/workflows/test-master.yml b/.github/workflows/test-master.yml new file mode 100644 index 0000000..0cbc1f2 --- /dev/null +++ b/.github/workflows/test-master.yml @@ -0,0 +1,24 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + name: Run Tests + + steps: + - uses: actions/checkout@v6 + - name: Go Setup + uses: ./.github/actions/go-setup + + - name: Run Tests + uses: ./.github/actions/test + + - name: Send coverage + uses: ./.github/actions/coverage diff --git a/command/run.go b/command/run.go index 07bbcb5..87f7714 100644 --- a/command/run.go +++ b/command/run.go @@ -22,7 +22,7 @@ func (c *RunCommand) Help() string { jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) helpText := fmt.Sprintf(` Usage: reactenv run [options] PATH - + Inject environment variables into a built react app. Example: @@ -84,7 +84,13 @@ func (c *RunCommand) Run(args []string) int { os.Exit(1) } - renv.FindOccurrences() + err = renv.FindOccurrences() + + if err != nil { + c.UI.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets)) + c.UI.Error(fmt.Sprintf("%v", err)) + os.Exit(1) + } if renv.OccurrencesTotal == 0 { c.UI.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets), 0)) diff --git a/go.mod b/go.mod index e2076ad..5a39871 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/mitchellh/gox v1.0.1 github.com/posener/complete v1.2.3 github.com/schollz/progressbar/v3 v3.19.0 + github.com/stretchr/testify v1.11.1 gotest.tools/gotestsum v1.13.0 ) @@ -22,6 +23,7 @@ require ( github.com/armon/go-radix v1.0.0 // indirect github.com/bgentry/speakeasy v0.2.0 // indirect github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnephin/pflag v1.0.7 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect @@ -36,6 +38,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/iochan v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -46,4 +49,5 @@ require ( golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index be60c48..bef359b 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -130,6 +130,7 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/magefile.go b/magefile.go index 76f2ec2..c3ff5fd 100644 --- a/magefile.go +++ b/magefile.go @@ -51,7 +51,15 @@ func Test() error { log := NewLogger() defer log.End() return RunSync([][]string{ - {"gotestsum", "--format", "pkgname", "--", "--cover", "./..."}, + {"gotestsum", "--format", "pkgname", "--", "-coverprofile", "cover.out", "./..."}, + }) +} + +func TestWatch() error { + log := NewLogger() + defer log.End() + return RunSync([][]string{ + {"gotestsum", "--watch", "--format", "pkgname", "--", "./..."}, }) } @@ -59,7 +67,7 @@ func Bench() error { log := NewLogger() defer log.End() return RunSync([][]string{ - {"gotestsum", "--format", "pkgname", "--", "--cover", "-bench", ".", "-benchmem", "./..."}, + {"gotestsum", "--format", "pkgname", "--", "-bench", ".", "-benchmem", "./..."}, }) } diff --git a/npm/reactenv-darwin-arm64/package.json b/npm/reactenv-darwin-arm64/package.json index 67580d4..6f5bc4a 100644 --- a/npm/reactenv-darwin-arm64/package.json +++ b/npm/reactenv-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-arm64", - "version": "0.1.81", + "version": "0.1.88", "description": "The macOS ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-darwin-x64/package.json b/npm/reactenv-darwin-x64/package.json index 171c9ce..86126a2 100644 --- a/npm/reactenv-darwin-x64/package.json +++ b/npm/reactenv-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-x64", - "version": "0.1.81", + "version": "0.1.88", "description": "The macOS 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-arm64/package.json b/npm/reactenv-linux-arm64/package.json index 9a2fa88..24f4277 100644 --- a/npm/reactenv-linux-arm64/package.json +++ b/npm/reactenv-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-arm64", - "version": "0.1.81", + "version": "0.1.88", "description": "The Linux ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-x64/package.json b/npm/reactenv-linux-x64/package.json index fd91105..2c29345 100644 --- a/npm/reactenv-linux-x64/package.json +++ b/npm/reactenv-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-x64", - "version": "0.1.81", + "version": "0.1.88", "description": "The Linux 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-win32-x64/package.json b/npm/reactenv-win32-x64/package.json index 2d9e3ac..6b0d184 100644 --- a/npm/reactenv-win32-x64/package.json +++ b/npm/reactenv-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-win32-x64", - "version": "0.1.81", + "version": "0.1.88", "description": "The Windows 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv/package.json b/npm/reactenv/package.json index cb1ffca..3f56cd3 100644 --- a/npm/reactenv/package.json +++ b/npm/reactenv/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli", - "version": "0.1.81", + "version": "0.1.88", "description": "reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "bin": { diff --git a/reactenv/reactenv.go b/reactenv/reactenv.go index bf09c2b..b48ec6a 100644 --- a/reactenv/reactenv.go +++ b/reactenv/reactenv.go @@ -1,6 +1,7 @@ package reactenv import ( + "bytes" "fmt" "io/fs" "os" @@ -12,8 +13,7 @@ import ( ) const ( - REACTENV_PREFIX = "__reactenv" - REACTENV_FIND_EXPRESSION = `(__reactenv\.[a-zA-Z_$][0-9a-zA-Z_$]*)` + REACTENV_PREFIX = "__reactenv" ) type Reactenv struct { @@ -22,7 +22,7 @@ type Reactenv struct { // Path of directory to scan Dir string - // Total file count (that match `REACTENV_FIND_EXPRESSION`, within `Dir`) + // Total file count - that have matches - within specified `Dir` FilesMatchTotal int // Files with occurrences (not every matched file will have an occurrence, so this may be less than `FilesMatchTotal`) Files []*fs.DirEntry @@ -62,6 +62,7 @@ func NewReactenv(ui *ui.Ui) *Reactenv { // Populates `Reactenv.Files` with all files that match `fileMatchExpression` func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { r.Dir = dir + r.Files = make([]*fs.DirEntry, 0) files, err := os.ReadDir(r.Dir) if err != nil { @@ -76,7 +77,8 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { for _, file := range files { if fileMatcher.MatchString(file.Name()) && !file.IsDir() { - r.Files = append(r.Files, &file) + fileEntry := file + r.Files = append(r.Files, &fileEntry) } } @@ -120,8 +122,80 @@ func (r *Reactenv) FilesWalkContents(fileCb func(fileIndex int, file fs.DirEntry return nil } +// Strictly locates all instances of valid reactenv variables and returns their byte +// start and end indices. +// +// This implementation allows for side-by-side occurrences, which a regex pattern +// match would not (without an unsupported lookahead assertion). +func FindAllOccurrenceBytePositions(data []byte, prefix []byte) [][]int { + // Guard against infinite loop allocation caused by empty prefixes. + if len(prefix) == 0 { + return nil + } + + var indices [][]int + + // Establish absolute boundaries by finding all prefix locations + var prefixStarts []int + offset := 0 + for { + idx := bytes.Index(data[offset:], prefix) + if idx == -1 { + break + } + absoluteIdx := offset + idx + prefixStarts = append(prefixStarts, absoluteIdx) + offset = absoluteIdx + len(prefix) + } + + // Iterate through boundaries and enforce positional grammar + for i, start := range prefixStarts { + current := start + len(prefix) + + limit := len(data) + if i+1 < len(prefixStarts) { + limit = prefixStarts[i+1] + } + + // Validate the initial character (cannot be a numeral). + if current < limit { + firstByte := data[current] + isValidStart := (firstByte >= 'a' && firstByte <= 'z') || + (firstByte >= 'A' && firstByte <= 'Z') || + firstByte == '_' || firstByte == '$' + + if !isValidStart { + // Abort: The character following the dot is invalid + continue + } + current++ + } else { + // Abort: The string terminates immediately after the dot + continue + } + + // Scan forward for all subsequent valid identifier bytes + for current < limit { + b := data[current] + isValid := (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || + b == '_' || b == '$' + + if !isValid { + break + } + current++ + } + + indices = append(indices, []int{start, current}) + } + + return indices +} + // Walks every file and populates `Reactenv.Occurrences*` fields. -func (r *Reactenv) FindOccurrences() { +func (r *Reactenv) FindOccurrences() error { // Reset occurrence fields r.OccurrencesTotal = 0 r.OccurrencesByFile = make([]*FileOccurrences, 0) @@ -133,8 +207,9 @@ func (r *Reactenv) FindOccurrences() { newOccurrencesByFile := make([]*FileOccurrences, 0) fileIndexesToRemove := make(map[int]int, 0) - r.FilesWalkContents(func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error { - fileOccurrences := regexp.MustCompile(REACTENV_FIND_EXPRESSION).FindAllIndex(fileContents, -1) + err := r.FilesWalkContents(func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error { + prefix := fmt.Appendf([]byte(""), "%s.", REACTENV_PREFIX) + fileOccurrences := FindAllOccurrenceBytePositions(fileContents, prefix) fileOccurrencesToStore := make([]Occurrence, 0, len(fileOccurrences)) r.OccurrencesTotal += len(fileOccurrences) @@ -166,6 +241,10 @@ func (r *Reactenv) FindOccurrences() { return nil }) + if err != nil { + return err + } + // Remove files with no occurrences if len(fileIndexesToRemove) > 0 { for fileIndex, file := range r.Files { @@ -178,6 +257,8 @@ func (r *Reactenv) FindOccurrences() { r.Files = newFiles r.OccurrencesByFile = newOccurrencesByFile } + + return nil } func (r *Reactenv) ReplaceOccurrences() { diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go new file mode 100644 index 0000000..61c2f97 --- /dev/null +++ b/reactenv/reactenv_test.go @@ -0,0 +1,694 @@ +package reactenv + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/hmerritt/reactenv/ui" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTestFile(t *testing.T, dir string, name string) { + t.Helper() + filePath := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644), "write test file %s", filePath) +} + +func TestReactenvFindFilesMatchesFilesAndIgnoresDirs(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "alpha.js") + writeTestFile(t, tempDir, "beta.js") + writeTestFile(t, tempDir, "gamma.css") + + matchingDir := filepath.Join(tempDir, "dir.js") + require.NoError(t, os.Mkdir(matchingDir, 0755), "create matching dir") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") + + require.Equal(t, tempDir, renv.Dir, "Dir") + + require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") + + require.Len(t, renv.Files, 2, "Files length") + + found := map[string]bool{} + for _, file := range renv.Files { + found[(*file).Name()] = true + } + + expected := map[string]bool{ + "alpha.js": true, + "beta.js": true, + } + + require.Equal(t, expected, found, "matched files") +} + +func TestReactenvFindFilesReturnsErrorForMissingDir(t *testing.T) { + renv := NewReactenv(nil) + missingDir := filepath.Join(t.TempDir(), "missing") + + require.Error(t, renv.FindFiles(missingDir, `.*`), "expected error for missing directory") +} + +func TestReactenvFindFilesReturnsErrorForBadRegex(t *testing.T) { + renv := NewReactenv(nil) + tempDir := t.TempDir() + + require.Error(t, renv.FindFiles(tempDir, `[`), "expected error for invalid regex") +} + +func TestReactenvFilesWalkCallsCallbackInOrder(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "b.js") + writeTestFile(t, tempDir, "a.js") + writeTestFile(t, tempDir, "c.css") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + type walkCall struct { + index int + name string + filePath string + } + calls := make([]walkCall, 0) + + err := renv.FilesWalk(func(fileIndex int, file os.DirEntry, filePath string) error { + calls = append(calls, walkCall{ + index: fileIndex, + name: file.Name(), + filePath: filePath, + }) + return nil + }) + require.NoError(t, err) + + expected := []walkCall{ + {index: 0, name: "a.js", filePath: path.Join(tempDir, "a.js")}, + {index: 1, name: "b.js", filePath: path.Join(tempDir, "b.js")}, + } + + require.Equal(t, expected, calls) +} + +func TestReactenvFilesWalkStopsOnError(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "first.js") + writeTestFile(t, tempDir, "second.js") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + callCount := 0 + expectedErr := os.ErrInvalid + + err := renv.FilesWalk(func(fileIndex int, file os.DirEntry, filePath string) error { + callCount++ + return expectedErr + }) + + require.ErrorIs(t, err, expectedErr) + require.Equal(t, 1, callCount) +} + +func TestReactenvFilesWalkContentsCallsCallbackInOrderWithContents(t *testing.T) { + tempDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "b.js"), []byte("beta"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "a.js"), []byte("alpha"), 0644)) + writeTestFile(t, tempDir, "c.css") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + type walkCall struct { + index int + name string + filePath string + contents string + } + calls := make([]walkCall, 0) + + err := renv.FilesWalkContents(func(fileIndex int, file os.DirEntry, filePath string, fileContents []byte) error { + calls = append(calls, walkCall{ + index: fileIndex, + name: file.Name(), + filePath: filePath, + contents: string(fileContents), + }) + return nil + }) + require.NoError(t, err) + + expected := []walkCall{ + {index: 0, name: "a.js", filePath: path.Join(tempDir, "a.js"), contents: "alpha"}, + {index: 1, name: "b.js", filePath: path.Join(tempDir, "b.js"), contents: "beta"}, + } + + require.Equal(t, expected, calls) +} + +func TestReactenvFilesWalkContentsStopsOnError(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "first.js") + writeTestFile(t, tempDir, "second.js") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + callCount := 0 + expectedErr := os.ErrInvalid + + err := renv.FilesWalkContents(func(fileIndex int, file os.DirEntry, filePath string, fileContents []byte) error { + callCount++ + return expectedErr + }) + + require.ErrorIs(t, err, expectedErr) + require.Equal(t, 1, callCount) +} + +func TestReactenvFilesWalkContentsReadErrorExits(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestReactenvFilesWalkContentsReadErrorHelper") + cmd.Env = append(os.Environ(), "REACTENV_HELPER_PROCESS=1") + err := cmd.Run() + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestReactenvFilesWalkContentsReadErrorHelper(t *testing.T) { + if os.Getenv("REACTENV_HELPER_PROCESS") != "1" { + t.Skip("helper process only") + } + + tempDir := t.TempDir() + writeTestFile(t, tempDir, "missing.js") + + renv := NewReactenv(ui.GetUi()) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, os.Remove(filepath.Join(tempDir, "missing.js"))) + + renv.FilesWalkContents(func(fileIndex int, file os.DirEntry, filePath string, fileContents []byte) error { + return nil + }) +} + +func TestFindAllOccurrenceBytePositions(t *testing.T) { + prefix := fmt.Appendf([]byte(""), "%s.", REACTENV_PREFIX) + + tests := []struct { + name string + input string + expected [][]int + }{ + { + name: "Single isolated variable", + input: "__reactenv.MY_VAR", + expected: [][]int{{0, 17}}, + }, + { + name: "Contiguous variables (the primary edge case)", + input: "__reactenv.FIRST_VAR__reactenv.SECOND_VAR", + expected: [][]int{{0, 20}, {20, 41}}, + }, + { + name: "Rejection of leading numeral", + input: "__reactenv.1INVALID", + expected: nil, // The function should bypass this entirely. + }, + { + name: "Acceptance of leading dollar sign and underscore", + input: "__reactenv.$VALID __reactenv._VALID", + expected: [][]int{{0, 17}, {18, 35}}, + }, + { + name: "Early termination upon encountering invalid characters", + input: "__reactenv.VALID-INVALID", + expected: [][]int{{0, 16}}, // Halts exactly before the hyphen. + }, + { + name: "Premature string termination", + input: "Incomplete text __reactenv.", + expected: nil, // Must not panic when the string ends at the prefix. + }, + { + name: "Complete absence of occurrences", + input: "Standard text devoid of any environment variables.", + expected: nil, + }, + { + name: "Multiple spaced variables", + input: "Start __reactenv.ONE middle __reactenv.TWO end", + expected: [][]int{{6, 20}, {28, 42}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := FindAllOccurrenceBytePositions([]byte(tc.input), prefix) + + // testify/assert will recursively compare the multidimensional slices + // and output a detailed diff if an index mismatch occurs. + assert.Equal(t, tc.expected, actual, "Mismatch in calculated indices.") + }) + } +} + +func FuzzFindAllOccurrenceBytePositions(f *testing.F) { + // Establish the Seed Corpus + f.Add([]byte("__reactenv.MY_VAR"), []byte("__reactenv.")) + f.Add([]byte("__reactenv.FIRST__reactenv.SECOND"), []byte("__reactenv.")) + f.Add([]byte("__reactenv.1INVALID"), []byte("__reactenv.")) + f.Add([]byte("Standard text devoid of variables"), []byte("__reactenv.")) + f.Add([]byte(""), []byte("__reactenv.")) + f.Add([]byte("__reactenv.VALID"), []byte("")) // Tests our new empty-prefix guard clause. + + f.Fuzz(func(t *testing.T, data []byte, prefix []byte) { + // Simply executing the function proves it does not panic. + indices := FindAllOccurrenceBytePositions(data, prefix) + + // Validate Invariants on the returned data. + for _, match := range indices { + start, end := match[0], match[1] + + // Invariant A: Bounds Safety + // The indices must never exceed the slice capacity, and the + // start must logically precede the end. + if start < 0 || end > len(data) || start >= end { + t.Errorf("Bounds violation: generated indices [%d, %d] invalid for data length %d", start, end, len(data)) + } + + // Invariant B: Prefix Integrity + // If a valid match was claimed, the resulting string MUST + // physically begin with the requested prefix. + extracted := data[start:end] + if !bytes.HasPrefix(extracted, prefix) { + t.Errorf("Prefix violation: extracted %q does not begin with %q", extracted, prefix) + } + + // Invariant C: Content Capture + // The function must have captured at least one valid character + // beyond the length of the prefix itself. + if len(extracted) <= len(prefix) { + t.Errorf("Length violation: extracted %q contains no variable identifier", extracted) + } + } + }) +} + +func TestReactenvFindOccurrencesPopulatesFields(t *testing.T) { + tempDir := t.TempDir() + content := "a=__reactenv.FIRST;b=__reactenv.SECOND;c=__reactenv.FIRST;d=__reactenv._THIRD1;e=__reactenv.1BAD;" + filePath := filepath.Join(tempDir, "app.js") + + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + + t.Setenv("FIRST", "one") + t.Setenv("_THIRD1", "three") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + require.NoError(t, renv.FindOccurrences()) + + expectedTokens := []string{ + "__reactenv.FIRST", + "__reactenv.SECOND", + "__reactenv.FIRST", + "__reactenv._THIRD1", + } + expectedOccurrences := occurrencesFromTokens(t, content, expectedTokens) + + require.Equal(t, 4, renv.OccurrencesTotal) + require.Len(t, renv.Files, 1) + require.Equal(t, "app.js", (*renv.Files[0]).Name()) + require.Len(t, renv.OccurrencesByFile, 1) + require.Equal(t, expectedOccurrences, renv.OccurrencesByFile[0].Occurrences) + + expectedKeys := map[string]bool{ + "FIRST": true, + "SECOND": true, + "_THIRD1": true, + } + require.Equal(t, expectedKeys, renv.OccurrenceKeys) + + expectedReplacement := map[string]string{ + "FIRST": "one", + "_THIRD1": "three", + } + require.Equal(t, expectedReplacement, renv.OccurrenceKeysReplacement) +} + +func TestReactenvFindOccurrencesFiltersFilesWithoutMatches(t *testing.T) { + tempDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "a.js"), []byte("__reactenv.A"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "b.js"), []byte("no matches"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "c.js"), []byte("__reactenv.B__reactenv.A"), 0644)) + + t.Setenv("A", "value-a") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + require.NoError(t, renv.FindOccurrences()) + + require.Equal(t, 3, renv.OccurrencesTotal) + require.Len(t, renv.Files, 2) + require.Len(t, renv.OccurrencesByFile, 2) + + counts := map[string]int{} + for i, file := range renv.Files { + counts[(*file).Name()] = len(renv.OccurrencesByFile[i].Occurrences) + } + + require.Equal(t, map[string]int{ + "a.js": 1, + "c.js": 2, + }, counts) + + expectedKeys := map[string]bool{ + "A": true, + "B": true, + } + require.Equal(t, expectedKeys, renv.OccurrenceKeys) + + expectedReplacement := map[string]string{ + "A": "value-a", + } + require.Equal(t, expectedReplacement, renv.OccurrenceKeysReplacement) +} + +func TestReactenvFindOccurrencesNoMatchesClearsFiles(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "a.js") + writeTestFile(t, tempDir, "b.js") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + require.NoError(t, renv.FindOccurrences()) + + require.Equal(t, 0, renv.OccurrencesTotal) + require.Empty(t, renv.Files) + require.Empty(t, renv.OccurrencesByFile) + require.Empty(t, renv.OccurrenceKeys) + require.Empty(t, renv.OccurrenceKeysReplacement) +} + +func TestReactenvFindOccurrencesResetsStateOnRepeat(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "app.js") + + require.NoError(t, os.WriteFile(filePath, []byte("__reactenv.ONE"), 0644)) + t.Setenv("ONE", "1") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + require.NoError(t, renv.FindOccurrences()) + + require.Equal(t, 1, renv.OccurrencesTotal) + require.Equal(t, map[string]bool{"ONE": true}, renv.OccurrenceKeys) + require.Equal(t, map[string]string{"ONE": "1"}, renv.OccurrenceKeysReplacement) + require.Len(t, renv.Files, 1) + require.Len(t, renv.OccurrencesByFile, 1) + + require.NoError(t, os.WriteFile(filePath, []byte("no occurrences"), 0644)) + + require.NoError(t, renv.FindOccurrences()) + + require.Equal(t, 0, renv.OccurrencesTotal) + require.Empty(t, renv.Files) + require.Empty(t, renv.OccurrencesByFile) + require.Empty(t, renv.OccurrenceKeys) + require.Empty(t, renv.OccurrenceKeysReplacement) +} + +func FuzzReactenvFindOccurrences(f *testing.F) { + f.Add("") + f.Add("no matches here") + f.Add("__reactenv.A") + f.Add("__reactenv.A__reactenv.B") + f.Add("__reactenv.1BAD") + + f.Fuzz(func(t *testing.T, content string) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "app.js") + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + + require.NoError(t, renv.FindOccurrences()) + + prefix := fmt.Appendf([]byte(""), "%s.", REACTENV_PREFIX) + matches := FindAllOccurrenceBytePositions([]byte(content), prefix) + expectedTotal := len(matches) + + require.Equal(t, expectedTotal, renv.OccurrencesTotal) + require.Equal(t, len(renv.Files), len(renv.OccurrencesByFile)) + + if expectedTotal == 0 { + require.Empty(t, renv.Files) + require.Empty(t, renv.OccurrencesByFile) + require.Empty(t, renv.OccurrenceKeys) + return + } + + require.Len(t, renv.Files, 1) + require.Len(t, renv.OccurrencesByFile, 1) + require.Len(t, renv.OccurrencesByFile[0].Occurrences, expectedTotal) + + expectedKeys := map[string]bool{} + for i, match := range matches { + occurrence := renv.OccurrencesByFile[0].Occurrences[i] + require.Equal(t, match, occurrence.StartEnd) + + occurrenceText := content[match[0]:match[1]] + key := strings.TrimPrefix(occurrenceText, "__reactenv.") + expectedKeys[key] = true + require.Equal(t, key, occurrence.Key) + } + + require.Equal(t, expectedKeys, renv.OccurrenceKeys) + + for key, value := range renv.OccurrenceKeysReplacement { + envValue, ok := os.LookupEnv(key) + require.True(t, ok) + require.Equal(t, envValue, value) + } + }) +} + +func TestReactenvReplaceOccurrencesReplacesMatches(t *testing.T) { + tempDir := t.TempDir() + content := "start __reactenv.ONE mid __reactenv.TWO end __reactenv.ONE!" + filePath := filepath.Join(tempDir, "app.js") + + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + t.Setenv("ONE", "1") + t.Setenv("TWO", "two") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, renv.FindOccurrences()) + + renv.ReplaceOccurrences() + + updated, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, "start 1 mid two end 1!", string(updated)) +} + +func TestReactenvReplaceOccurrencesHandlesAdjacentMatches(t *testing.T) { + tempDir := t.TempDir() + content := "__reactenv.ONE__reactenv.TWO" + filePath := filepath.Join(tempDir, "app.js") + + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + t.Setenv("ONE", "first") + t.Setenv("TWO", "second") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, renv.FindOccurrences()) + + renv.ReplaceOccurrences() + + updated, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, "firstsecond", string(updated)) +} + +func TestReactenvReplaceOccurrencesUsesEmptyStringForMissingValues(t *testing.T) { + tempDir := t.TempDir() + content := "x__reactenv.MISSINGy" + filePath := filepath.Join(tempDir, "app.js") + + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, renv.FindOccurrences()) + + renv.ReplaceOccurrences() + + updated, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, "x", string(updated)) +} + +func TestReactenvReplaceOccurrencesNoMatchesIsNoop(t *testing.T) { + tempDir := t.TempDir() + content := "no matches" + filePath := filepath.Join(tempDir, "app.js") + + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, renv.FindOccurrences()) + + renv.ReplaceOccurrences() + + updated, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, content, string(updated)) +} + +func TestReactenvReplaceOccurrencesMultipleFiles(t *testing.T) { + tempDir := t.TempDir() + fileA := filepath.Join(tempDir, "a.js") + fileB := filepath.Join(tempDir, "b.js") + + require.NoError(t, os.WriteFile(fileA, []byte("a=__reactenv.A"), 0644)) + require.NoError(t, os.WriteFile(fileB, []byte("b=__reactenv.B"), 0644)) + + t.Setenv("A", "alpha") + t.Setenv("B", "beta") + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, renv.FindOccurrences()) + + renv.ReplaceOccurrences() + + updatedA, err := os.ReadFile(fileA) + require.NoError(t, err) + require.Equal(t, "a=alpha", string(updatedA)) + + updatedB, err := os.ReadFile(fileB) + require.NoError(t, err) + require.Equal(t, "b=beta", string(updatedB)) +} + +func FuzzReactenvReplaceOccurrences(f *testing.F) { + f.Add("") + f.Add("plain text") + f.Add("__reactenv.A") + f.Add("__reactenv.A__reactenv.B") + f.Add("x__reactenv._A1y__reactenv.$B2z") + f.Add("__reactenv.1BAD__reactenv.GOOD") + + f.Fuzz(func(t *testing.T, content string) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "app.js") + require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) + + for _, key := range keysFromContent(content) { + t.Setenv(key, "value-"+key) + } + + renv := NewReactenv(nil) + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`)) + require.NoError(t, renv.FindOccurrences()) + + renv.ReplaceOccurrences() + + updated, err := os.ReadFile(filePath) + require.NoError(t, err) + + expected := applyExpectedReplacements(content, renv.OccurrencesByFile, renv.OccurrenceKeysReplacement) + require.Equal(t, expected, string(updated)) + + remaining := FindAllOccurrenceBytePositions(updated, []byte(REACTENV_PREFIX+".")) + require.Empty(t, remaining) + }) +} + +func occurrencesFromTokens(t *testing.T, content string, tokens []string) []Occurrence { + t.Helper() + + occurrences := make([]Occurrence, 0, len(tokens)) + searchStart := 0 + + for _, token := range tokens { + index := strings.Index(content[searchStart:], token) + require.NotEqual(t, -1, index, "token %s not found", token) + + start := searchStart + index + end := start + len(token) + key := strings.TrimPrefix(token, "__reactenv.") + + occurrences = append(occurrences, Occurrence{ + Key: key, + StartEnd: []int{start, end}, + }) + + searchStart = end + } + + return occurrences +} + +func applyExpectedReplacements(content string, occurrencesByFile []*FileOccurrences, replacements OccurrenceKeysReplacement) string { + if len(occurrencesByFile) == 0 { + return content + } + + occurrences := occurrencesByFile[0].Occurrences + if len(occurrences) == 0 { + return content + } + + contentBytes := []byte(content) + result := make([]byte, 0, len(contentBytes)) + lastIndex := 0 + for _, occurrence := range occurrences { + start, end := occurrence.StartEnd[0], occurrence.StartEnd[1] + result = append(result, contentBytes[lastIndex:start]...) + result = append(result, replacements[occurrence.Key]...) + lastIndex = end + } + result = append(result, contentBytes[lastIndex:]...) + return string(result) +} + +func keysFromContent(content string) []string { + positions := FindAllOccurrenceBytePositions([]byte(content), []byte(REACTENV_PREFIX+".")) + keys := make([]string, 0, len(positions)) + for _, pos := range positions { + token := content[pos[0]:pos[1]] + key := strings.TrimPrefix(token, REACTENV_PREFIX+".") + keys = append(keys, key) + } + return keys +} diff --git a/version/version_base.go b/version/version_base.go index d31a895..d8b97fd 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -9,7 +9,7 @@ var ( // The compilation date. This will be filled in by the compiler. BuildDate string - Version = "0.1.81" + Version = "0.1.88" VersionPrerelease = "" VersionMetadata = "" )