Add tests for default-python template on different Python versions (#2025)

## Changes
Add new type of test helpers that run the command and compare full
output (golden files approach).

In case of JSON, there is also an option to ignore certain paths.

Add test for different versions of Python to go through bundle init
default-python / validate / deploy / summary.

## Tests
New integration tests.
This commit is contained in:
Denis Bilenko 2024-12-20 15:40:54 +01:00 committed by GitHub
parent dd9f59837e
commit e0952491c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 724 additions and 0 deletions

8
NOTICE
View File

@ -97,3 +97,11 @@ License - https://github.com/stretchr/testify/blob/master/LICENSE
whilp/git-urls - https://github.com/whilp/git-urls
Copyright (c) 2020 Will Maier
License - https://github.com/whilp/git-urls/blob/master/LICENSE
github.com/wI2L/jsondiff v0.6.1
Copyright (c) 2020-2024 William Poussier <william.poussier@gmail.com>
License - https://github.com/wI2L/jsondiff/blob/master/LICENSE
https://github.com/hexops/gotextdiff
Copyright (c) 2009 The Go Authors. All rights reserved.
License - https://github.com/hexops/gotextdiff/blob/main/LICENSE

6
go.mod
View File

@ -14,6 +14,7 @@ require (
github.com/hashicorp/hc-install v0.9.0 // MPL 2.0
github.com/hashicorp/terraform-exec v0.21.0 // MPL 2.0
github.com/hashicorp/terraform-json v0.23.0 // MPL 2.0
github.com/hexops/gotextdiff v1.0.3 // BSD 3-Clause "New" or "Revised" License
github.com/manifoldco/promptui v0.9.0 // BSD-3-Clause
github.com/mattn/go-isatty v0.0.20 // MIT
github.com/nwidger/jsoncolor v0.3.2 // MIT
@ -22,6 +23,7 @@ require (
github.com/spf13/cobra v1.8.1 // Apache 2.0
github.com/spf13/pflag v1.0.5 // BSD-3-Clause
github.com/stretchr/testify v1.10.0 // MIT
github.com/wI2L/jsondiff v0.6.1 // MIT
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225
golang.org/x/mod v0.22.0
golang.org/x/oauth2 v0.24.0
@ -55,6 +57,10 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/zclconf/go-cty v1.15.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect

14
go.sum generated
View File

@ -109,6 +109,8 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW
github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg=
github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@ -156,6 +158,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wI2L/jsondiff v0.6.1 h1:ISZb9oNWbP64LHnu4AUhsMF5W0FIj5Ok3Krip9Shqpw=
github.com/wI2L/jsondiff v0.6.1/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ=

View File

@ -0,0 +1,132 @@
package bundle_test
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/databricks/cli/integration/internal/acc"
"github.com/databricks/cli/internal/testcli"
"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/python/pythontest"
"github.com/stretchr/testify/require"
)
var pythonVersions = []string{
"3.8",
"3.9",
"3.10",
"3.11",
"3.12",
"3.13",
}
var pythonVersionsShort = []string{
"3.9",
"3.12",
}
var extraInstalls = map[string][]string{
"3.12": {"setuptools"},
"3.13": {"setuptools"},
}
func TestDefaultPython(t *testing.T) {
versions := pythonVersions
if testing.Short() {
versions = pythonVersionsShort
}
for _, pythonVersion := range versions {
t.Run(pythonVersion, func(t *testing.T) {
testDefaultPython(t, pythonVersion)
})
}
}
func testDefaultPython(t *testing.T, pythonVersion string) {
ctx, wt := acc.WorkspaceTest(t)
uniqueProjectId := testutil.RandomName("")
ctx, replacements := testcli.WithReplacementsMap(ctx)
replacements.Set(uniqueProjectId, "$UNIQUE_PRJ")
user, err := wt.W.CurrentUser.Me(ctx)
require.NoError(t, err)
require.NotNil(t, user)
testcli.PrepareReplacementsUser(t, replacements, *user)
testcli.PrepareReplacements(t, replacements, wt.W)
tmpDir := t.TempDir()
testutil.Chdir(t, tmpDir)
opts := pythontest.VenvOpts{
PythonVersion: pythonVersion,
Dir: tmpDir,
}
pythontest.RequireActivatedPythonEnv(t, ctx, &opts)
extras, ok := extraInstalls[pythonVersion]
if ok {
args := append([]string{"pip", "install", "--python", opts.PythonExe}, extras...)
cmd := exec.Command("uv", args...)
require.NoError(t, cmd.Run())
}
projectName := "project_name_" + uniqueProjectId
initConfig := map[string]string{
"project_name": projectName,
"include_notebook": "yes",
"include_python": "yes",
"include_dlt": "yes",
}
b, err := json.Marshal(initConfig)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "config.json"), b, 0o644)
require.NoError(t, err)
testcli.AssertOutput(
t,
ctx,
[]string{"bundle", "init", "default-python", "--config-file", "config.json"},
testutil.TestData("testdata/default_python/bundle_init.txt"),
)
testutil.Chdir(t, projectName)
t.Cleanup(func() {
// Delete the stack
testcli.RequireSuccessfulRun(t, ctx, "bundle", "destroy", "--auto-approve")
})
testcli.AssertOutput(
t,
ctx,
[]string{"bundle", "validate"},
testutil.TestData("testdata/default_python/bundle_validate.txt"),
)
testcli.AssertOutput(
t,
ctx,
[]string{"bundle", "deploy"},
testutil.TestData("testdata/default_python/bundle_deploy.txt"),
)
testcli.AssertOutputJQ(
t,
ctx,
[]string{"bundle", "summary", "--output", "json"},
testutil.TestData("testdata/default_python/bundle_summary.txt"),
[]string{
"/bundle/terraform/exec_path",
"/resources/jobs/project_name_$UNIQUE_PRJ_job/email_notifications",
"/resources/jobs/project_name_$UNIQUE_PRJ_job/job_clusters/0/new_cluster/node_type_id",
"/resources/jobs/project_name_$UNIQUE_PRJ_job/url",
"/resources/pipelines/project_name_$UNIQUE_PRJ_pipeline/catalog",
"/resources/pipelines/project_name_$UNIQUE_PRJ_pipeline/url",
"/workspace/current_user",
},
)
}

