From ffb1ae27a5885906acb1debda1308d8059ab6dbd Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Thu, 11 Dec 2025 11:27:07 -0700 Subject: [PATCH 1/2] fix(jest): escape parentheses and brackets in test file paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jest interprets CLI arguments as regex patterns. This causes issues with Next.js App Router paths that use parentheses for route groups (e.g., `(main)`) and brackets for dynamic routes (e.g., `[catalogId]`). When paths like `src/app/(main)/test.spec.tsx` are passed to Jest, the `(main)` is interpreted as a regex capture group instead of a literal directory name, causing Jest to fail to match any test files. This fix escapes parentheses and brackets in test file paths before passing them to Jest, converting: - `src/app/(main)/test.tsx` -> `src/app/\(main\)/test.tsx` - `src/app/[id]/test.tsx` -> `src/app/\[id\]/test.tsx` Other regex metacharacters (like dots) are intentionally not escaped because they work fine in practice and escaping them breaks existing behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/runner/jest.go | 29 +++++++++++++++++++++++++-- internal/runner/jest_test.go | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/internal/runner/jest.go b/internal/runner/jest.go index 2533b7be..f7205eae 100644 --- a/internal/runner/jest.go +++ b/internal/runner/jest.go @@ -187,11 +187,23 @@ func (j Jest) commandNameAndArgs(cmd string, testCases []string) (string, []stri if err != nil { return "", []string{}, err } + + // Escape parentheses and brackets in test file paths since Jest + // interprets CLI arguments as regex patterns. These characters are + // commonly used in Next.js App Router paths (e.g., (main) for route + // groups, [id] for dynamic routes) and cause regex matching issues. + // We don't escape all regex metacharacters (like dots) because they + // work fine in practice and escaping them breaks existing behavior. + escapedTestCases := make([]string, len(testCases)) + for i, testCase := range testCases { + escapedTestCases[i] = escapeJestPathPattern(testCase) + } + idx := slices.Index(words, "{{testExamples}}") if idx < 0 { - words = append(words, testCases...) + words = append(words, escapedTestCases...) } else { - words = slices.Replace(words, idx, idx+1, testCases...) + words = slices.Replace(words, idx, idx+1, escapedTestCases...) } outputIdx := slices.Index(words, "{{resultPath}}") @@ -243,3 +255,16 @@ func (j Jest) retryCommandNameAndArgs(cmd string, testCases []string, testPaths func (j Jest) GetExamples(files []string) ([]plan.TestCase, error) { return nil, fmt.Errorf("not supported in Jest") } + +// escapeJestPathPattern escapes characters in file paths that cause issues +// when Jest interprets them as regex patterns. This specifically targets +// parentheses and brackets used in Next.js App Router conventions. +func escapeJestPathPattern(path string) string { + replacer := strings.NewReplacer( + "(", "\\(", + ")", "\\)", + "[", "\\[", + "]", "\\]", + ) + return replacer.Replace(path) +} diff --git a/internal/runner/jest_test.go b/internal/runner/jest_test.go index 63c296b8..713b2b67 100644 --- a/internal/runner/jest_test.go +++ b/internal/runner/jest_test.go @@ -359,6 +359,7 @@ func TestJestCommandNameAndArgs_WithInterpolationPlaceholder(t *testing.T) { } wantName := "jest" + // Paths with parentheses and brackets are escaped for regex since Jest interprets them as patterns wantArgs := []string{"spec/user.spec.js", "spec/billing.spec.js", "--outputFile", "jest.json"} if diff := cmp.Diff(gotName, wantName); diff != "" { @@ -384,6 +385,7 @@ func TestJestCommandNameAndArgs_WithoutInterpolationPlaceholder(t *testing.T) { } wantName := "jest" + // Paths with parentheses and brackets are escaped for regex since Jest interprets them as patterns wantArgs := []string{"--json", "--outputFile", "jest.json", "spec/user.spec.js", "spec/billing.spec.js"} if diff := cmp.Diff(gotName, wantName); diff != "" { @@ -418,6 +420,42 @@ func TestJestCommandNameAndArgs_InvalidTestCommand(t *testing.T) { } } +func TestJestCommandNameAndArgs_WithSpecialCharactersInPath(t *testing.T) { + // Test paths with special regex characters like parentheses (Next.js route groups) + // and square brackets (Next.js dynamic routes) + testCases := []string{ + "src/app/(main)/page.test.tsx", + "src/app/(main)/[catalogId]/product.test.tsx", + } + testCommand := "jest {{testExamples}} --outputFile {{resultPath}}" + + jest := NewJest(RunnerConfig{ + TestCommand: testCommand, + ResultPath: "jest.json", + }) + + gotName, gotArgs, err := jest.commandNameAndArgs(testCommand, testCases) + if err != nil { + t.Errorf("commandNameAndArgs(%q, %q) error = %v", testCases, testCommand, err) + } + + wantName := "jest" + // Parentheses and brackets should be escaped for regex (Next.js App Router conventions) + wantArgs := []string{ + `src/app/\(main\)/page.test.tsx`, + `src/app/\(main\)/\[catalogId\]/product.test.tsx`, + "--outputFile", + "jest.json", + } + + if diff := cmp.Diff(gotName, wantName); diff != "" { + t.Errorf("commandNameAndArgs(%q, %q) diff (-got +want):\n%s", testCases, testCommand, diff) + } + if diff := cmp.Diff(gotArgs, wantArgs); diff != "" { + t.Errorf("commandNameAndArgs(%q, %q) diff (-got +want):\n%s", testCases, testCommand, diff) + } +} + func TestJestRetryCommandNameAndArgs_HappyPath(t *testing.T) { testCases := []string{"this will fail", "this other one will fail"} testPaths := []string{"spec/user.spec.js", "spec/billing.spec.js"} From fe935e53ae2f484e5e8d4b6d2758d933092ee97b Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Thu, 19 Feb 2026 16:55:47 -0700 Subject: [PATCH 2/2] fix(jest): use --runTestsByPath instead of regex escaping Replace the escapeJestPathPattern approach with Jest's --runTestsByPath flag, which treats CLI arguments as literal file paths instead of regex patterns. This is a cleaner solution for Next.js App Router paths containing parentheses and brackets, and is recommended by Jest for CI pipelines due to better performance. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/jest.md | 2 +- internal/runner/jest.go | 30 +++--------------------------- internal/runner/jest_test.go | 15 +++++++-------- 3 files changed, 11 insertions(+), 36 deletions(-) diff --git a/docs/jest.md b/docs/jest.md index 9d1ec823..a59e8fb6 100644 --- a/docs/jest.md +++ b/docs/jest.md @@ -10,7 +10,7 @@ export BUILDKITE_TEST_ENGINE_RESULT_PATH=tmp/jest-result.json By default, bktec runs Jest with the following command: ```sh -npx jest {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}} +npx jest --runTestsByPath {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}} ``` In this command, `{{testExamples}}` is replaced by bktec with the list of test files or tests to run, and `{{resultPath}}` is replaced with the value set in `BUILDKITE_TEST_ENGINE_RESULT_PATH`. You can customize this command using the `BUILDKITE_TEST_ENGINE_TEST_CMD` environment variable. diff --git a/internal/runner/jest.go b/internal/runner/jest.go index f7205eae..5e6dc29f 100644 --- a/internal/runner/jest.go +++ b/internal/runner/jest.go @@ -22,7 +22,7 @@ type Jest struct { func NewJest(j RunnerConfig) Jest { if j.TestCommand == "" { - j.TestCommand = "npx jest {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}}" + j.TestCommand = "npx jest --runTestsByPath {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}}" } if j.TestFilePattern == "" { @@ -188,22 +188,11 @@ func (j Jest) commandNameAndArgs(cmd string, testCases []string) (string, []stri return "", []string{}, err } - // Escape parentheses and brackets in test file paths since Jest - // interprets CLI arguments as regex patterns. These characters are - // commonly used in Next.js App Router paths (e.g., (main) for route - // groups, [id] for dynamic routes) and cause regex matching issues. - // We don't escape all regex metacharacters (like dots) because they - // work fine in practice and escaping them breaks existing behavior. - escapedTestCases := make([]string, len(testCases)) - for i, testCase := range testCases { - escapedTestCases[i] = escapeJestPathPattern(testCase) - } - idx := slices.Index(words, "{{testExamples}}") if idx < 0 { - words = append(words, escapedTestCases...) + words = append(words, testCases...) } else { - words = slices.Replace(words, idx, idx+1, escapedTestCases...) + words = slices.Replace(words, idx, idx+1, testCases...) } outputIdx := slices.Index(words, "{{resultPath}}") @@ -255,16 +244,3 @@ func (j Jest) retryCommandNameAndArgs(cmd string, testCases []string, testPaths func (j Jest) GetExamples(files []string) ([]plan.TestCase, error) { return nil, fmt.Errorf("not supported in Jest") } - -// escapeJestPathPattern escapes characters in file paths that cause issues -// when Jest interprets them as regex patterns. This specifically targets -// parentheses and brackets used in Next.js App Router conventions. -func escapeJestPathPattern(path string) string { - replacer := strings.NewReplacer( - "(", "\\(", - ")", "\\)", - "[", "\\[", - "]", "\\]", - ) - return replacer.Replace(path) -} diff --git a/internal/runner/jest_test.go b/internal/runner/jest_test.go index 713b2b67..f91fcc32 100644 --- a/internal/runner/jest_test.go +++ b/internal/runner/jest_test.go @@ -22,7 +22,7 @@ func TestNewJest(t *testing.T) { { input: RunnerConfig{}, want: RunnerConfig{ - TestCommand: "npx jest {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}}", + TestCommand: "npx jest --runTestsByPath {{testExamples}} --json --testLocationInResults --outputFile {{resultPath}}", TestFilePattern: "**/{__tests__/**/*,*.spec,*.test}.{ts,js,tsx,jsx}", TestFileExcludePattern: "", RetryTestCommand: "npx jest --testNamePattern '{{testNamePattern}}' --json --testLocationInResults --outputFile {{resultPath}}", @@ -359,7 +359,6 @@ func TestJestCommandNameAndArgs_WithInterpolationPlaceholder(t *testing.T) { } wantName := "jest" - // Paths with parentheses and brackets are escaped for regex since Jest interprets them as patterns wantArgs := []string{"spec/user.spec.js", "spec/billing.spec.js", "--outputFile", "jest.json"} if diff := cmp.Diff(gotName, wantName); diff != "" { @@ -385,7 +384,6 @@ func TestJestCommandNameAndArgs_WithoutInterpolationPlaceholder(t *testing.T) { } wantName := "jest" - // Paths with parentheses and brackets are escaped for regex since Jest interprets them as patterns wantArgs := []string{"--json", "--outputFile", "jest.json", "spec/user.spec.js", "spec/billing.spec.js"} if diff := cmp.Diff(gotName, wantName); diff != "" { @@ -422,12 +420,13 @@ func TestJestCommandNameAndArgs_InvalidTestCommand(t *testing.T) { func TestJestCommandNameAndArgs_WithSpecialCharactersInPath(t *testing.T) { // Test paths with special regex characters like parentheses (Next.js route groups) - // and square brackets (Next.js dynamic routes) + // and square brackets (Next.js dynamic routes). These are passed as-is because + // the default TestCommand uses --runTestsByPath which treats args as literal paths. testCases := []string{ "src/app/(main)/page.test.tsx", "src/app/(main)/[catalogId]/product.test.tsx", } - testCommand := "jest {{testExamples}} --outputFile {{resultPath}}" + testCommand := "jest --runTestsByPath {{testExamples}} --outputFile {{resultPath}}" jest := NewJest(RunnerConfig{ TestCommand: testCommand, @@ -440,10 +439,10 @@ func TestJestCommandNameAndArgs_WithSpecialCharactersInPath(t *testing.T) { } wantName := "jest" - // Parentheses and brackets should be escaped for regex (Next.js App Router conventions) wantArgs := []string{ - `src/app/\(main\)/page.test.tsx`, - `src/app/\(main\)/\[catalogId\]/product.test.tsx`, + "--runTestsByPath", + "src/app/(main)/page.test.tsx", + "src/app/(main)/[catalogId]/product.test.tsx", "--outputFile", "jest.json", }