From 055d61e505662898ad4508b179987d074a30d166 Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Tue, 22 Sep 2026 13:15:57 +0200 Subject: [PATCH 1/7] Support isolated RSpec and Minitest testdrives --- .../testdrive/frameworks_integration_test.go | 2 + internal/testdrive/multiframework_test.go | 7 +- internal/testdrive/tracer/ruby.go | 88 +++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 internal/testdrive/tracer/ruby.go diff --git a/internal/testdrive/frameworks_integration_test.go b/internal/testdrive/frameworks_integration_test.go index bedaf9f3..05b0f6f8 100644 --- a/internal/testdrive/frameworks_integration_test.go +++ b/internal/testdrive/frameworks_integration_test.go @@ -39,6 +39,8 @@ func TestPublicFrameworkTestdrives(t *testing.T) { {"cucumber", `{"scripts":{"test":"cucumber-js"},"devDependencies":{"@cucumber/cucumber":"12.2.0"}}`, "npm test", map[string]string{"features/one.feature": "Feature: Arithmetic\n Scenario: Add\n Given addition works\n", "features/step_definitions/one.js": `const {Given} = require('@cucumber/cucumber'); Given('addition works', () => require('node:assert').equal(1+1,2));`}}, {"cypress", `{"scripts":{"test":"cypress run"},"devDependencies":{"cypress":"15.1.0"}}`, "npm test", map[string]string{"cypress.config.js": `module.exports={e2e:{supportFile:false,setupNodeEvents(on,config){on('task',{answer:()=>42});on('after:run',()=>{require('node:fs').writeFileSync('original-hook.txt','ran');});return config;}}};`, "cypress/e2e/one.cy.js": `it('preserves hooks', () => { cy.task('answer').should('equal',42); });`}}, {"pytest", "", "python -m pytest", map[string]string{"requirements.txt": "pytest==8.4.2\n", "pytest.ini": "[pytest]\n", "test_one.py": "def test_adds():\n assert 1 + 1 == 2\n"}}, + {"rspec", "", "bundle exec rspec", map[string]string{"Gemfile": "source 'https://rubygems.org'\ngem 'rspec', '3.13.1'\n", "spec/one_spec.rb": "RSpec.describe 'addition' do\n it('adds') { expect(1 + 1).to eq(2) }\nend\n"}}, + {"minitest", "", "bundle exec rake test", map[string]string{"Gemfile": "source 'https://rubygems.org'\ngem 'rake', '13.3.0'\ngem 'minitest', '5.25.5'\n", "Rakefile": "require 'rake/testtask'\nRake::TestTask.new(:test) { |t| t.pattern = 'test/**/*_test.rb' }\n", "test/one_test.rb": "require 'minitest/autorun'\nclass AdditionTest < Minitest::Test\n def test_adds\n assert_equal 2, 1 + 1\n end\nend\n"}}, } for _, fixture := range fixtures { t.Run(fixture.name, func(t *testing.T) { diff --git a/internal/testdrive/multiframework_test.go b/internal/testdrive/multiframework_test.go index 9e484f32..642de476 100644 --- a/internal/testdrive/multiframework_test.go +++ b/internal/testdrive/multiframework_test.go @@ -10,6 +10,7 @@ import ( "github.com/DataDog/ddtest/internal/settings" "os" "path/filepath" + "strings" "testing" "github.com/DataDog/ddtest/internal/testdrive/intake" @@ -17,7 +18,7 @@ import ( ) func TestPrepareAllSupportedFrameworks(t *testing.T) { - for _, name := range []string{"jest", "mocha", "vitest", "playwright", "cucumber", "cypress", "pytest"} { + for _, name := range []string{"jest", "mocha", "vitest", "playwright", "cucumber", "cypress", "pytest", "rspec", "minitest"} { t.Run(name, func(t *testing.T) { root := t.TempDir() switch name { @@ -125,7 +126,9 @@ func TestLanguageEnvironmentsPreserveCustomerOptions(t *testing.T) { python := (&Testdrive{language: "python"}).environment("/session/python", "http://127.0.0.1:1234", "session") require.Equal(t, "/session/python"+string(os.PathListSeparator)+"/customer/modules", python["PYTHONPATH"]) require.Equal(t, "-q --ddtrace", python["PYTEST_ADDOPTS"]) - + ruby := (&Testdrive{language: "ruby"}).environment("/session/Gemfile", "http://127.0.0.1:1234", "session") + require.True(t, strings.HasPrefix(ruby["RUBYOPT"], "-W0 ")) + require.Equal(t, "/session/gems", ruby["BUNDLE_PATH"]) } func TestCypressWrapperUsesExplicitConfigWithoutEditingIt(t *testing.T) { diff --git a/internal/testdrive/tracer/ruby.go b/internal/testdrive/tracer/ruby.go new file mode 100644 index 00000000..f199d812 --- /dev/null +++ b/internal/testdrive/tracer/ruby.go @@ -0,0 +1,88 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026 Datadog, Inc. + +package tracer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/DataDog/ddtest/internal/ext" +) + +const RubyVersion = "1.39.0" + +// Ruby resolves an overlay bundle inside the session. Bundler sees the project's +// original Gemfile (including relative gemspecs), but never writes its lockfile. +type Ruby struct { + root string + executor commandExecutor +} + +func NewRuby(root string) *Ruby { return &Ruby{root: root, executor: &ext.DefaultCommandExecutor{}} } + +func (r *Ruby) Install(ctx context.Context, directory string) (string, error) { + if strings.Contains(directory, " ") { + return "", fmt.Errorf("the pinned Ruby tracer's native extensions cannot build in paths containing spaces; run testdrive from a checkout without spaces") + } + gemfile := filepath.Join(directory, "Gemfile") + path := strings.ReplaceAll(strings.ReplaceAll(filepath.Join(r.root, "Gemfile"), `\`, `\\`), "'", `\'`) + contents := "source 'https://rubygems.org'\neval_gemfile '" + path + "'\n" + + "dependencies.reject! { |dependency| dependency.name == 'datadog-ci' }\n" + + "gem 'datadog-ci', '" + RubyVersion + "'\n" + if err := os.WriteFile(gemfile, []byte(contents), 0600); err != nil { + return "", fmt.Errorf("write isolated Gemfile: %w", err) + } + if err := copyRubyLockfile(r.root, directory); err != nil { + return "", err + } + env := RubyEnvironment(gemfile) + if output, err := r.executor.CombinedOutput(ctx, "bundle", []string{"install"}, env); err != nil { + return "", commandError("install isolated Ruby bundle", output, err) + } + return gemfile, nil +} + +// Preserve the customer's resolved versions while adding the tracer. PATH +// sources in a lockfile are relative to its Gemfile, so relocate those sources +// when copying it into the session. The original remains untouched. +func copyRubyLockfile(root, directory string) error { + contents, err := os.ReadFile(filepath.Join(root, "Gemfile.lock")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read project lockfile: %w", err) + } + lines := strings.Split(string(contents), "\n") + inPath := false + for i, line := range lines { + if line != "" && !strings.HasPrefix(line, " ") { + inPath = line == "PATH" + } + if inPath && strings.HasPrefix(line, " remote: ") { + path := strings.TrimPrefix(line, " remote: ") + if !filepath.IsAbs(path) { + lines[i] = " remote: " + filepath.Join(root, path) + } + } + } + if err := os.WriteFile(filepath.Join(directory, "Gemfile.lock"), []byte(strings.Join(lines, "\n")), 0600); err != nil { + return fmt.Errorf("copy project lockfile: %w", err) + } + return nil +} + +func RubyEnvironment(gemfile string) map[string]string { + return map[string]string{ + "BUNDLE_GEMFILE": gemfile, + "BUNDLE_PATH": filepath.Join(filepath.Dir(gemfile), "gems"), + "BUNDLE_APP_CONFIG": filepath.Join(filepath.Dir(gemfile), "bundle-config"), + "BUNDLE_FROZEN": "false", "BUNDLE_DEPLOYMENT": "false", + } +} From 198170b6716db262c9a704354c2cecfe627eb562 Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Thu, 24 Sep 2026 09:15:57 +0200 Subject: [PATCH 2/7] Preserve isolated Ruby project settings --- internal/testdrive/command_test.go | 11 +++++++++++ internal/testdrive/tracer/ruby.go | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/internal/testdrive/command_test.go b/internal/testdrive/command_test.go index 7ff9f3c7..26bdc1de 100644 --- a/internal/testdrive/command_test.go +++ b/internal/testdrive/command_test.go @@ -2,6 +2,7 @@ package testdrive import ( "os" + "path/filepath" "testing" "github.com/DataDog/ddtest/internal/framework" @@ -46,3 +47,13 @@ func TestTestdrivePreservesExplicitCommandArguments(t *testing.T) { require.Equal(t, "npm", command) require.Equal(t, []string{"run", "smoke", "--", "--config", "config with spaces.js"}, args) } + +func TestTestdriveMinitestRequiresExecutableRailsBinstub(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "bin", "rails"), []byte("#!/bin/sh\n"), 0644)) + command, args, err := TestdriveCommand(root, NewMinitest()) + require.NoError(t, err) + require.Equal(t, "bundle", command) + require.Equal(t, []string{"exec", "rake", "test"}, args) +} diff --git a/internal/testdrive/tracer/ruby.go b/internal/testdrive/tracer/ruby.go index f199d812..6e4659ab 100644 --- a/internal/testdrive/tracer/ruby.go +++ b/internal/testdrive/tracer/ruby.go @@ -41,6 +41,9 @@ func (r *Ruby) Install(ctx context.Context, directory string) (string, error) { if err := copyRubyLockfile(r.root, directory); err != nil { return "", err } + if err := copyRubyBundleConfig(r.root, directory); err != nil { + return "", err + } env := RubyEnvironment(gemfile) if output, err := r.executor.CombinedOutput(ctx, "bundle", []string{"install"}, env); err != nil { return "", commandError("install isolated Ruby bundle", output, err) @@ -48,6 +51,24 @@ func (r *Ruby) Install(ctx context.Context, directory string) (string, error) { return gemfile, nil } +func copyRubyBundleConfig(root, directory string) error { + contents, err := os.ReadFile(filepath.Join(root, ".bundle", "config")) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read project Bundler config: %w", err) + } + configDirectory := filepath.Join(directory, "bundle-config") + if err := os.MkdirAll(configDirectory, 0700); err != nil { + return fmt.Errorf("create isolated Bundler config: %w", err) + } + if err := os.WriteFile(filepath.Join(configDirectory, "config"), contents, 0600); err != nil { + return fmt.Errorf("copy project Bundler config: %w", err) + } + return nil +} + // Preserve the customer's resolved versions while adding the tracer. PATH // sources in a lockfile are relative to its Gemfile, so relocate those sources // when copying it into the session. The original remains untouched. @@ -84,5 +105,6 @@ func RubyEnvironment(gemfile string) map[string]string { "BUNDLE_PATH": filepath.Join(filepath.Dir(gemfile), "gems"), "BUNDLE_APP_CONFIG": filepath.Join(filepath.Dir(gemfile), "bundle-config"), "BUNDLE_FROZEN": "false", "BUNDLE_DEPLOYMENT": "false", + "BUNDLE_WITH": "", "BUNDLE_WITHOUT": "", "BUNDLE_ONLY": "", } } From b34588ccda69cebc9b99977c0d19e81c0f1cd153 Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Thu, 24 Sep 2026 13:38:30 +0200 Subject: [PATCH 3/7] Select latest Ruby tracer or a release and Git revision --- internal/testdrive/tracer/ruby.go | 32 +++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/internal/testdrive/tracer/ruby.go b/internal/testdrive/tracer/ruby.go index 6e4659ab..f6082e72 100644 --- a/internal/testdrive/tracer/ruby.go +++ b/internal/testdrive/tracer/ruby.go @@ -15,26 +15,37 @@ import ( "github.com/DataDog/ddtest/internal/ext" ) -const RubyVersion = "1.39.0" - // Ruby resolves an overlay bundle inside the session. Bundler sees the project's // original Gemfile (including relative gemspecs), but never writes its lockfile. type Ruby struct { root string + version string executor commandExecutor } -func NewRuby(root string) *Ruby { return &Ruby{root: root, executor: &ext.DefaultCommandExecutor{}} } +func NewRuby(root, version string) *Ruby { + return &Ruby{root: root, version: version, executor: &ext.DefaultCommandExecutor{}} +} func (r *Ruby) Install(ctx context.Context, directory string) (string, error) { if strings.Contains(directory, " ") { - return "", fmt.Errorf("the pinned Ruby tracer's native extensions cannot build in paths containing spaces; run testdrive from a checkout without spaces") + return "", fmt.Errorf("the Ruby tracer's native extensions cannot build in paths containing spaces; run testdrive from a checkout without spaces") + } + selection := "" + version := strings.NewReplacer(`\`, `\\`, "'", `\'`).Replace(r.version) + if ref, ok := strings.CutPrefix(version, "git:"); ok { + if ref == "" { + return "", fmt.Errorf("tracer git ref must not be empty") + } + selection = ", git: 'https://github.com/DataDog/datadog-ci-rb.git', ref: '" + ref + "'" + } else if version != "" && version != "latest" { + selection = ", '" + version + "'" } gemfile := filepath.Join(directory, "Gemfile") path := strings.ReplaceAll(strings.ReplaceAll(filepath.Join(r.root, "Gemfile"), `\`, `\\`), "'", `\'`) contents := "source 'https://rubygems.org'\neval_gemfile '" + path + "'\n" + "dependencies.reject! { |dependency| dependency.name == 'datadog-ci' }\n" + - "gem 'datadog-ci', '" + RubyVersion + "'\n" + "gem 'datadog-ci'" + selection + "\n" if err := os.WriteFile(gemfile, []byte(contents), 0600); err != nil { return "", fmt.Errorf("write isolated Gemfile: %w", err) } @@ -44,8 +55,17 @@ func (r *Ruby) Install(ctx context.Context, directory string) (string, error) { if err := copyRubyBundleConfig(r.root, directory); err != nil { return "", err } + args := []string{"install"} + lock, err := os.ReadFile(filepath.Join(directory, "Gemfile.lock")) + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("read isolated lockfile: %w", err) + } + // Refresh a copied tracer pin while preserving other locked dependencies. + if strings.Contains(string(lock), "\n datadog-ci (") { + args = []string{"update", "datadog-ci", "--conservative"} + } env := RubyEnvironment(gemfile) - if output, err := r.executor.CombinedOutput(ctx, "bundle", []string{"install"}, env); err != nil { + if output, err := r.executor.CombinedOutput(ctx, "bundle", args, env); err != nil { return "", commandError("install isolated Ruby bundle", output, err) } return gemfile, nil From 09297ac8c2d643a5fced9b4ba070b03ee4dc52cc Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Thu, 24 Sep 2026 14:09:11 +0200 Subject: [PATCH 4/7] Reuse the project Ruby tracer without replacing its bundle --- internal/testdrive/multiframework_test.go | 13 ++++++ internal/testdrive/tracer/ruby.go | 50 ++++++++++++----------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/internal/testdrive/multiframework_test.go b/internal/testdrive/multiframework_test.go index 642de476..d4a47bd0 100644 --- a/internal/testdrive/multiframework_test.go +++ b/internal/testdrive/multiframework_test.go @@ -190,3 +190,16 @@ func TestPythonProjectTracerPreservesImportEnvironment(t *testing.T) { t.Fatal(env) } } + +func TestRubyProjectTracerPreservesBundleEnvironment(t *testing.T) { + drive := &Testdrive{language: "ruby"} + env := drive.environment("", "http://127.0.0.1:1234", "session") + for _, key := range []string{"BUNDLE_GEMFILE", "BUNDLE_PATH", "BUNDLE_APP_CONFIG", "BUNDLE_FROZEN", "BUNDLE_WITHOUT"} { + if _, changed := env[key]; changed { + t.Fatal("project Bundler setting overridden", key) + } + } + if !strings.Contains(env["RUBYOPT"], "-rdatadog/ci/auto_instrument") { + t.Fatal(env) + } +} diff --git a/internal/testdrive/tracer/ruby.go b/internal/testdrive/tracer/ruby.go index f6082e72..124e422e 100644 --- a/internal/testdrive/tracer/ruby.go +++ b/internal/testdrive/tracer/ruby.go @@ -13,10 +13,10 @@ import ( "strings" "github.com/DataDog/ddtest/internal/ext" + "github.com/DataDog/ddtest/internal/platform" ) -// Ruby resolves an overlay bundle inside the session. Bundler sees the project's -// original Gemfile (including relative gemspecs), but never writes its lockfile. +// Ruby reuses the project tracer, adding an isolated overlay bundle only when absent. type Ruby struct { root string version string @@ -27,48 +27,49 @@ func NewRuby(root, version string) *Ruby { return &Ruby{root: root, version: version, executor: &ext.DefaultCommandExecutor{}} } -func (r *Ruby) Install(ctx context.Context, directory string) (string, error) { - if strings.Contains(directory, " ") { - return "", fmt.Errorf("the Ruby tracer's native extensions cannot build in paths containing spaces; run testdrive from a checkout without spaces") - } +func (r *Ruby) Install(ctx context.Context, directory string) (Installation, error) { selection := "" version := strings.NewReplacer(`\`, `\\`, "'", `\'`).Replace(r.version) if ref, ok := strings.CutPrefix(version, "git:"); ok { if ref == "" { - return "", fmt.Errorf("tracer git ref must not be empty") + return Installation{}, fmt.Errorf("tracer git ref must not be empty") } selection = ", git: 'https://github.com/DataDog/datadog-ci-rb.git', ref: '" + ref + "'" } else if version != "" && version != "latest" { selection = ", '" + version + "'" } + projectGemfile := os.Getenv("BUNDLE_GEMFILE") + if projectGemfile == "" { + projectGemfile = filepath.Join(r.root, "Gemfile") + } + project, err := platform.DetectRubyTracer(ctx, r.executor, map[string]string{"BUNDLE_GEMFILE": projectGemfile, "RUBYOPT": ""}) + if err != nil { + return Installation{}, err + } + if project != "" { + return Installation{Project: true}, nil + } + if strings.Contains(directory, " ") { + return Installation{}, fmt.Errorf("the Ruby tracer's native extensions cannot build in paths containing spaces; run testdrive from a checkout without spaces") + } gemfile := filepath.Join(directory, "Gemfile") path := strings.ReplaceAll(strings.ReplaceAll(filepath.Join(r.root, "Gemfile"), `\`, `\\`), "'", `\'`) contents := "source 'https://rubygems.org'\neval_gemfile '" + path + "'\n" + - "dependencies.reject! { |dependency| dependency.name == 'datadog-ci' }\n" + "gem 'datadog-ci'" + selection + "\n" if err := os.WriteFile(gemfile, []byte(contents), 0600); err != nil { - return "", fmt.Errorf("write isolated Gemfile: %w", err) + return Installation{}, fmt.Errorf("write isolated Gemfile: %w", err) } if err := copyRubyLockfile(r.root, directory); err != nil { - return "", err + return Installation{}, err } if err := copyRubyBundleConfig(r.root, directory); err != nil { - return "", err - } - args := []string{"install"} - lock, err := os.ReadFile(filepath.Join(directory, "Gemfile.lock")) - if err != nil && !os.IsNotExist(err) { - return "", fmt.Errorf("read isolated lockfile: %w", err) - } - // Refresh a copied tracer pin while preserving other locked dependencies. - if strings.Contains(string(lock), "\n datadog-ci (") { - args = []string{"update", "datadog-ci", "--conservative"} + return Installation{}, err } env := RubyEnvironment(gemfile) - if output, err := r.executor.CombinedOutput(ctx, "bundle", args, env); err != nil { - return "", commandError("install isolated Ruby bundle", output, err) + if output, err := r.executor.CombinedOutput(ctx, "bundle", []string{"install"}, env); err != nil { + return Installation{}, commandError("install isolated Ruby bundle", output, err) } - return gemfile, nil + return Installation{Path: gemfile}, nil } func copyRubyBundleConfig(root, directory string) error { @@ -120,6 +121,9 @@ func copyRubyLockfile(root, directory string) error { } func RubyEnvironment(gemfile string) map[string]string { + if gemfile == "" { + return nil + } return map[string]string{ "BUNDLE_GEMFILE": gemfile, "BUNDLE_PATH": filepath.Join(filepath.Dir(gemfile), "gems"), From a6464535564adff8a1ff3af3190a3fa841162754 Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Thu, 24 Sep 2026 15:31:19 +0200 Subject: [PATCH 5/7] Use Platform-owned tracer preparation in 145 --- internal/testdrive/multiframework_test.go | 2 +- internal/testdrive/tracer/ruby.go | 134 ---------------------- 2 files changed, 1 insertion(+), 135 deletions(-) delete mode 100644 internal/testdrive/tracer/ruby.go diff --git a/internal/testdrive/multiframework_test.go b/internal/testdrive/multiframework_test.go index d4a47bd0..602ccb43 100644 --- a/internal/testdrive/multiframework_test.go +++ b/internal/testdrive/multiframework_test.go @@ -128,7 +128,7 @@ func TestLanguageEnvironmentsPreserveCustomerOptions(t *testing.T) { require.Equal(t, "-q --ddtrace", python["PYTEST_ADDOPTS"]) ruby := (&Testdrive{language: "ruby"}).environment("/session/Gemfile", "http://127.0.0.1:1234", "session") require.True(t, strings.HasPrefix(ruby["RUBYOPT"], "-W0 ")) - require.Equal(t, "/session/gems", ruby["BUNDLE_PATH"]) + require.NotContains(t, ruby, "BUNDLE_PATH") // Supplied by the platform installation result. } func TestCypressWrapperUsesExplicitConfigWithoutEditingIt(t *testing.T) { diff --git a/internal/testdrive/tracer/ruby.go b/internal/testdrive/tracer/ruby.go deleted file mode 100644 index 124e422e..00000000 --- a/internal/testdrive/tracer/ruby.go +++ /dev/null @@ -1,134 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed -// under the Apache License Version 2.0. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2026 Datadog, Inc. - -package tracer - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/DataDog/ddtest/internal/ext" - "github.com/DataDog/ddtest/internal/platform" -) - -// Ruby reuses the project tracer, adding an isolated overlay bundle only when absent. -type Ruby struct { - root string - version string - executor commandExecutor -} - -func NewRuby(root, version string) *Ruby { - return &Ruby{root: root, version: version, executor: &ext.DefaultCommandExecutor{}} -} - -func (r *Ruby) Install(ctx context.Context, directory string) (Installation, error) { - selection := "" - version := strings.NewReplacer(`\`, `\\`, "'", `\'`).Replace(r.version) - if ref, ok := strings.CutPrefix(version, "git:"); ok { - if ref == "" { - return Installation{}, fmt.Errorf("tracer git ref must not be empty") - } - selection = ", git: 'https://github.com/DataDog/datadog-ci-rb.git', ref: '" + ref + "'" - } else if version != "" && version != "latest" { - selection = ", '" + version + "'" - } - projectGemfile := os.Getenv("BUNDLE_GEMFILE") - if projectGemfile == "" { - projectGemfile = filepath.Join(r.root, "Gemfile") - } - project, err := platform.DetectRubyTracer(ctx, r.executor, map[string]string{"BUNDLE_GEMFILE": projectGemfile, "RUBYOPT": ""}) - if err != nil { - return Installation{}, err - } - if project != "" { - return Installation{Project: true}, nil - } - if strings.Contains(directory, " ") { - return Installation{}, fmt.Errorf("the Ruby tracer's native extensions cannot build in paths containing spaces; run testdrive from a checkout without spaces") - } - gemfile := filepath.Join(directory, "Gemfile") - path := strings.ReplaceAll(strings.ReplaceAll(filepath.Join(r.root, "Gemfile"), `\`, `\\`), "'", `\'`) - contents := "source 'https://rubygems.org'\neval_gemfile '" + path + "'\n" + - "gem 'datadog-ci'" + selection + "\n" - if err := os.WriteFile(gemfile, []byte(contents), 0600); err != nil { - return Installation{}, fmt.Errorf("write isolated Gemfile: %w", err) - } - if err := copyRubyLockfile(r.root, directory); err != nil { - return Installation{}, err - } - if err := copyRubyBundleConfig(r.root, directory); err != nil { - return Installation{}, err - } - env := RubyEnvironment(gemfile) - if output, err := r.executor.CombinedOutput(ctx, "bundle", []string{"install"}, env); err != nil { - return Installation{}, commandError("install isolated Ruby bundle", output, err) - } - return Installation{Path: gemfile}, nil -} - -func copyRubyBundleConfig(root, directory string) error { - contents, err := os.ReadFile(filepath.Join(root, ".bundle", "config")) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("read project Bundler config: %w", err) - } - configDirectory := filepath.Join(directory, "bundle-config") - if err := os.MkdirAll(configDirectory, 0700); err != nil { - return fmt.Errorf("create isolated Bundler config: %w", err) - } - if err := os.WriteFile(filepath.Join(configDirectory, "config"), contents, 0600); err != nil { - return fmt.Errorf("copy project Bundler config: %w", err) - } - return nil -} - -// Preserve the customer's resolved versions while adding the tracer. PATH -// sources in a lockfile are relative to its Gemfile, so relocate those sources -// when copying it into the session. The original remains untouched. -func copyRubyLockfile(root, directory string) error { - contents, err := os.ReadFile(filepath.Join(root, "Gemfile.lock")) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("read project lockfile: %w", err) - } - lines := strings.Split(string(contents), "\n") - inPath := false - for i, line := range lines { - if line != "" && !strings.HasPrefix(line, " ") { - inPath = line == "PATH" - } - if inPath && strings.HasPrefix(line, " remote: ") { - path := strings.TrimPrefix(line, " remote: ") - if !filepath.IsAbs(path) { - lines[i] = " remote: " + filepath.Join(root, path) - } - } - } - if err := os.WriteFile(filepath.Join(directory, "Gemfile.lock"), []byte(strings.Join(lines, "\n")), 0600); err != nil { - return fmt.Errorf("copy project lockfile: %w", err) - } - return nil -} - -func RubyEnvironment(gemfile string) map[string]string { - if gemfile == "" { - return nil - } - return map[string]string{ - "BUNDLE_GEMFILE": gemfile, - "BUNDLE_PATH": filepath.Join(filepath.Dir(gemfile), "gems"), - "BUNDLE_APP_CONFIG": filepath.Join(filepath.Dir(gemfile), "bundle-config"), - "BUNDLE_FROZEN": "false", "BUNDLE_DEPLOYMENT": "false", - "BUNDLE_WITH": "", "BUNDLE_WITHOUT": "", "BUNDLE_ONLY": "", - } -} From bccbe6dd0acaabb10d194d8165ff8f8b0caada1d Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Thu, 24 Sep 2026 16:54:13 +0200 Subject: [PATCH 6/7] Avoid claiming an overridden Ruby tracer selection in reports --- internal/testdrive/testdrive.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/testdrive/testdrive.go b/internal/testdrive/testdrive.go index 14583496..b88d2856 100644 --- a/internal/testdrive/testdrive.go +++ b/internal/testdrive/testdrive.go @@ -190,6 +190,10 @@ func (t *Testdrive) Run(ctx context.Context, output io.Writer) (runErr error) { } tracerLabel := t.tracerLabel + if t.language == "ruby" { + // The project Gemfile may already pin the tracer selected by Bundler. + tracerLabel = "datadog-ci · isolated bundle" + } if installation.Project { tracerLabel = t.installedTracerLabel(t.projectTracer) + " · reused" } From eb1d0e4bb6595e9b5dff2cf9efa609b4dd1295ad Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Thu, 24 Sep 2026 17:01:51 +0200 Subject: [PATCH 7/7] Use the project Ruby bundle for testdrive --- internal/testdrive/command_test.go | 4 ++-- internal/testdrive/multiframework_test.go | 6 ++++-- internal/testdrive/ruby.go | 12 ++++++++++++ internal/testdrive/testdrive.go | 15 ++++++++------- 4 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 internal/testdrive/ruby.go diff --git a/internal/testdrive/command_test.go b/internal/testdrive/command_test.go index 26bdc1de..63de64be 100644 --- a/internal/testdrive/command_test.go +++ b/internal/testdrive/command_test.go @@ -50,10 +50,10 @@ func TestTestdrivePreservesExplicitCommandArguments(t *testing.T) { func TestTestdriveMinitestRequiresExecutableRailsBinstub(t *testing.T) { root := t.TempDir() + t.Chdir(root) require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(root, "bin", "rails"), []byte("#!/bin/sh\n"), 0644)) - command, args, err := TestdriveCommand(root, NewMinitest()) - require.NoError(t, err) + command, args := framework.NewMinitest().Command() require.Equal(t, "bundle", command) require.Equal(t, []string{"exec", "rake", "test"}, args) } diff --git a/internal/testdrive/multiframework_test.go b/internal/testdrive/multiframework_test.go index 602ccb43..041c6091 100644 --- a/internal/testdrive/multiframework_test.go +++ b/internal/testdrive/multiframework_test.go @@ -44,6 +44,7 @@ func TestPrepareAllSupportedFrameworks(t *testing.T) { installer := &fakeTracer{preloadPath: filepath.Join(root, "isolated")} run.platform = installer run.projectTracer = "" + run.projectTracer = "" executor := &fakeTestdriveExecutor{} run.executor = executor run.startIntake = func(string) (localIntake, error) { @@ -66,7 +67,8 @@ func TestPrepareAllSupportedFrameworks(t *testing.T) { require.NotContains(t, executor.env, "NODE_OPTIONS") case "ruby": require.NotContains(t, executor.env, "BUNDLE_GEMFILE") - require.Contains(t, preview.String(), "Bundler updates the project Gemfile and lockfile") + require.Contains(t, preview.String(), "Bundler updates Gemfile and Gemfile.lock.") + require.NotContains(t, preview.String(), "It will not change") require.Contains(t, output.String(), "datadog-ci · installed in project") require.Contains(t, executor.env["RUBYOPT"], "datadog/ci/auto_instrument") require.NotContains(t, executor.env, "NODE_OPTIONS") @@ -128,7 +130,7 @@ func TestLanguageEnvironmentsPreserveCustomerOptions(t *testing.T) { require.Equal(t, "-q --ddtrace", python["PYTEST_ADDOPTS"]) ruby := (&Testdrive{language: "ruby"}).environment("/session/Gemfile", "http://127.0.0.1:1234", "session") require.True(t, strings.HasPrefix(ruby["RUBYOPT"], "-W0 ")) - require.NotContains(t, ruby, "BUNDLE_PATH") // Supplied by the platform installation result. + require.NotContains(t, ruby, "BUNDLE_PATH") // Inherit project Bundler configuration. } func TestCypressWrapperUsesExplicitConfigWithoutEditingIt(t *testing.T) { diff --git a/internal/testdrive/ruby.go b/internal/testdrive/ruby.go new file mode 100644 index 00000000..b2deb334 --- /dev/null +++ b/internal/testdrive/ruby.go @@ -0,0 +1,12 @@ +package testdrive + +import ( + "os" + "strings" +) + +func rubyEnvironment(path string) map[string]string { + env := map[string]string{} + env["RUBYOPT"] = strings.TrimSpace(os.Getenv("RUBYOPT") + " -rbundler/setup -rdatadog/ci/auto_instrument") + return env +} diff --git a/internal/testdrive/testdrive.go b/internal/testdrive/testdrive.go index b88d2856..aab0773f 100644 --- a/internal/testdrive/testdrive.go +++ b/internal/testdrive/testdrive.go @@ -79,11 +79,6 @@ func Prepare(version string) (*Testdrive, error) { return nil, err } language := detectedPlatform.Name() - switch runner.Name() { - case "jest", "mocha", "vitest", "playwright", "cucumber", "cypress", "pytest": - default: - return nil, fmt.Errorf("testdrive does not yet support %s", runner.Name()) - } command, args := runner.Command() label := map[string]string{"javascript": "dd-trace", "python": "ddtrace", "ruby": "datadog-ci"}[language] + "@" + version @@ -140,6 +135,10 @@ func (t *Testdrive) Preview(output io.Writer) { } _, _ = fmt.Fprintf(output, " - run: %s\n", shellquote.Join(append([]string{command}, args...)...)) _, _ = fmt.Fprintln(output) + if t.language == "ruby" && t.projectTracer == "" { + _, _ = fmt.Fprintln(output, "Bundler updates Gemfile and Gemfile.lock.") + return + } patterns := map[string][]string{ "javascript": {"package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"}, "python": {"pyproject.toml", "setup.py", "setup.cfg", "requirements*.txt", "requirements*.in", "Pipfile", "Pipfile.lock", "poetry.lock", "uv.lock", "pdm.lock", "pylock.toml"}, @@ -191,8 +190,8 @@ func (t *Testdrive) Run(ctx context.Context, output io.Writer) (runErr error) { tracerLabel := t.tracerLabel if t.language == "ruby" { - // The project Gemfile may already pin the tracer selected by Bundler. - tracerLabel = "datadog-ci · isolated bundle" + // Bundler owns the project dependency selection. + tracerLabel = "datadog-ci · installed in project" } if installation.Project { tracerLabel = t.installedTracerLabel(t.projectTracer) + " · reused" @@ -430,6 +429,8 @@ func (t *Testdrive) environment(path, intakeURL, sessionID string) map[string]st maps.Copy(env, t.javascriptEnvironment(path)) case "python": maps.Copy(env, pythonEnvironment(path)) + case "ruby": + maps.Copy(env, rubyEnvironment(path)) } return env }