View File

@ -0,0 +1,6 @@
Building project_name_$UNIQUE_PRJ...
Uploading project_name_$UNIQUE_PRJ-0.0.1+<NUMID>.<NUMID>-py3-none-any.whl...
Uploading bundle files to /Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/files...
Deploying resources...
Updating deployment state...
Deployment complete!

View File

@ -0,0 +1,8 @@
Welcome to the default Python template for Databricks Asset Bundles!
Workspace to use (auto-detected, edit in 'project_name_$UNIQUE_PRJ/databricks.yml'): https://$DATABRICKS_HOST
✨ Your new project has been created in the 'project_name_$UNIQUE_PRJ' directory!
Please refer to the README.md file for "getting started" instructions.
See also the documentation at https://docs.databricks.com/dev-tools/bundles/index.html.

View File

@ -0,0 +1,185 @@
{
"bundle": {
"name": "project_name_$UNIQUE_PRJ",
"target": "dev",
"environment": "dev",
"terraform": {
"exec_path": "/tmp/.../terraform"
},
"git": {
"bundle_root_path": ".",
"inferred": true
},
"mode": "development",
"deployment": {
"lock": {
"enabled": false
}
}
},
"include": [
"resources/project_name_$UNIQUE_PRJ.job.yml",
"resources/project_name_$UNIQUE_PRJ.pipeline.yml"
],
"workspace": {
"host": "https://$DATABRICKS_HOST",
"current_user": {
"active": true,
"displayName": "$USERNAME",
"emails": [
{
"primary": true,
"type": "work",
"value": "$USERNAME"
}
],
"groups": [
{
"$ref": "Groups/$USER.Groups[0]",
"display": "team.engineering",
"type": "direct",
"value": "$USER.Groups[0]"
}
],
"id": "$USER.Id",
"name": {
"familyName": "$USERNAME",
"givenName": "$USERNAME"
},
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:workspace:2.0:User"
],
"short_name": "$USERNAME",
"userName": "$USERNAME"
},
"root_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev",
"file_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/files",
"resource_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/resources",
"artifact_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/artifacts",
"state_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/state"
},
"resources": {
"jobs": {
"project_name_$UNIQUE_PRJ_job": {
"deployment": {
"kind": "BUNDLE",
"metadata_file_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/state/metadata.json"
},
"edit_mode": "UI_LOCKED",
"email_notifications": {
"on_failure": [
"$USERNAME"
]
},
"format": "MULTI_TASK",
"id": "<NUMID>",
"job_clusters": [
{
"job_cluster_key": "job_cluster",
"new_cluster": {
"autoscale": {
"max_workers": 4,
"min_workers": 1
},
"node_type_id": "i3.xlarge",
"spark_version": "15.4.x-scala2.12"
}
}
],
"max_concurrent_runs": 4,
"name": "[dev $USERNAME] project_name_$UNIQUE_PRJ_job",
"queue": {
"enabled": true
},
"tags": {
"dev": "$USERNAME"
},
"tasks": [
{
"job_cluster_key": "job_cluster",
"notebook_task": {
"notebook_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/files/src/notebook"
},
"task_key": "notebook_task"
},
{
"depends_on": [
{
"task_key": "notebook_task"
}
],
"pipeline_task": {
"pipeline_id": "${resources.pipelines.project_name_$UNIQUE_PRJ_pipeline.id}"
},
"task_key": "refresh_pipeline"
},
{
"depends_on": [
{
"task_key": "refresh_pipeline"
}
],
"job_cluster_key": "job_cluster",
"libraries": [
{
"whl": "dist/*.whl"
}
],
"python_wheel_task": {
"entry_point": "main",
"package_name": "project_name_$UNIQUE_PRJ"
},
"task_key": "main_task"
}
],
"trigger": {
"pause_status": "PAUSED",
"periodic": {
"interval": 1,
"unit": "DAYS"
}
},
"url": "https://$DATABRICKS_HOST/jobs/<NUMID>?o=<NUMID>"
}
},
"pipelines": {
"project_name_$UNIQUE_PRJ_pipeline": {
"catalog": "main",
"configuration": {
"bundle.sourcePath": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/files/src"
},
"deployment": {
"kind": "BUNDLE",
"metadata_file_path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/state/metadata.json"
},
"development": true,
"id": "<UUID>",
"libraries": [
{
"notebook": {
"path": "/Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev/files/src/dlt_pipeline"
}
}
],
"name": "[dev $USERNAME] project_name_$UNIQUE_PRJ_pipeline",
"target": "project_name_$UNIQUE_PRJ_dev",
"url": "https://$DATABRICKS_HOST/pipelines/<UUID>?o=<NUMID>"
}
}
},
"sync": {
"paths": [
"."
]
},
"presets": {
"name_prefix": "[dev $USERNAME] ",
"pipelines_development": true,
"trigger_pause_status": "PAUSED",
"jobs_max_concurrent_runs": 4,
"tags": {
"dev": "$USERNAME"
}
}
}

