diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2319b91..169822a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,28 @@ jobs: format: go-coverprofile version: v5.19.0 + mocha-compatibility: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mocha: ["8.4.0", "9.2.2", "10.8.2", "11.7.6"] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5 + with: + go-version: "1.26.3" + + - name: Test Mocha adapter + run: | + mocha_dir="${RUNNER_TEMP}/mocha-${{ matrix.mocha }}" + npm install --prefix "${mocha_dir}" "mocha@${{ matrix.mocha }}" + DDTEST_MOCHA_NODE_MODULES="${mocha_dir}/node_modules" \ + go test -v ./internal/framework -run TestMochaAdapterIntegration + lint: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 69a61cb..9e1a7d5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Currently supported: - Ruby with RSpec or Minitest. - Python with pytest. -- JavaScript with Jest or Vitest. +- JavaScript with Jest, Mocha, or Vitest. ## Prerequisites @@ -23,8 +23,9 @@ Minimum supported library and runtime requirements: - Ruby requires the `datadog-ci` gem **1.31.0** or higher. - Python requires the `ddtrace` package **4.11.0** or higher and `pytest`. -- JavaScript requires the `dd-trace` package **5.111.0** or higher, Node.js, and - Jest or Vitest 1.6 or higher. +- JavaScript requires the `dd-trace` package **5.111.0** or higher and Node.js. + Mocha support requires Mocha 8 or higher; Vitest support requires Vitest 1.6 + or higher. For instructions on setting up Test Optimization, see the [Datadog Test Optimization documentation](https://docs.datadoghq.com/tests/setup/). @@ -89,6 +90,16 @@ ddtest plan \ --max-parallelism 32 ``` +For JavaScript/Mocha: + +```bash +ddtest plan \ + --platform javascript \ + --framework mocha \ + --min-parallelism 8 \ + --max-parallelism 32 +``` + This prepares the plan and writes it to `.testoptimization/` folder for later reuse. Copy `.testoptimization/` to any CI job that runs `ddtest run` or reads DDTest's plan file lists. For the full file layout and formats, see @@ -122,6 +133,12 @@ For JavaScript/Vitest: ddtest run --platform javascript --framework vitest ``` +For JavaScript/Mocha: + +```bash +ddtest run --platform javascript --framework mocha +``` + For CI-node mode, worker environment variables, custom commands, and parallelism details, see [Running DDTest](docs/running.md). @@ -130,8 +147,8 @@ parallelism details, see [Running DDTest](docs/running.md). | CLI flag | What it does | | --- | --- | | `--platform` | Language/platform. Currently supported: `ruby`, `python`, `javascript`. | -| `--framework` | Test framework. Currently supported: `rspec`, `minitest`, `pytest`, `jest`, `vitest`. | -| `--command` | Override the default base command for supported framework modes. Currently used by RSpec and Minitest run/discovery, and Jest and Vitest run/discovery. For pytest, use `PYTEST_ADDOPTS` for pytest flags. | +| `--framework` | Test framework. Currently supported: `rspec`, `minitest`, `pytest`, `jest`, `mocha`, `vitest`. | +| `--command` | Override the default base command for supported framework modes. Currently used by RSpec and Minitest run/discovery, and Jest, Mocha, and Vitest run/discovery. For pytest, use `PYTEST_ADDOPTS` for pytest flags. | | `--min-parallelism` | Minimum CI node or worker count DDTest considers when planning. | | `--max-parallelism` | Maximum CI node or worker count DDTest considers when planning. | | `--target-time` | Target wall time DDTest tries to satisfy when selecting parallelism. | diff --git a/docs/best_practices.md b/docs/best_practices.md index 79ddd33..9208436 100644 --- a/docs/best_practices.md +++ b/docs/best_practices.md @@ -179,6 +179,18 @@ DDTest passes the Vitest arguments to its config-aware discovery API. DDTest appends the selected test files during execution. Do not include test files or a `--` separator in the command. +## Mocha Support + +Use a command that invokes Mocha directly when passing framework flags: + +```bash +ddtest run --platform javascript --framework mocha --command "pnpm exec mocha --parallel" +``` + +Do not include test files or a `--` separator. DDTest reads Mocha's effective +configuration for discovery and replaces configured `spec` inputs with the +files assigned to each worker during execution. + ## Minitest Support In Non-Rails Projects We use `bundle exec rake test` command when we don't detect `rails` command to diff --git a/docs/layout.md b/docs/layout.md index 5ff664e..3987283 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -237,7 +237,7 @@ Fields: | --- | --- | | `name` | Test name reported by the framework. | | `suite` | Test suite name reported by the framework. | -| `module` | Framework module name, such as `rspec`, `minitest`, `pytest`, `jest`, or `vitest`. | +| `module` | Framework module name, such as `rspec`, `minitest`, `pytest`, `jest`, `mocha`, or `vitest`. | | `parameters` | Serialized test parameters. | | `suiteSourceFile` | Source file containing the suite. | diff --git a/docs/running.md b/docs/running.md index a84f3b6..ba2c6cc 100644 --- a/docs/running.md +++ b/docs/running.md @@ -38,6 +38,12 @@ For JavaScript/Vitest: ddtest run --platform javascript --framework vitest ``` +For JavaScript/Mocha: + +```bash +ddtest run --platform javascript --framework mocha +``` + On one CI node, the default `--min-parallelism` and `--max-parallelism` equal the available physical CPU core count, so DDTest can start one worker per physical core without defaulting to one worker per hyperthread. @@ -69,6 +75,12 @@ For JavaScript/Vitest: ddtest run --platform javascript --framework vitest --ci-node ``` +For JavaScript/Mocha: + +```bash +ddtest run --platform javascript --framework mocha --ci-node +``` + In CI-node mode, DDTest uses one local worker by default so database and other per-worker resources stay easy to isolate. To fan out within each CI node, set `--ci-node-workers` to a positive integer, or use `--ci-node-workers ncpu` to @@ -102,8 +114,8 @@ starting each worker. Use `--command` to override the framework's default base test command where supported. DDTest currently applies this override to RSpec run and full -discovery, Minitest run and full discovery, and Jest and Vitest run and file -discovery: +discovery, Minitest run and full discovery, and Jest, Mocha, and Vitest run and +file discovery: ```bash ddtest run --platform ruby --framework rspec --command "bundle exec rspec --profile" @@ -116,6 +128,14 @@ and `--runTestsByPath ` during execution: ddtest run --platform javascript --framework jest --command "pnpm jest --runInBand" ``` +For JavaScript/Mocha, the command must invoke Mocha directly. DDTest loads its +effective configuration, discovers files without loading test modules, and +replaces configured `spec` entries with each worker's assigned files: + +```bash +ddtest run --platform javascript --framework mocha --command "pnpm exec mocha --parallel" +``` + For JavaScript/Vitest, the command must invoke Vitest directly. During planning, DDTest uses `list --filesOnly --json` on Vitest 2.0 and newer and the config-aware discovery API on Vitest 1.6. It appends selected files during execution: @@ -175,6 +195,25 @@ as Jest's `--testMatch`. DDTest prepends `-r dd-trace/ci/init` to `NODE_OPTIONS` for worker processes unless `NODE_OPTIONS` already loads `dd-trace/ci/init`. +## Mocha Discovery And Instrumentation + +DDTest supports Mocha 8 and newer. It uses Mocha's own option loader and file +collector, so discovery honors `.mocharc.*`, the `mocha` property in +`package.json`, `MOCHA_OPTIONS` on versions that support it, `spec`, +`extension`, `recursive`, `ignore`, and `sort` without loading test modules or +running hooks. Files configured with `--file` are treated as shared setup and +are loaded by every worker rather than being partitioned. + +Mocha normally adds positional files to configured `spec` patterns. During a +DDTest run, the adapter replaces that merged list with the worker's assigned +files while preserving the rest of the effective Mocha configuration. This +prevents every worker from running the entire configured suite. + +DDTest uses the local `node_modules/.bin/mocha` when present and otherwise +expects Mocha to be resolvable from the current project. Discovery removes +`-r dd-trace/ci/init` from `NODE_OPTIONS`; test runs retain it for Test +Optimization instrumentation. + ## Vitest Discovery And Instrumentation For JavaScript/Vitest 2.0 or higher, DDTest discovers test files with Vitest's diff --git a/docs/settings.md b/docs/settings.md index 456b945..ada13ab 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -6,8 +6,8 @@ CLI flags take precedence over environment variables. | CLI flag | Environment variable | Env alias | Default | What it does | | --- | --- | --- | ---: | --- | | `--platform` | `DD_TEST_OPTIMIZATION_RUNNER_PLATFORM` | | `ruby` | Language/platform. Currently supported: `ruby`, `python`, `javascript`. | -| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | | `rspec` | Test framework. Currently supported: `rspec`, `minitest`, `pytest`, `jest`, `vitest`. | -| `--command` | `DD_TEST_OPTIMIZATION_RUNNER_COMMAND` | | `""` | Override the default base test command for supported framework modes. Currently used by RSpec and Minitest run/discovery, and Jest and Vitest run/discovery; pytest ignores it. DDTest appends selected tests and framework-specific flags. For pytest, use `PYTEST_ADDOPTS` for pytest flags. | +| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | | `rspec` | Test framework. Currently supported: `rspec`, `minitest`, `pytest`, `jest`, `mocha`, `vitest`. | +| `--command` | `DD_TEST_OPTIMIZATION_RUNNER_COMMAND` | | `""` | Override the default base test command for supported framework modes. Currently used by RSpec and Minitest run/discovery, and Jest, Mocha, and Vitest run/discovery; pytest ignores it. DDTest appends selected tests and framework-specific flags. For pytest, use `PYTEST_ADDOPTS` for pytest flags. | | `--min-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MIN_PARALLELISM` | | physical CPU count | Minimum count DDTest considers when planning. Interpret it as CI nodes in CI-node mode, or workers in a single-node run. | | `--max-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM` | | physical CPU count | Maximum count DDTest considers when planning. Interpret it as CI nodes in CI-node mode, or workers in a single-node run. | | `--ci-job-overhead` | `DD_TEST_OPTIMIZATION_RUNNER_CI_JOB_OVERHEAD` | | `25s` | Modeled overhead for adding one more CI node. Accepts durations such as `25s`, `1m`, `1500ms`, or `0s` to disable this bias. Increase it to use fewer CI nodes; decrease it to prefer faster wall time. | @@ -15,7 +15,7 @@ CLI flags take precedence over environment variables. | `--ci-node` | `DD_TEST_OPTIMIZATION_RUNNER_CI_NODE` | | `-1` (off) | Restrict this run to files assigned to CI node **N** (0-indexed). | | `--ci-node-workers` | `DD_TEST_OPTIMIZATION_RUNNER_CI_NODE_WORKERS` | | `1` | Number of workers to start on this CI node. Use a positive integer, or `ncpu` to use the node's available physical CPU cores. | | `--worker-env` | `DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV` | | `""` | Template env vars per worker: `--worker-env "DATABASE_NAME_TEST=app_test{{nodeIndex}}_{{workerIndex}}"`. `{{nodeIndex}}` is the CI node index (`0` for single-node runs); `{{workerIndex}}` is the worker process index within that CI node. | -| `--tests-location` | `DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION` | `KNAPSACK_PRO_TEST_FILE_PATTERN` | `""` | Custom glob pattern to discover test files, such as `--tests-location "custom/spec/**/*_spec.rb"`, `--tests-location "tests/**/*_test.py"`, or `--tests-location "packages/**/__tests__/**/*.test.ts"`. Defaults to `spec/**/*_spec.rb` for RSpec, `test/**/*_test.rb` for Minitest, pytest config or `**/{test_*,*_test}.py` for pytest, and each JavaScript framework's configured/default test matching for Jest and Vitest. | +| `--tests-location` | `DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION` | `KNAPSACK_PRO_TEST_FILE_PATTERN` | `""` | Custom glob pattern to discover test files, such as `--tests-location "custom/spec/**/*_spec.rb"`, `--tests-location "tests/**/*_test.py"`, or `--tests-location "packages/**/__tests__/**/*.test.ts"`. Defaults to `spec/**/*_spec.rb` for RSpec, `test/**/*_test.rb` for Minitest, pytest config or `**/{test_*,*_test}.py` for pytest, and each JavaScript framework's configured/default test matching for Jest, Mocha, and Vitest. | | `--tests-exclude-pattern` | `DD_TEST_OPTIMIZATION_RUNNER_TESTS_EXCLUDE_PATTERN` | `KNAPSACK_PRO_TEST_FILE_EXCLUDE_PATTERN` | `""` | Glob pattern to exclude test files from discovery, such as `--tests-exclude-pattern "spec/system/**/*_spec.rb"`. | | `--test-discovery-cache` | `DD_TEST_OPTIMIZATION_RUNNER_TEST_DISCOVERY_CACHE` | | `""` | Path to a restored test discovery cache file. DDTest imports it before planning and refreshes the internal discovery cache after successful full discovery. | | `--force-full-test-discovery` | `DD_TEST_OPTIMIZATION_RUNNER_FORCE_FULL_TEST_DISCOVERY` | | `false` | Force full test discovery when the framework supports it, including in suite-level skipping mode. | diff --git a/docs/third-party-runners.md b/docs/third-party-runners.md index c538e4e..ef2f68e 100644 --- a/docs/third-party-runners.md +++ b/docs/third-party-runners.md @@ -57,6 +57,18 @@ if [ -s .testoptimization/runner/test-files.txt ]; then fi ``` +## Mocha + +When another runner consumes DDTest's Mocha file list, load Test Optimization +initialization before invoking Mocha: + +```bash +export NODE_OPTIONS="-r dd-trace/ci/init${NODE_OPTIONS:+ $NODE_OPTIONS}" +if [ -s .testoptimization/runner/test-files.txt ]; then + xargs ./node_modules/.bin/mocha < .testoptimization/runner/test-files.txt +fi +``` + ## Custom Runners Read `.testoptimization/runner/test-files.txt` when your runner should handle diff --git a/internal/framework/mocha.go b/internal/framework/mocha.go new file mode 100644 index 0000000..9807db6 --- /dev/null +++ b/internal/framework/mocha.go @@ -0,0 +1,287 @@ +package framework + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "log/slog" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/DataDog/ddtest/internal/discovery" + "github.com/DataDog/ddtest/internal/ext" + "github.com/DataDog/ddtest/internal/settings" + "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/DataDog/ddtest/internal/utils" +) + +const ( + binMochaPath = "node_modules/.bin/mocha" + mochaDiscoveryMarker = "__DDTEST_MOCHA_FILES__" + mochaRequestEnvVar = "DDTEST_MOCHA_REQUEST" +) + +//go:embed scripts/mocha_adapter.js +var mochaAdapterScript string + +type Mocha struct { + executor ext.CommandExecutor + commandOverride []string + platformEnv map[string]string +} + +func NewMocha() *Mocha { + return &Mocha{ + executor: &ext.DefaultCommandExecutor{}, + commandOverride: loadCommandOverride(), + platformEnv: make(map[string]string), + } +} + +func (m *Mocha) SetPlatformEnv(platformEnv map[string]string) { m.platformEnv = platformEnv } +func (m *Mocha) GetPlatformEnv() map[string]string { return m.platformEnv } +func (m *Mocha) Name() string { return "mocha" } +func (m *Mocha) SupportsFullTestDiscovery() bool { return false } + +func (m *Mocha) SourceFileForSuite(suite string) (string, bool) { + suite = strings.TrimSpace(suite) + if suite == "" { + return "", false + } + return suite, true +} + +func (m *Mocha) HasUnskippableMarker(testFile string) bool { + return utils.FileContainsAll(testFile, "@datadog", "unskippable") +} + +func (m *Mocha) TestPattern() string { + if custom := settings.GetTestsLocation(); custom != "" { + return custom + } + return filepath.ToSlash(filepath.Join("test", "**", "*.{js,cjs,mjs}")) +} + +func (m *Mocha) DiscoverTests(context.Context, discovery.TestFileSet) ([]testoptimization.Test, error) { + return nil, ErrFullTestDiscoveryUnsupported +} + +func (m *Mocha) DiscoverTestFiles(ctx context.Context, testFiles discovery.TestFileSet) ([]string, error) { + if settings.GetTestsExcludePattern() == "" { + if testFiles.Empty() { + return []string{}, nil + } + if testFiles.UseExplicitFiles() { + return slices.Clone(testFiles.ExplicitFiles), nil + } + } + + command, baseArgs := m.getMochaCommand() + cliArgs, err := mochaCLIArgs(command, baseArgs) + if err != nil { + return nil, err + } + requestBody := map[string]any{"mode": "discover", "cliArgs": cliArgs} + if settings.GetTestsLocation() != "" { + requestBody["spec"] = []string{testFiles.Pattern} + } + request, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to encode Mocha discovery request: %w", err) + } + adapterPath, adapterEnv, err := prepareMochaAdapter(m.discoveryEnv(), request) + if err != nil { + return nil, err + } + defer func() { _ = os.Remove(adapterPath) }() + + slog.Info("Discovering Mocha test files", "command", command, "args", baseArgs) + output, err := m.executor.CombinedOutput(ctx, command, baseArgs, adapterEnv) + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return nil, fmt.Errorf("failed to discover Mocha test files: %w", err) + } + return nil, fmt.Errorf("failed to discover Mocha test files: %s: %w", message, err) + } + + discoveredFiles, err := parseMochaDiscoveryOutput(output) + if err != nil { + return nil, err + } + if settings.GetTestsLocation() == "" && settings.GetTestsExcludePattern() == "" { + return discoveredFiles, nil + } + return filterMochaTestFiles(discoveredFiles, testFiles) +} + +func (m *Mocha) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { + command, baseArgs := m.getMochaCommand() + cliArgs, err := mochaCLIArgs(command, baseArgs) + if err != nil { + return err + } + request, err := json.Marshal(map[string]any{"mode": "run", "cliArgs": cliArgs, "files": testFiles}) + if err != nil { + return fmt.Errorf("failed to encode Mocha run request: %w", err) + } + + slog.Info("Running Mocha tests", "command", command, "args", baseArgs, "testFiles", testFiles) + mergedEnv := make(map[string]string) + maps.Copy(mergedEnv, m.platformEnv) + maps.Copy(mergedEnv, envMap) + adapterPath, adapterEnv, err := prepareMochaAdapter(mergedEnv, request) + if err != nil { + return err + } + defer func() { _ = os.Remove(adapterPath) }() + return m.executor.Run(ctx, command, baseArgs, adapterEnv) +} + +func prepareMochaAdapter(baseEnv map[string]string, request []byte) (string, map[string]string, error) { + adapterFile, err := os.CreateTemp("", "ddtest-mocha-adapter-*.js") + if err != nil { + return "", nil, fmt.Errorf("failed to create Mocha adapter: %w", err) + } + adapterPath := adapterFile.Name() + removeAdapter := func() { _ = os.Remove(adapterPath) } + if _, err := adapterFile.WriteString(mochaAdapterScript); err != nil { + _ = adapterFile.Close() + removeAdapter() + return "", nil, fmt.Errorf("failed to write Mocha adapter: %w", err) + } + if err := adapterFile.Close(); err != nil { + removeAdapter() + return "", nil, fmt.Errorf("failed to close Mocha adapter: %w", err) + } + + adapterEnv := make(map[string]string, len(baseEnv)+2) + maps.Copy(adapterEnv, baseEnv) + nodeOptions, ok := adapterEnv[nodeOptionsEnvVar] + if !ok { + nodeOptions = os.Getenv(nodeOptionsEnvVar) + } + adapterEnv[nodeOptionsEnvVar] = strings.TrimSpace(nodeOptions + " --require " + strconv.Quote(adapterPath)) + adapterEnv[mochaRequestEnvVar] = string(request) + return adapterPath, adapterEnv, nil +} + +func (m *Mocha) discoveryEnv() map[string]string { + envMap := make(map[string]string, len(m.platformEnv)+1) + maps.Copy(envMap, m.platformEnv) + nodeOptions, ok := envMap[nodeOptionsEnvVar] + if !ok { + var found bool + nodeOptions, found = os.LookupEnv(nodeOptionsEnvVar) + if !found { + return envMap + } + } + envMap[nodeOptionsEnvVar] = stripNodeOptionsRequire(nodeOptions, ddTraceCIInitModule) + return envMap +} + +func (m *Mocha) getMochaCommand() (string, []string) { + if len(m.commandOverride) > 0 { + return m.commandOverride[0], m.commandOverride[1:] + } + if info, err := os.Stat(binMochaPath); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { + return binMochaPath, nil + } + return "npx", []string{"mocha"} +} + +func mochaCLIArgs(command string, baseArgs []string) ([]string, error) { + if isMochaExecutable(command) { + return slices.Clone(baseArgs), nil + } + for i, arg := range baseArgs { + if isMochaExecutable(arg) { + return slices.Clone(baseArgs[i+1:]), nil + } + } + return nil, fmt.Errorf("Mocha command must invoke Mocha directly: %s %s", command, strings.Join(baseArgs, " ")) +} + +func isMochaExecutable(value string) bool { + base := filepath.Base(value) + return base == "mocha" || base == "mocha.js" || base == "_mocha" +} + +func parseMochaDiscoveryOutput(output []byte) ([]string, error) { + markerIndex := strings.LastIndex(string(output), mochaDiscoveryMarker) + if markerIndex < 0 { + return nil, fmt.Errorf("Mocha discovery output did not contain a file list") + } + encodedFiles := string(output[markerIndex+len(mochaDiscoveryMarker):]) + if lineEnd := strings.IndexByte(encodedFiles, '\n'); lineEnd >= 0 { + encodedFiles = encodedFiles[:lineEnd] + } + var paths []string + if err := json.Unmarshal([]byte(encodedFiles), &paths); err != nil { + return nil, fmt.Errorf("failed to parse Mocha test file list: %w", err) + } + return normalizeMochaTestFiles(paths), nil +} + +func normalizeMochaTestFiles(paths []string) []string { + cwd, _ := os.Getwd() + if resolvedCwd, err := filepath.EvalSymlinks(cwd); err == nil { + cwd = resolvedCwd + } + files := make([]string, 0, len(paths)) + for _, candidate := range paths { + testFile := strings.TrimSpace(candidate) + if testFile == "" { + continue + } + if filepath.IsAbs(testFile) && cwd != "" { + pathForRel := testFile + if resolvedPath, err := filepath.EvalSymlinks(testFile); err == nil { + pathForRel = resolvedPath + } + relativePath, err := filepath.Rel(cwd, pathForRel) + if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + continue + } + testFile = relativePath + } + normalized := utils.NormalizePath(testFile) + if normalized == "" { + continue + } + if _, err := os.Stat(normalized); err != nil { + continue + } + files = append(files, normalized) + } + slices.Sort(files) + return slices.Compact(files) +} + +func filterMochaTestFiles(testFiles []string, selectedTestFiles discovery.TestFileSet) ([]string, error) { + if settings.GetTestsExcludePattern() != "" { + selectedTestFiles.ExplicitFiles = nil + } + if settings.GetTestsLocation() == "" { + selectedTestFiles.Pattern = "" + } + matcher, err := discovery.NewTestFileSetMatcher(selectedTestFiles, settings.GetTestsExcludePattern()) + if err != nil { + return nil, err + } + filtered := make([]string, 0, len(testFiles)) + for _, testFile := range testFiles { + normalized := utils.NormalizePath(testFile) + if normalized != "" && matcher.MatchNormalizedPath(normalized) { + filtered = append(filtered, normalized) + } + } + slices.Sort(filtered) + return slices.Compact(filtered), nil +} diff --git a/internal/framework/mocha_test.go b/internal/framework/mocha_test.go new file mode 100644 index 0000000..7163c0f --- /dev/null +++ b/internal/framework/mocha_test.go @@ -0,0 +1,320 @@ +package framework + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/DataDog/ddtest/internal/discovery" + "github.com/DataDog/ddtest/internal/ext" +) + +type mochaCommandExecutor struct { + output []byte + combinedErr error + runErr error + capturedName string + capturedArgs []string + capturedEnv map[string]string +} + +func (m *mochaCommandExecutor) CombinedOutput(_ context.Context, name string, args []string, envMap map[string]string) ([]byte, error) { + m.capture(name, args, envMap) + return m.output, m.combinedErr +} + +func (m *mochaCommandExecutor) Run(_ context.Context, name string, args []string, envMap map[string]string) error { + m.capture(name, args, envMap) + return m.runErr +} + +func (m *mochaCommandExecutor) capture(name string, args []string, envMap map[string]string) { + m.capturedName = name + m.capturedArgs = slices.Clone(args) + m.capturedEnv = make(map[string]string) + for key, value := range envMap { + m.capturedEnv[key] = value + } +} + +func TestMochaBasics(t *testing.T) { + mocha := NewMocha() + if mocha.Name() != "mocha" { + t.Fatalf("Name() = %q, want mocha", mocha.Name()) + } + if mocha.SupportsFullTestDiscovery() { + t.Fatal("Mocha should use suite-level discovery") + } + if got := mocha.TestPattern(); got != "test/**/*.{js,cjs,mjs}" { + t.Fatalf("TestPattern() = %q", got) + } + if source, ok := mocha.SourceFileForSuite(" test/unit.spec.js "); !ok || source != "test/unit.spec.js" { + t.Fatalf("SourceFileForSuite() = %q, %v", source, ok) + } + if source, ok := mocha.SourceFileForSuite(" "); ok || source != "" { + t.Fatalf("empty SourceFileForSuite() = %q, %v", source, ok) + } + if _, err := mocha.DiscoverTests(context.Background(), discovery.TestFileSet{}); !errors.Is(err, ErrFullTestDiscoveryUnsupported) { + t.Fatalf("DiscoverTests() error = %v", err) + } +} + +func TestMochaCommandArgs(t *testing.T) { + tests := []struct { + name string + command string + args []string + want []string + wantErr bool + }{ + {name: "local", command: "node_modules/.bin/mocha", args: []string{"--parallel"}, want: []string{"--parallel"}}, + {name: "npx", command: "npx", args: []string{"mocha", "--config", "custom.json"}, want: []string{"--config", "custom.json"}}, + {name: "pnpm", command: "pnpm", args: []string{"exec", "mocha", "--parallel"}, want: []string{"--parallel"}}, + {name: "missing", command: "npm", args: []string{"test"}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := mochaCLIArgs(test.command, test.args) + if (err != nil) != test.wantErr { + t.Fatalf("mochaCLIArgs() error = %v", err) + } + if !slices.Equal(got, test.want) { + t.Fatalf("mochaCLIArgs() = %v, want %v", got, test.want) + } + }) + } +} + +func TestMochaDiscoverTestFiles(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + for _, file := range []string{"test/a.spec.js", "test/b.spec.js"} { + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + } + + absA, _ := filepath.Abs("test/a.spec.js") + absB, _ := filepath.Abs("test/b.spec.js") + executor := &mochaCommandExecutor{ + output: []byte("config log\n" + mochaDiscoveryMarker + "[" + strconvQuote(absB) + "," + strconvQuote(absA) + "," + strconvQuote(absA) + "]\n"), + } + mocha := &Mocha{ + executor: executor, + commandOverride: []string{"pnpm", "exec", "mocha", "--parallel"}, + platformEnv: map[string]string{"NODE_OPTIONS": "-r dd-trace/ci/init --max-old-space-size=4096", "CUSTOM": "value"}, + } + files, err := mocha.DiscoverTestFiles(context.Background(), discovery.TestFileSet{Pattern: mocha.TestPattern()}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(files, []string{"test/a.spec.js", "test/b.spec.js"}) { + t.Fatalf("files = %v", files) + } + if executor.capturedName != "pnpm" || !slices.Equal(executor.capturedArgs, []string{"exec", "mocha", "--parallel"}) { + t.Fatalf("command = %q %v", executor.capturedName, executor.capturedArgs) + } + var request struct { + Mode string `json:"mode"` + CLIArgs []string `json:"cliArgs"` + } + if err := json.Unmarshal([]byte(executor.capturedEnv[mochaRequestEnvVar]), &request); err != nil { + t.Fatal(err) + } + if request.Mode != "discover" || !slices.Equal(request.CLIArgs, []string{"--parallel"}) { + t.Fatalf("request = %#v", request) + } + if !strings.HasPrefix(executor.capturedEnv["NODE_OPTIONS"], "--max-old-space-size=4096 --require ") || executor.capturedEnv["CUSTOM"] != "value" { + t.Fatalf("discovery env = %v", executor.capturedEnv) + } +} + +func TestMochaDiscoverTestFilesPassesCustomLocation(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + setTestsLocation(t, "spec/**/*.js") + writeMochaFixture(t, root, "spec/custom.spec.js", "test") + absCustom, _ := filepath.Abs("spec/custom.spec.js") + executor := &mochaCommandExecutor{output: []byte(mochaDiscoveryMarker + "[" + strconvQuote(absCustom) + "]\n")} + mocha := &Mocha{ + executor: executor, + commandOverride: []string{"mocha"}, + platformEnv: make(map[string]string), + } + + files, err := mocha.DiscoverTestFiles(context.Background(), discovery.TestFileSet{Pattern: mocha.TestPattern()}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(files, []string{"spec/custom.spec.js"}) { + t.Fatalf("files = %v", files) + } + var request struct { + Spec []string `json:"spec"` + } + if err := json.Unmarshal([]byte(executor.capturedEnv[mochaRequestEnvVar]), &request); err != nil { + t.Fatal(err) + } + if !slices.Equal(request.Spec, []string{"spec/**/*.js"}) { + t.Fatalf("discovery spec = %v", request.Spec) + } +} + +func TestMochaRunTests(t *testing.T) { + executor := &mochaCommandExecutor{} + mocha := &Mocha{ + executor: executor, + commandOverride: []string{"npx", "mocha", "--parallel"}, + platformEnv: map[string]string{"NODE_OPTIONS": "-r dd-trace/ci/init", "BASE": "base"}, + } + files := []string{"test/a.spec.js"} + if err := mocha.RunTests(context.Background(), files, map[string]string{"WORKER": "1"}); err != nil { + t.Fatal(err) + } + if executor.capturedName != "npx" || !slices.Equal(executor.capturedArgs, []string{"mocha", "--parallel"}) || + !strings.HasPrefix(executor.capturedEnv["NODE_OPTIONS"], "-r dd-trace/ci/init --require ") || executor.capturedEnv["WORKER"] != "1" { + t.Fatalf("run = %q env=%v", executor.capturedName, executor.capturedEnv) + } + var request struct { + Mode string `json:"mode"` + CLIArgs []string `json:"cliArgs"` + Files []string `json:"files"` + } + if err := json.Unmarshal([]byte(executor.capturedEnv[mochaRequestEnvVar]), &request); err != nil { + t.Fatal(err) + } + if request.Mode != "run" || !slices.Equal(request.CLIArgs, []string{"--parallel"}) || !slices.Equal(request.Files, files) { + t.Fatalf("request = %#v", request) + } +} + +func TestParseMochaDiscoveryOutputErrors(t *testing.T) { + if _, err := parseMochaDiscoveryOutput([]byte("noise")); err == nil { + t.Fatal("expected missing marker error") + } + if _, err := parseMochaDiscoveryOutput([]byte(mochaDiscoveryMarker + "not-json")); err == nil { + t.Fatal("expected JSON error") + } +} + +func TestMochaUnskippableMarker(t *testing.T) { + file := filepath.Join(t.TempDir(), "marked.spec.js") + if err := os.WriteFile(file, []byte("// @datadog unskippable\n"), 0644); err != nil { + t.Fatal(err) + } + if !NewMocha().HasUnskippableMarker(file) { + t.Fatal("expected marker") + } +} + +func TestMochaAdapterIntegration(t *testing.T) { + nodeModules := os.Getenv("DDTEST_MOCHA_NODE_MODULES") + if nodeModules == "" { + t.Skip("DDTEST_MOCHA_NODE_MODULES is not set") + } + + root := t.TempDir() + if err := os.Symlink(nodeModules, filepath.Join(root, "node_modules")); err != nil { + t.Fatal(err) + } + writeMochaFixture(t, root, ".mocharc.json", `{"spec":["test/**/*.spec.js"],"file":["setup.js"]}`) + writeMochaFixture(t, root, "setup.js", "global.ddtestSetup = true\n") + writeMochaFixture(t, root, "test/selected.spec.js", `const assert = require("assert"); describe("selected", () => { it("uses setup", () => assert.equal(global.ddtestSetup, true)) })`) + writeMochaFixture(t, root, "test/unselected.spec.js", `describe("unselected", () => { it("must not run", () => { throw new Error("unselected file ran") }) })`) + t.Chdir(root) + + mocha := &Mocha{executor: &ext.DefaultCommandExecutor{}, platformEnv: make(map[string]string)} + files, err := mocha.DiscoverTestFiles(context.Background(), discovery.TestFileSet{Pattern: mocha.TestPattern()}) + if err != nil { + t.Fatal(err) + } + want := []string{"test/selected.spec.js", "test/unselected.spec.js"} + if !slices.Equal(files, want) { + t.Fatalf("discovered files = %v, want %v", files, want) + } + if err := mocha.RunTests(context.Background(), []string{"test/selected.spec.js"}, nil); err != nil { + t.Fatalf("selected-file run failed: %v", err) + } + + if err := os.Remove(filepath.Join(root, ".mocharc.json")); err != nil { + t.Fatal(err) + } + files, err = mocha.DiscoverTestFiles(context.Background(), discovery.TestFileSet{Pattern: mocha.TestPattern()}) + if err != nil { + t.Fatalf("default discovery failed: %v", err) + } + if !slices.Equal(files, want) { + t.Fatalf("default discovered files = %v, want %v", files, want) + } +} + +func TestMochaAdapterCustomLocationAndCommandIntegration(t *testing.T) { + nodeModules := os.Getenv("DDTEST_MOCHA_NODE_MODULES") + if nodeModules == "" { + t.Skip("DDTEST_MOCHA_NODE_MODULES is not set") + } + + root := t.TempDir() + mochaCommand := filepath.Join(nodeModules, ".bin", "mocha") + wrapper := filepath.Join(root, "mocha-wrapper.sh") + writeMochaFixture(t, root, "mocha-wrapper.sh", "#!/bin/sh\nexport DDTEST_MOCHA_WRAPPER=preserved\nexec \"$@\"\n") + if err := os.Chmod(wrapper, 0755); err != nil { + t.Fatal(err) + } + writeMochaFixture(t, root, ".mocharc.json", `{"spec":["test/**/*.spec.js"]}`) + writeMochaFixture(t, root, "spec/custom.spec.js", `const assert = require("assert"); describe("custom", () => { it("uses wrapper", () => assert.equal(process.env.DDTEST_MOCHA_WRAPPER, "preserved")) })`) + t.Chdir(root) + setTestsLocation(t, "spec/**/*.js") + + mocha := &Mocha{ + executor: &ext.DefaultCommandExecutor{}, + commandOverride: []string{wrapper, mochaCommand}, + platformEnv: make(map[string]string), + } + files, err := mocha.DiscoverTestFiles(context.Background(), discovery.TestFileSet{Pattern: mocha.TestPattern()}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(files, []string{"spec/custom.spec.js"}) { + t.Fatalf("discovered files = %v", files) + } + if err := mocha.RunTests(context.Background(), files, nil); err != nil { + t.Fatalf("custom-command run failed: %v", err) + } +} + +func writeMochaFixture(t *testing.T, root, name, contents string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } +} + +func strconvQuote(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func TestMochaDiscoveryFailureIncludesOutput(t *testing.T) { + mocha := &Mocha{ + executor: &mochaCommandExecutor{output: []byte("bad config"), combinedErr: errors.New("exit 1")}, + commandOverride: []string{"mocha"}, + platformEnv: make(map[string]string), + } + _, err := mocha.DiscoverTestFiles(context.Background(), discovery.TestFileSet{Pattern: mocha.TestPattern()}) + if err == nil || !strings.Contains(err.Error(), "bad config") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/framework/scripts/mocha_adapter.js b/internal/framework/scripts/mocha_adapter.js new file mode 100644 index 0000000..1b7e357 --- /dev/null +++ b/internal/framework/scripts/mocha_adapter.js @@ -0,0 +1,73 @@ +"use strict" + +const fs = require("fs") +const path = require("path") + +const outputMarker = "__DDTEST_MOCHA_FILES__" +const requestJSON = process.env.DDTEST_MOCHA_REQUEST +const entrypoint = process.argv[1] || "" + +// NODE_OPTIONS is inherited by command wrappers and package managers. Wait +// until the process that actually runs Mocha so those tools can establish the +// intended working directory, module path, and environment first. +if (requestJSON && ["mocha", "mocha.js", "_mocha"].includes(path.basename(entrypoint))) { + delete process.env.DDTEST_MOCHA_REQUEST + runAdapter(JSON.parse(requestJSON), entrypoint) +} + +function runAdapter(request, mochaEntrypoint) { + let resolvedEntrypoint = path.resolve(mochaEntrypoint) + try { + resolvedEntrypoint = fs.realpathSync(resolvedEntrypoint) + } catch (_) { + // Keep the unresolved command path as a module-resolution hint. + } + const packagePath = require.resolve("mocha/package.json", { + paths: [path.dirname(resolvedEntrypoint), process.cwd()], + }) + const mochaRoot = path.dirname(packagePath) + const mochaVersion = require(packagePath).version + const majorVersion = Number.parseInt(mochaVersion.split(".")[0], 10) + + if (!Number.isInteger(majorVersion) || majorVersion < 8) { + throw new Error(`ddtest requires Mocha 8 or newer; found ${mochaVersion}`) + } + + const optionsPath = path.join(mochaRoot, "lib/cli/options.js") + const optionsModule = require(optionsPath) + const options = optionsModule.loadOptions(request.cliArgs || []) + + if (request.mode === "discover") { + const collectFiles = require(path.join(mochaRoot, "lib/cli/collect-files.js")) + const collection = collectFiles({ + ignore: options.ignore || [], + extension: options.extension || [], + file: options.file || [], + recursive: Boolean(options.recursive), + sort: Boolean(options.sort), + spec: request.spec && request.spec.length + ? request.spec + : (options._ && options._.length ? options._ : ["test"]), + }) + + // --file entries are global setup files. Mocha loads them for every run, so + // they must not be partitioned as independently runnable test files. + // Mocha 8 and 9 return the file array directly. Mocha 10 and newer + // return an object that also describes unmatched --file entries. + const collectedFiles = Array.isArray(collection) ? collection : collection.files + const setupFiles = new Set((options.file || []).map(file => path.resolve(file))) + const testFiles = collectedFiles.filter(file => !setupFiles.has(path.resolve(file))) + process.stdout.write(`${outputMarker}${JSON.stringify(testFiles)}\n`) + process.exit(0) + } else if (request.mode === "run") { + const files = request.files || [] + + // The selected command will load Mocha's CLI after this preload. Replace + // its option loader so configured specs cannot be merged back in, while + // retaining all other effective Mocha options on every supported version. + options._ = files + optionsModule.loadOptions = () => options + } else { + throw new Error(`unknown ddtest Mocha adapter mode: ${request.mode}`) + } +} diff --git a/internal/platform/javascript.go b/internal/platform/javascript.go index 9553a42..d163fdc 100644 --- a/internal/platform/javascript.go +++ b/internal/platform/javascript.go @@ -129,6 +129,8 @@ func (j *JavaScript) DetectFramework() (framework.Framework, error) { switch frameworkName { case "jest": fw = framework.NewJest() + case "mocha": + fw = framework.NewMocha() case "vitest": platformEnv = addNodeImport(platformEnv, ddTraceRegisterModule) fw = framework.NewVitest() diff --git a/internal/platform/javascript_test.go b/internal/platform/javascript_test.go index afb680c..5d6fce3 100644 --- a/internal/platform/javascript_test.go +++ b/internal/platform/javascript_test.go @@ -219,6 +219,28 @@ func TestJavaScript_DetectFramework_Jest(t *testing.T) { } } +func TestJavaScript_DetectFramework_Mocha(t *testing.T) { + t.Setenv(nodeOptionsEnvVar, "") + viper.Reset() + viper.Set("framework", "mocha") + settings.Init() + defer func() { + viper.Reset() + settings.Init() + }() + + fw, err := NewJavaScript().DetectFramework() + if err != nil { + t.Fatalf("DetectFramework failed: %v", err) + } + if fw.Name() != "mocha" { + t.Fatalf("framework name = %q, want mocha", fw.Name()) + } + if got := fw.GetPlatformEnv()[nodeOptionsEnvVar]; got != nodeOptionsDDTraceCIArg { + t.Fatalf("NODE_OPTIONS = %q, want %q", got, nodeOptionsDDTraceCIArg) + } +} + func TestJavaScript_DetectFramework_Vitest(t *testing.T) { t.Setenv(nodeOptionsEnvVar, "") viper.Reset()