diff --git a/README.md b/README.md index 8034037..c847fc9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,45 @@ import qascsv "github.com/hypersequent/qasphere-csv" Refer to the [basic example](examples/basic/main.go) for API usage. +## Steps and Test Data + +`Step.Action` and `Step.Expected` remain the API for ordinary test steps. The library serializes all steps into QA Sphere's modern JSON `Steps` CSV column: + +```go +Steps: []qascsv.Step{{ + Action: "Run the query", + Expected: "One row is returned", + Data: []qascsv.StepData{ + qascsv.StepDataText{Label: "Query", Value: "SELECT 1", Format: "sql"}, + qascsv.StepDataLink{Label: "Reference", Value: "https://example.com/spec"}, + qascsv.StepDataFile{Label: "Fixture", Value: qascsv.File{ + Name: "fixture.csv", ID: "uploaded-file-id", URL: "https://example.com/fixture.csv", + MimeType: "text/csv", Size: 128, + }}, + }, +}} +``` + +Shared steps can reference an existing QA Sphere shared step by ID, or include a title and sub-steps so the shared step can be recreated during import. Test data belongs on sub-steps, not directly on the shared row: + +```go +Steps: []qascsv.Step{{ + SharedStepID: 42, +}, { + Title: "Sign in", + SubSteps: []qascsv.Step{{ + Action: "Enter the credentials", + Data: []qascsv.StepData{ + qascsv.StepDataText{Label: "User", Value: "alice"}, + }, + }}, +}} +``` + +Each standalone step or shared-step sub-step accepts at most 20 data items. Labels and shared-step titles are limited to 255 Unicode characters. Text values are required and limited to 65,535 UTF-16 code units; their optional formats are limited to 32 characters. Link values must be HTTP(S) URLs no longer than 255 characters. File data uses the same validation and JSON shape as test-case files. + +> **Migration note:** generated CSVs now always contain one JSON `Steps` column after `Preconditions`. Earlier releases generated variable `Step N` and `Expected N` columns; consumers that inspect headers or post-process CSV output must update to the modern column. + ## Importing Test Cases on QA Sphere 1. Create a new Project, if not already done. diff --git a/examples/basic/main.go b/examples/basic/main.go index 0f876c6..75f89ef 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -20,6 +20,10 @@ func main() { Preconditions: "The \"About Us\" page is opened", Steps: []qascsv.Step{{ Action: "Test the display across various screen sizes (desktop, tablet, mobile) to ensure that blocks and buttons adjust appropriately to different viewport widths", + Data: []qascsv.StepData{ + qascsv.StepDataText{Label: "Viewports", Value: "1440x900, 768x1024, 390x844"}, + qascsv.StepDataLink{Label: "Design", Value: "https://example.com/about-us-design"}, + }, }}, }); err != nil { log.Fatal("failed to add single test case", err) @@ -38,6 +42,15 @@ func main() { }, { Action: "Click the \"Cart\" icon", Expected: "The empty state is shown in the \"Cart\" modal", + }, { + Title: "Verify an empty cart", + SubSteps: []qascsv.Step{{ + Action: "Inspect the cart item count", + Expected: "The item count is zero", + Data: []qascsv.StepData{ + qascsv.StepDataText{Label: "Expected count", Value: "0"}, + }, + }}, }}, }, { Title: "Changing to corresponding cursor after hovering the element", diff --git a/qacsv_test.go b/qacsv_test.go index 62d7844..73c34cd 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -127,11 +127,11 @@ var successTestCases = []TestCase{ }, } -const successTestCasesCSV = `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,Step 2,Expected 2 -root/child,,standalone,tc-with-all-fields,legacy-id,false,high,"tag1,tag2",[req1](http://req1),"[link-1](http://link1),[link-2](http://link2)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",preconditions,,,action-1,expected-1,action-2,expected-2 -root/child,,standalone,"tc-with-special-chars.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",legacy-id,false,high,"tag1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[req.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;]()","[link-1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;](http://link1)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]","preconditions.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",,,"action.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","expected.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",, -root,,standalone,tc-with-minimal-fields,,false,high,,,,,,,,,,, -root,,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",,,,action-1,,,expected-2 +const successTestCasesCSV = `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params +root/child,,standalone,tc-with-all-fields,legacy-id,false,high,"tag1,tag2",[req1](http://req1),"[link-1](http://link1),[link-2](http://link2)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",preconditions,"[{""description"":""action-1"",""expected"":""expected-1""},{""description"":""action-2"",""expected"":""expected-2""}]",, +root/child,,standalone,"tc-with-special-chars.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",legacy-id,false,high,"tag1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[req.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;]()","[link-1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;](http://link1)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]","preconditions.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[{""description"":""action.,<>/@$%\""\""''*&()[]{}+-[BACKTICK]!~;"",""expected"":""expected.,<>/@$%\""\""''*&()[]{}+-[BACKTICK]!~;""}]",, +root,,standalone,tc-with-minimal-fields,,false,high,,,,,,,, +root,,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",,"[{""description"":""action-1""},{""expected"":""expected-2""}]",, ` var failureTestCases = []TestCase{ @@ -355,12 +355,12 @@ var customFieldSuccessTestCases = []TestCase{ }, } -const customFieldSuccessTestCasesCSV = `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,custom_field_dropdown_test_env,custom_field_dropdown_automation,custom_field_text_notes -custom-fields,,standalone,tc-with-single-custom-field,,false,medium,,,,,,,,,,"{""value"":""staging"",""isDefault"":false}",, -custom-fields,,standalone,tc-with-multiple-custom-fields,,false,high,"regression,smoke",,,,,,,Execute test,Test passes,"{""value"":""production"",""isDefault"":false}","{""value"":""Automated"",""isDefault"":false}","{""value"":""This is a test note with special chars: !@#$%^&*()"",""isDefault"":false}" -custom-fields,,standalone,tc-with-empty-custom-field-value,,false,low,,,,,,,,,,,,"{""value"":"""",""isDefault"":false}" -custom-fields,,standalone,tc-with-default-custom-field,,false,medium,,,,,,,,,,,"{""value"":"""",""isDefault"":false}", -custom-fields/comprehensive,,standalone,tc-with-all-fields-and-custom-fields,CF-001,false,high,"custom,comprehensive",[CF Requirements](http://cf-req),[CF Link](http://cf-link),"[{""fileName"":""cf-test.txt"",""id"":""cf-file-id"",""url"":""http://cf-file"",""mimeType"":""text/plain"",""size"":100}]",Custom field test setup,,,Step 1,Result 1,"{""value"":""development"",""isDefault"":false}","{""value"":""In Progress"",""isDefault"":false}", +const customFieldSuccessTestCasesCSV = `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params,custom_field_dropdown_test_env,custom_field_dropdown_automation,custom_field_text_notes +custom-fields,,standalone,tc-with-single-custom-field,,false,medium,,,,,,,,,"{""value"":""staging"",""isDefault"":false}",, +custom-fields,,standalone,tc-with-multiple-custom-fields,,false,high,"regression,smoke",,,,,"[{""description"":""Execute test"",""expected"":""Test passes""}]",,,"{""value"":""production"",""isDefault"":false}","{""value"":""Automated"",""isDefault"":false}","{""value"":""This is a test note with special chars: !@#$%^&*()"",""isDefault"":false}" +custom-fields,,standalone,tc-with-empty-custom-field-value,,false,low,,,,,,,,,,,"{""value"":"""",""isDefault"":false}" +custom-fields,,standalone,tc-with-default-custom-field,,false,medium,,,,,,,,,,"{""value"":"""",""isDefault"":false}", +custom-fields/comprehensive,,standalone,tc-with-all-fields-and-custom-fields,CF-001,false,high,"custom,comprehensive",[CF Requirements](http://cf-req),[CF Link](http://cf-link),"[{""fileName"":""cf-test.txt"",""id"":""cf-file-id"",""url"":""http://cf-file"",""mimeType"":""text/plain"",""size"":100}]",Custom field test setup,"[{""description"":""Step 1"",""expected"":""Result 1""}]",,,"{""value"":""development"",""isDefault"":false}","{""value"":""In Progress"",""isDefault"":false}", ` var customFieldFailureTestCases = []TestCase{ @@ -530,8 +530,8 @@ func TestFolderSlashEscaping(t *testing.T) { csv, err := qasCSV.GenerateCSV() require.NoError(t, err) - expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params -root\/parent/child\/leaf,,standalone,tc-in-slash-folder,,false,high,,,,,,, + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params +root\/parent/child\/leaf,,standalone,tc-in-slash-folder,,false,high,,,,,,,, ` require.Equal(t, expected, csv) } @@ -557,8 +557,8 @@ func TestAddFolderEmpty(t *testing.T) { csv, err := qasCSV.GenerateCSV() require.NoError(t, err) - expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params -empty-folder,,,,,,,,,,,,, + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params +empty-folder,,,,,,,,,,,,,, ` require.Equal(t, expected, csv) } @@ -575,8 +575,8 @@ func TestAddFolderWithComment(t *testing.T) { csv, err := qasCSV.GenerateCSV() require.NoError(t, err) - expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params -commented-folder,This is a folder comment,,,,,,,,,,,, + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params +commented-folder,This is a folder comment,,,,,,,,,,,,, ` require.Equal(t, expected, csv) } @@ -600,9 +600,9 @@ func TestAddFolderWithCommentAndTestCases(t *testing.T) { csv, err := qasCSV.GenerateCSV() require.NoError(t, err) - expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params -my-folder,Folder description,,,,,,,,,,,, -my-folder,,standalone,tc-in-commented-folder,,false,high,,,,,,, + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params +my-folder,Folder description,,,,,,,,,,,,, +my-folder,,standalone,tc-in-commented-folder,,false,high,,,,,,,, ` require.Equal(t, expected, csv) } @@ -662,8 +662,155 @@ func TestAddFolderWithSlashInName(t *testing.T) { csv, err := qasCSV.GenerateCSV() require.NoError(t, err) - expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params -folder\/with\/slashes/child,slash comment,,,,,,,,,,,, + expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Steps,Parameter Values,Template Suffix Params +folder\/with\/slashes/child,slash comment,,,,,,,,,,,,, ` require.Equal(t, expected, csv) } + +func TestModernStepsSerialization(t *testing.T) { + file := File{ + Name: "evidence.txt", + ID: "file-id", + URL: "https://files.example.com/evidence.txt", + MimeType: "text/plain", + Size: 12, + } + qasCSV := NewQASphereCSV() + require.NoError(t, qasCSV.AddTestCase(TestCase{ + Title: "modern steps", + FolderPath: []string{"root"}, + Priority: PriorityHigh, + Steps: []Step{ + {}, + {Action: "Open the page", Expected: "The page opens"}, + {Data: []StepData{ + StepDataText{Label: "Input", Value: "SELECT 1", Format: "sql"}, + StepDataLink{Value: "https://example.com"}, + StepDataFile{Label: "Evidence", Value: file}, + }}, + {SharedStepID: 42}, + { + Title: "Sign in", + SubSteps: []Step{ + {}, + {Action: "Enter credentials", Data: []StepData{StepDataText{Value: "alice"}}}, + }, + }, + }, + })) + + csvText, err := qasCSV.GenerateCSV() + require.NoError(t, err) + records, err := csv.NewReader(strings.NewReader(csvText)).ReadAll() + require.NoError(t, err) + require.Len(t, records, 2) + require.Equal(t, "Steps", records[0][12]) + require.Equal(t, "Parameter Values", records[0][13]) + require.JSONEq(t, `[ + {"description":"Open the page","expected":"The page opens"}, + {"data":[ + {"type":"text","label":"Input","data":{"value":"SELECT 1","format":"sql"}}, + {"type":"link","data":{"value":"https://example.com"}}, + {"type":"file","label":"Evidence","data":{"value":{"fileName":"evidence.txt","id":"file-id","url":"https://files.example.com/evidence.txt","mimeType":"text/plain","size":12}}} + ]}, + {"sharedStepId":42}, + {"title":"Sign in","subSteps":[{"description":"Enter credentials","data":[{"type":"text","data":{"value":"alice"}}]}]} + ]`, records[1][12]) +} + +func TestEmptyStepsCell(t *testing.T) { + qasCSV := NewQASphereCSV() + require.NoError(t, qasCSV.AddTestCase(testCaseWithSteps([]Step{{}, {Action: "", Expected: ""}}))) + + csvText, err := qasCSV.GenerateCSV() + require.NoError(t, err) + records, err := csv.NewReader(strings.NewReader(csvText)).ReadAll() + require.NoError(t, err) + require.Empty(t, records[1][12]) +} + +func TestStepDataValidationBoundaries(t *testing.T) { + validFile := File{Name: "file", ID: "id", URL: "https://example.com/file", MimeType: "text/plain", Size: 1} + validURL := "https://example.com/" + strings.Repeat("a", 255-len("https://example.com/")) + validCases := map[string][]Step{ + "20 data items": {{Data: repeatStepData(20)}}, + "20 sub-step data items": {{Title: "shared", SubSteps: []Step{{Data: repeatStepData(20)}}}}, + "255 character label": {{Data: []StepData{StepDataText{Label: strings.Repeat("界", 255), Value: "v"}}}}, + "65535 UTF-16 code units": {{Data: []StepData{StepDataText{Value: strings.Repeat("😀", 32767) + "a"}}}}, + "32 character format": {{Data: []StepData{StepDataText{Value: "v", Format: strings.Repeat("界", 32)}}}}, + "255 character link": {{Data: []StepData{StepDataLink{Value: validURL}}}}, + "valid file": {{Data: []StepData{StepDataFile{Value: validFile}}}}, + } + for name, steps := range validCases { + t.Run(name, func(t *testing.T) { + require.NoError(t, NewQASphereCSV().AddTestCase(testCaseWithSteps(steps))) + }) + } +} + +func TestStepValidationFailures(t *testing.T) { + var nilText *StepDataText + invalidCases := map[string][]Step{ + "too many data items": {{Data: repeatStepData(21)}}, + "too many sub-step data items": {{Title: "shared", SubSteps: []Step{{Data: repeatStepData(21)}}}}, + "long label": {{Data: []StepData{StepDataText{Label: strings.Repeat("界", 256), Value: "v"}}}}, + "missing text": {{Data: []StepData{StepDataText{}}}}, + "long UTF-16 text": {{Data: []StepData{StepDataText{Value: strings.Repeat("😀", 32768)}}}}, + "long format": {{Data: []StepData{StepDataText{Value: "v", Format: strings.Repeat("界", 33)}}}}, + "missing link": {{Data: []StepData{StepDataLink{}}}}, + "non-HTTP link": {{Data: []StepData{StepDataLink{Value: "ftp://example.com/file"}}}}, + "long link": {{Data: []StepData{StepDataLink{Value: "https://example.com/" + strings.Repeat("a", 256)}}}}, + "invalid file": {{Data: []StepData{StepDataFile{Value: File{}}}}}, + "nil data": {{Data: []StepData{nil}}}, + "typed nil data": {{Data: []StepData{nilText}}}, + "negative shared ID": {{SharedStepID: -1}}, + "long shared title": {{Title: strings.Repeat("界", 256)}}, + "shared action": {{Title: "shared", Action: "not allowed"}}, + "shared expected": {{SharedStepID: 1, Expected: "not allowed"}}, + "shared data": {{Title: "shared", Data: []StepData{StepDataText{Value: "v"}}}}, + "sub-steps without shared metadata": {{SubSteps: []Step{{Action: "child"}}}}, + "sub-steps on standalone content": {{Action: "action", SubSteps: []Step{{Action: "child"}}}}, + "nested shared ID": {{Title: "shared", SubSteps: []Step{{SharedStepID: 1}}}}, + "nested shared title": {{Title: "shared", SubSteps: []Step{{Title: "nested"}}}}, + "nested sub-steps": {{Title: "shared", SubSteps: []Step{{SubSteps: []Step{{Action: "nested"}}}}}}, + } + for name, steps := range invalidCases { + t.Run(name, func(t *testing.T) { + require.Error(t, NewQASphereCSV().AddTestCase(testCaseWithSteps(steps))) + }) + } +} + +func TestAddTestCasesStepValidationIsAtomicAndIndexed(t *testing.T) { + qasCSV := NewQASphereCSV() + testCases := []TestCase{ + testCaseWithSteps([]Step{{Action: "valid"}}), + testCaseWithSteps([]Step{{Data: repeatStepData(21)}}), + } + testCases[0].Title = "valid" + testCases[1].Title = "invalid" + + err := qasCSV.AddTestCases(testCases) + require.Error(t, err) + require.Contains(t, err.Error(), "test case 1") + require.Zero(t, qasCSV.numTCases) + require.Empty(t, qasCSV.folderOrder) +} + +func testCaseWithSteps(steps []Step) TestCase { + return TestCase{ + Title: "steps test", + FolderPath: []string{"root"}, + Priority: PriorityHigh, + Steps: steps, + } +} + +func repeatStepData(count int) []StepData { + items := make([]StepData, count) + for i := range items { + items[i] = StepDataText{Value: "value"} + } + return items +} diff --git a/qascsv.go b/qascsv.go index 4b53781..e58a539 100644 --- a/qascsv.go +++ b/qascsv.go @@ -8,9 +8,11 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "strconv" "strings" + "unicode/utf16" "unicode/utf8" "github.com/go-playground/validator/v10" @@ -18,11 +20,11 @@ import ( "github.com/pkg/errors" ) -// staticColumns will always be present in the CSV file -// but there can be additional columns for steps and custom fields. +// staticColumns will always be present in the CSV file, +// but there can be additional columns for custom fields. var staticColumns = []string{ "Folder", "Folder Comment", "Type", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements", - "Links", "Files", "Preconditions", "Parameter Values", "Template Suffix Params", + "Links", "Files", "Preconditions", "Steps", "Parameter Values", "Template Suffix Params", } // Priority represents the priority of a test case in QA Sphere. @@ -74,8 +76,50 @@ type Step struct { Action string // The expected result of the action. Markdown is supported. (optional) Expected string + // Data is the ordered test data associated with a standalone step or a + // shared step's sub-step. A step can contain at most 20 data items. (optional) + Data []StepData + // SharedStepID references an existing shared step. A positive ID marks this + // row as a shared step. (optional) + SharedStepID int + // Title names a shared step and can be used with SubSteps to recreate one + // during import. (optional) + Title string + // SubSteps contains the standalone steps embedded in a shared step. (optional) + SubSteps []Step } +// StepData is a test data item attached to a standalone step or shared-step +// sub-step. The concrete type determines the JSON discriminator. +type StepData interface { + isStepData() +} + +// StepDataText represents text or source-code test data. +type StepDataText struct { + Label string + Value string + Format string +} + +func (StepDataText) isStepData() {} + +// StepDataLink represents an HTTP(S) link used as test data. +type StepDataLink struct { + Label string + Value string +} + +func (StepDataLink) isStepData() {} + +// StepDataFile represents an uploaded file used as test data. +type StepDataFile struct { + Label string + Value File +} + +func (StepDataFile) isStepData() {} + // ParameterValue represents parameter values that you provide for template test cases. // Template test cases are test cases where the body of the test case can contain some placeholders // of the form ${parameter_name}. Then the users need to provide the values for these parameters @@ -186,7 +230,6 @@ type QASphereCSV struct { customFields []CustomField numTCases int - maxSteps int } func NewQASphereCSV() *QASphereCSV { @@ -345,10 +388,147 @@ func (q *QASphereCSV) validateTestCase(tc TestCase) error { } } } + if err := q.validateSteps(tc.Steps); err != nil { + return err + } return q.validate.Struct(tc) } +func (q *QASphereCSV) validateSteps(steps []Step) error { + for i, step := range steps { + if err := q.validateStep(step, false); err != nil { + return errors.Wrapf(err, "steps[%d]", i) + } + } + return nil +} + +func (q *QASphereCSV) validateStep(step Step, subStep bool) error { + if step.SharedStepID < 0 { + return errors.New("shared step ID cannot be negative") + } + + if subStep { + if step.SharedStepID != 0 || step.Title != "" || step.SubSteps != nil { + return errors.New("shared-step metadata is not allowed on a sub-step") + } + return q.validateStepData(step.Data) + } + + isShared := step.SharedStepID != 0 || step.Title != "" + if !isShared { + if step.SubSteps != nil { + return errors.New("sub-steps are not allowed on a standalone step") + } + return q.validateStepData(step.Data) + } + if utf8.RuneCountInString(step.Title) > 255 { + return errors.New("shared step title must not exceed 255 characters") + } + if step.Action != "" || step.Expected != "" || len(step.Data) > 0 { + return errors.New("action, expected result, and data are not allowed directly on a shared step") + } + for i, child := range step.SubSteps { + if err := q.validateStep(child, true); err != nil { + return errors.Wrapf(err, "subSteps[%d]", i) + } + } + return nil +} + +func (q *QASphereCSV) validateStepData(items []StepData) error { + if len(items) > 20 { + return errors.New("step data must not contain more than 20 items") + } + for i, item := range items { + if err := q.validateStepDataItem(item); err != nil { + return errors.Wrapf(err, "data[%d]", i) + } + } + return nil +} + +func (q *QASphereCSV) validateStepDataItem(item StepData) error { + switch value := item.(type) { + case StepDataText: + return validateStepDataText(value) + case *StepDataText: + if value == nil { + return errors.New("step data item cannot be nil") + } + return validateStepDataText(*value) + case StepDataLink: + return validateStepDataLink(value) + case *StepDataLink: + if value == nil { + return errors.New("step data item cannot be nil") + } + return validateStepDataLink(*value) + case StepDataFile: + if err := validateStepDataLabel(value.Label); err != nil { + return err + } + return errors.Wrap(q.validate.Struct(value.Value), "file validation") + case *StepDataFile: + if value == nil { + return errors.New("step data item cannot be nil") + } + if err := validateStepDataLabel(value.Label); err != nil { + return err + } + return errors.Wrap(q.validate.Struct(value.Value), "file validation") + case nil: + return errors.New("step data item cannot be nil") + default: + return errors.Errorf("unsupported step data type %T", item) + } +} + +func validateStepDataText(value StepDataText) error { + if err := validateStepDataLabel(value.Label); err != nil { + return err + } + if value.Value == "" { + return errors.New("text value is required") + } + if utf16Length(value.Value) > 65535 { + return errors.New("text value must not exceed 65,535 UTF-16 code units") + } + if utf8.RuneCountInString(value.Format) > 32 { + return errors.New("text format must not exceed 32 characters") + } + return nil +} + +func validateStepDataLink(value StepDataLink) error { + if err := validateStepDataLabel(value.Label); err != nil { + return err + } + if value.Value == "" { + return errors.New("link value is required") + } + if utf8.RuneCountInString(value.Value) > 255 { + return errors.New("link value must not exceed 255 characters") + } + parsed, err := url.Parse(value.Value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return errors.New("link value must be a valid HTTP(S) URL") + } + return nil +} + +func validateStepDataLabel(label string) error { + if utf8.RuneCountInString(label) > 255 { + return errors.New("step data label must not exceed 255 characters") + } + return nil +} + +func utf16Length(value string) int { + return len(utf16.Encode([]rune(value))) +} + func (q *QASphereCSV) addTCase(tc TestCase) { folderPath := escapeFolderPath(tc.FolderPath) if _, exists := q.folderTCaseMap[folderPath]; !exists { @@ -357,9 +537,6 @@ func (q *QASphereCSV) addTCase(tc TestCase) { q.folderTCaseMap[folderPath] = append(q.folderTCaseMap[folderPath], tc) q.numTCases++ - if len(tc.Steps) > q.maxSteps { - q.maxSteps = len(tc.Steps) - } } func (q *QASphereCSV) getFolders() []string { @@ -378,12 +555,9 @@ func jsonMarshal(v any) ([]byte, error) { func (q *QASphereCSV) getCSVRows() ([][]string, error) { rows := make([][]string, 0, q.numTCases+1) - numCols := len(staticColumns) + 2*q.maxSteps + len(q.customFields) + numCols := len(staticColumns) + len(q.customFields) rows = append(rows, append(make([]string, 0, numCols), staticColumns...)) - for i := 0; i < q.maxSteps; i++ { - rows[0] = append(rows[0], fmt.Sprintf("Step %d", i+1), fmt.Sprintf("Expected %d", i+1)) - } customFieldsMap := make(map[string]int) for i, cf := range q.customFields { @@ -438,21 +612,25 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { parameterValues = string(parameterValuesb) } + var steps string + stepsJSON, err := marshalSteps(tc.Steps) + if err != nil { + return nil, errors.Wrap(err, "json marshal steps") + } + if len(stepsJSON) > 0 { + stepsb, err := jsonMarshal(stepsJSON) + if err != nil { + return nil, errors.Wrap(err, "json marshal steps") + } + steps = string(stepsb) + } + row := make([]string, 0, numCols) row = append(row, f, "", string(tc.Type), tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft), string(tc.Priority), strings.Join(tc.Tags, ","), strings.Join(requirements, ","), - strings.Join(links, ","), files, tc.Preconditions, parameterValues, + strings.Join(links, ","), files, tc.Preconditions, steps, parameterValues, strings.Join(tc.FilledTCaseTitleSuffixParams, ",")) - numSteps := len(tc.Steps) - for i := 0; i < q.maxSteps; i++ { - if i < numSteps { - row = append(row, tc.Steps[i].Action, tc.Steps[i].Expected) - } else { - row = append(row, "", "") - } - } - customFieldCols := make([]string, len(customFieldsMap)) for systemName, cfValue := range tc.CustomFields { cfValueJSON, err := jsonMarshal(cfValue) @@ -471,6 +649,108 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) { return rows, nil } +type stepJSON struct { + Description string `json:"description,omitempty"` + Expected string `json:"expected,omitempty"` + Data []stepDataJSON `json:"data,omitempty"` + SharedStepID int `json:"sharedStepId,omitempty"` + Title string `json:"title,omitempty"` + SubSteps []stepJSON `json:"subSteps,omitempty"` +} + +type stepDataJSON struct { + Type string `json:"type"` + Label string `json:"label,omitempty"` + Data any `json:"data"` +} + +type stepDataTextJSON struct { + Value string `json:"value"` + Format string `json:"format,omitempty"` +} + +type stepDataValueJSON[T any] struct { + Value T `json:"value"` +} + +func marshalSteps(steps []Step) ([]stepJSON, error) { + result := make([]stepJSON, 0, len(steps)) + for _, step := range steps { + converted, keep, err := marshalStep(step) + if err != nil { + return nil, err + } + if keep { + result = append(result, converted) + } + } + return result, nil +} + +func marshalStep(step Step) (stepJSON, bool, error) { + data, err := marshalStepData(step.Data) + if err != nil { + return stepJSON{}, false, err + } + subSteps, err := marshalSteps(step.SubSteps) + if err != nil { + return stepJSON{}, false, err + } + result := stepJSON{ + Description: step.Action, + Expected: step.Expected, + Data: data, + SharedStepID: step.SharedStepID, + Title: step.Title, + SubSteps: subSteps, + } + keep := result.Description != "" || result.Expected != "" || len(result.Data) > 0 || + result.SharedStepID != 0 || result.Title != "" || len(result.SubSteps) > 0 + return result, keep, nil +} + +func marshalStepData(items []StepData) ([]stepDataJSON, error) { + result := make([]stepDataJSON, 0, len(items)) + for _, item := range items { + converted, err := marshalStepDataItem(item) + if err != nil { + return nil, err + } + result = append(result, converted) + } + return result, nil +} + +func marshalStepDataItem(item StepData) (stepDataJSON, error) { + switch value := item.(type) { + case StepDataText: + return stepDataJSON{Type: "text", Label: value.Label, Data: stepDataTextJSON{Value: value.Value, Format: value.Format}}, nil + case *StepDataText: + if value == nil { + return stepDataJSON{}, errors.New("step data item cannot be nil") + } + return marshalStepDataItem(*value) + case StepDataLink: + return stepDataJSON{Type: "link", Label: value.Label, Data: stepDataValueJSON[string]{Value: value.Value}}, nil + case *StepDataLink: + if value == nil { + return stepDataJSON{}, errors.New("step data item cannot be nil") + } + return marshalStepDataItem(*value) + case StepDataFile: + return stepDataJSON{Type: "file", Label: value.Label, Data: stepDataValueJSON[File]{Value: value.Value}}, nil + case *StepDataFile: + if value == nil { + return stepDataJSON{}, errors.New("step data item cannot be nil") + } + return marshalStepDataItem(*value) + case nil: + return stepDataJSON{}, errors.New("step data item cannot be nil") + default: + return stepDataJSON{}, errors.Errorf("unsupported step data type %T", item) + } +} + func (q *QASphereCSV) writeCSV(w io.Writer) error { rows, err := q.getCSVRows() if err != nil {