View File

@ -0,0 +1,8 @@
Name: project_name_$UNIQUE_PRJ
Target: dev
Workspace:
Host: https://$DATABRICKS_HOST
User: $USERNAME
Path: /Workspace/Users/$USERNAME/.bundle/project_name_$UNIQUE_PRJ/dev
Validation OK!

224
internal/testcli/golden.go Normal file
View File

@ -0,0 +1,224 @@
package testcli
import (
"context"
"fmt"
"os"
"regexp"
"slices"
"strings"
"testing"
"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/iamutil"
"github.com/databricks/cli/libs/testdiff"
"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/service/iam"
"github.com/stretchr/testify/assert"
)
var OverwriteMode = os.Getenv("TESTS_OUTPUT") == "OVERWRITE"
func ReadFile(t testutil.TestingT, ctx context.Context, filename string) string {
data, err := os.ReadFile(filename)
if os.IsNotExist(err) {
return ""
}
assert.NoError(t, err)
// On CI, on Windows \n in the file somehow end up as \r\n
return NormalizeNewlines(string(data))
}
func captureOutput(t testutil.TestingT, ctx context.Context, args []string) string {
t.Logf("run args: [%s]", strings.Join(args, ", "))
r := NewRunner(t, ctx, args...)
stdout, stderr, err := r.Run()
assert.NoError(t, err)
out := stderr.String() + stdout.String()
return ReplaceOutput(t, ctx, out)
}
func WriteFile(t testutil.TestingT, filename, data string) {
t.Logf("Overwriting %s", filename)
err := os.WriteFile(filename, []byte(data), 0o644)
assert.NoError(t, err)
}
func AssertOutput(t testutil.TestingT, ctx context.Context, args []string, expectedPath string) {
expected := ReadFile(t, ctx, expectedPath)
out := captureOutput(t, ctx, args)
if out != expected {
actual := fmt.Sprintf("Output from %v", args)
testdiff.AssertEqualTexts(t, expectedPath, actual, expected, out)
if OverwriteMode {
WriteFile(t, expectedPath, out)
}
}
}
func AssertOutputJQ(t testutil.TestingT, ctx context.Context, args []string, expectedPath string, ignorePaths []string) {
expected := ReadFile(t, ctx, expectedPath)
out := captureOutput(t, ctx, args)
if out != expected {
actual := fmt.Sprintf("Output from %v", args)
testdiff.AssertEqualJQ(t.(*testing.T), expectedPath, actual, expected, out, ignorePaths)
if OverwriteMode {
WriteFile(t, expectedPath, out)
}
}
}
var (
uuidRegex = regexp.MustCompile(`[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}`)
numIdRegex = regexp.MustCompile(`[0-9]{3,}`)
privatePathRegex = regexp.MustCompile(`(/tmp|/private)(/.*)/([a-zA-Z0-9]+)`)
)
func ReplaceOutput(t testutil.TestingT, ctx context.Context, out string) string {
out = NormalizeNewlines(out)
replacements := GetReplacementsMap(ctx)
if replacements == nil {
t.Fatal("WithReplacementsMap was not called")
}
out = replacements.Replace(out)
out = uuidRegex.ReplaceAllString(out, "<UUID>")
out = numIdRegex.ReplaceAllString(out, "<NUMID>")
out = privatePathRegex.ReplaceAllString(out, "/tmp/.../$3")
return out
}
type key int
const (
replacementsMapKey = key(1)
)
type Replacement struct {
Old string
New string
}
type ReplacementsContext struct {
Repls []Replacement
}
func (r *ReplacementsContext) Replace(s string) string {
// QQQ Should probably only replace whole words
for _, repl := range r.Repls {
s = strings.ReplaceAll(s, repl.Old, repl.New)
}
return s
}
func (r *ReplacementsContext) Set(old, new string) {
if old == "" || new == "" {
return
}
r.Repls = append(r.Repls, Replacement{Old: old, New: new})
}
func WithReplacementsMap(ctx context.Context) (context.Context, *ReplacementsContext) {
value := ctx.Value(replacementsMapKey)
if value != nil {
if existingMap, ok := value.(*ReplacementsContext); ok {
return ctx, existingMap
}
}
newMap := &ReplacementsContext{}
ctx = context.WithValue(ctx, replacementsMapKey, newMap)
return ctx, newMap
}
func GetReplacementsMap(ctx context.Context) *ReplacementsContext {
value := ctx.Value(replacementsMapKey)
if value != nil {
if existingMap, ok := value.(*ReplacementsContext); ok {
return existingMap
}
}
return nil
}
func PrepareReplacements(t testutil.TestingT, r *ReplacementsContext, w *databricks.WorkspaceClient) {
// in some clouds (gcp) w.Config.Host includes "https://" prefix in others it's really just a host (azure)
host := strings.TrimPrefix(strings.TrimPrefix(w.Config.Host, "http://"), "https://")
r.Set(host, "$DATABRICKS_HOST")
r.Set(w.Config.ClusterID, "$DATABRICKS_CLUSTER_ID")
r.Set(w.Config.WarehouseID, "$DATABRICKS_WAREHOUSE_ID")
r.Set(w.Config.ServerlessComputeID, "$DATABRICKS_SERVERLESS_COMPUTE_ID")
r.Set(w.Config.MetadataServiceURL, "$DATABRICKS_METADATA_SERVICE_URL")
r.Set(w.Config.AccountID, "$DATABRICKS_ACCOUNT_ID")
r.Set(w.Config.Token, "$DATABRICKS_TOKEN")
r.Set(w.Config.Username, "$DATABRICKS_USERNAME")
r.Set(w.Config.Password, "$DATABRICKS_PASSWORD")
r.Set(w.Config.Profile, "$DATABRICKS_CONFIG_PROFILE")
r.Set(w.Config.ConfigFile, "$DATABRICKS_CONFIG_FILE")
r.Set(w.Config.GoogleServiceAccount, "$DATABRICKS_GOOGLE_SERVICE_ACCOUNT")
r.Set(w.Config.GoogleCredentials, "$GOOGLE_CREDENTIALS")
r.Set(w.Config.AzureResourceID, "$DATABRICKS_AZURE_RESOURCE_ID")
r.Set(w.Config.AzureClientSecret, "$ARM_CLIENT_SECRET")
// r.Set(w.Config.AzureClientID, "$ARM_CLIENT_ID")
r.Set(w.Config.AzureClientID, "$USERNAME")
r.Set(w.Config.AzureTenantID, "$ARM_TENANT_ID")
r.Set(w.Config.ActionsIDTokenRequestURL, "$ACTIONS_ID_TOKEN_REQUEST_URL")
r.Set(w.Config.ActionsIDTokenRequestToken, "$ACTIONS_ID_TOKEN_REQUEST_TOKEN")
r.Set(w.Config.AzureEnvironment, "$ARM_ENVIRONMENT")
r.Set(w.Config.ClientID, "$DATABRICKS_CLIENT_ID")
r.Set(w.Config.ClientSecret, "$DATABRICKS_CLIENT_SECRET")
r.Set(w.Config.DatabricksCliPath, "$DATABRICKS_CLI_PATH")
// This is set to words like "path" that happen too frequently
// r.Set(w.Config.AuthType, "$DATABRICKS_AUTH_TYPE")
}
func PrepareReplacementsUser(t testutil.TestingT, r *ReplacementsContext, u iam.User) {
// There could be exact matches or overlap between different name fields, so sort them by length
// to ensure we match the largest one first and map them all to the same token
names := []string{
u.DisplayName,
u.UserName,
iamutil.GetShortUserName(&u),
u.Name.FamilyName,
u.Name.GivenName,
}
if u.Name != nil {
names = append(names, u.Name.FamilyName)
names = append(names, u.Name.GivenName)
}
for _, val := range u.Emails {
names = append(names, val.Value)
}
stableSortReverseLength(names)
for _, name := range names {
r.Set(name, "$USERNAME")
}
for ind, val := range u.Groups {
r.Set(val.Value, fmt.Sprintf("$USER.Groups[%d]", ind))
}
r.Set(u.Id, "$USER.Id")
for ind, val := range u.Roles {
r.Set(val.Value, fmt.Sprintf("$USER.Roles[%d]", ind))
}
}
func stableSortReverseLength(strs []string) {
slices.SortStableFunc(strs, func(a, b string) int {
return len(b) - len(a)
})
}
func NormalizeNewlines(input string) string {
output := strings.ReplaceAll(input, "\r\n", "\n")
return strings.ReplaceAll(output, "\r", "\n")
}

View File

@ -0,0 +1,13 @@
package testcli
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSort(t *testing.T) {
input := []string{"a", "bc", "cd"}
stableSortReverseLength(input)
assert.Equal(t, []string{"bc", "cd", "a"}, input)
}

View File

@ -47,6 +47,9 @@ func Chdir(t TestingT, dir string) string {
wd, err := os.Getwd()
require.NoError(t, err)
if os.Getenv("TESTS_ORIG_WD") == "" {
t.Setenv("TESTS_ORIG_WD", wd)
}
abs, err := filepath.Abs(dir)
require.NoError(t, err)
@ -61,3 +64,10 @@ func Chdir(t TestingT, dir string) string {
return wd
}
// Return filename ff testutil.Chdir was not called.
// Return absolute path to filename testutil.Chdir() was called.
func TestData(filename string) string {
// Note, if TESTS_ORIG_WD is not set, Getenv return "" and Join returns filename
return filepath.Join(os.Getenv("TESTS_ORIG_WD"), filename)
}

90
libs/testdiff/testdiff.go Normal file
View File

@ -0,0 +1,90 @@
package testdiff
import (
"fmt"
"strings"
"github.com/databricks/cli/internal/testutil"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/stretchr/testify/assert"
"github.com/wI2L/jsondiff"
)
func UnifiedDiff(filename1, filename2, s1, s2 string) string {
edits := myers.ComputeEdits(span.URIFromPath(filename1), s1, s2)
return fmt.Sprint(gotextdiff.ToUnified(filename1, filename2, s1, edits))
}
func AssertEqualTexts(t testutil.TestingT, filename1, filename2, expected, out string) {
if len(out) < 1000 && len(expected) < 1000 {
// This shows full strings + diff which could be useful when debugging newlines
assert.Equal(t, expected, out)
} else {
// only show diff for large texts
diff := UnifiedDiff(filename1, filename2, expected, out)
t.Errorf("Diff:\n" + diff)
}
}
func AssertEqualJQ(t testutil.TestingT, expectedName, outName, expected, out string, ignorePaths []string) {
patch, err := jsondiff.CompareJSON([]byte(expected), []byte(out))
if err != nil {
t.Logf("CompareJSON error for %s vs %s: %s (fallback to textual comparison)", outName, expectedName, err)
AssertEqualTexts(t, expectedName, outName, expected, out)
} else {
diff := UnifiedDiff(expectedName, outName, expected, out)
t.Logf("Diff:\n%s", diff)
allowedDiffs := []string{}
erroredDiffs := []string{}
for _, op := range patch {
if allowDifference(ignorePaths, op) {
allowedDiffs = append(allowedDiffs, fmt.Sprintf("%7s %s %v old=%v", op.Type, op.Path, op.Value, op.OldValue))
} else {
erroredDiffs = append(erroredDiffs, fmt.Sprintf("%7s %s %v old=%v", op.Type, op.Path, op.Value, op.OldValue))
}
}
if len(allowedDiffs) > 0 {
t.Logf("Allowed differences between %s and %s:\n ==> %s", expectedName, outName, strings.Join(allowedDiffs, "\n ==> "))
}
if len(erroredDiffs) > 0 {
t.Errorf("Unexpected differences between %s and %s:\n ==> %s", expectedName, outName, strings.Join(erroredDiffs, "\n ==> "))
}
}
}
func allowDifference(ignorePaths []string, op jsondiff.Operation) bool {
if matchesPrefixes(ignorePaths, op.Path) {
return true
}
if op.Type == "replace" && almostSameStrings(op.OldValue, op.Value) {
return true
}
return false
}
// compare strings and ignore forward vs backward slashes
func almostSameStrings(v1, v2 any) bool {
s1, ok := v1.(string)
if !ok {
return false
}
s2, ok := v2.(string)
if !ok {
return false
}
return strings.ReplaceAll(s1, "\\", "/") == strings.ReplaceAll(s2, "\\", "/")
}
func matchesPrefixes(prefixes []string, path string) bool {
for _, p := range prefixes {
if p == path {
return true
}
if strings.HasPrefix(path, p+"/") {
return true
}
}
return false
}

View File

@ -0,0 +1,20 @@
package testdiff
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiff(t *testing.T) {
assert.Equal(t, "", UnifiedDiff("a", "b", "", ""))
assert.Equal(t, "", UnifiedDiff("a", "b", "abc", "abc"))
assert.Equal(t, "--- a\n+++ b\n@@ -1 +1,2 @@\n abc\n+123\n", UnifiedDiff("a", "b", "abc\n", "abc\n123\n"))
}
func TestMatchesPrefixes(t *testing.T) {
assert.False(t, matchesPrefixes([]string{}, ""))
assert.False(t, matchesPrefixes([]string{"/hello", "/hello/world"}, ""))
assert.True(t, matchesPrefixes([]string{"/hello", "/a/b"}, "/hello"))
assert.True(t, matchesPrefixes([]string{"/hello", "/a/b"}, "/a/b/c"))
}