diff --git a/README.md b/README.md index 8bb9642..8034037 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,34 @@ Refer to the [basic example](examples/basic/main.go) for API usage. For more details, please check the [documentation](https://docs.qasphere.com/). +## Custom Fields + +Custom fields must be declared with `AddCustomField`/`AddCustomFields` before adding test cases that use them. Three types are supported: + +- `text` — plain text, no length limit. +- `dropdown` — the value must match one of the options defined for the field in QA Sphere (option values are limited to 255 characters). +- `richtext` — rich text, no length limit. **Values are HTML** (e.g. `

`, `
`), unlike `Preconditions` and `Steps`, which take markdown. QA Sphere sanitizes the HTML on import using an allowlist of tags and attributes. + +For example, to populate QA Sphere's rich text Description field: + +```go +qasCSV := qascsv.NewQASphereCSV() +_ = qasCSV.AddCustomField(qascsv.CustomField{ + SystemName: "description", + Type: qascsv.CustomFieldTypeRichtext, +}) +_ = qasCSV.AddTestCase(qascsv.TestCase{ + Title: "Login with valid credentials", + FolderPath: []string{"Auth"}, + Priority: qascsv.PriorityHigh, + CustomFields: map[string]qascsv.CustomFieldValue{ + "description": {Value: "

Verifies the standard login flow.

"}, + }, +}) +``` + +This produces a `custom_field_richtext_description` column matching QA Sphere's CSV export format. + ## Contributing We welcome contributions! If you have a feature request, encounter a problem, or have questions, please [create a new issue](https://github.com/Hypersequent/qasphere-csv/issues/new/choose). You can also contribute by opening a pull request. diff --git a/qacsv_test.go b/qacsv_test.go index 35fab5a..62d7844 100644 --- a/qacsv_test.go +++ b/qacsv_test.go @@ -1,6 +1,8 @@ package qascsv import ( + "encoding/csv" + "encoding/json" "io" "os" "strings" @@ -373,12 +375,12 @@ var customFieldFailureTestCases = []TestCase{ }, }, { - Title: "tc-with-very-long-custom-field-value", + Title: "tc-with-very-long-dropdown-value", FolderPath: []string{"custom-fields-errors"}, Priority: "medium", CustomFields: map[string]CustomFieldValue{ - "notes": { - Value: strings.Repeat("a", 256), // Exceeds 255 char limit + "test_env": { + Value: strings.Repeat("a", 256), // Dropdown options are limited to 255 chars }, }, }, @@ -457,6 +459,64 @@ func TestCustomFieldFailureTestCases(t *testing.T) { } } +func TestRichtextCustomField(t *testing.T) { + qasCSV := NewQASphereCSV() + require.NoError(t, qasCSV.AddCustomField(CustomField{ + SystemName: "description", + Type: CustomFieldTypeRichtext, + })) + + // Long multi-line HTML value, well over 255 chars, with quotes and commas + // to exercise CSV and JSON escaping + longHTML := "

This is a \"long\" description, with commas.

\n" + + "
func main() {\n\tfmt.Println(\"hello\")\n}
\n" + + "

" + strings.Repeat("Lorem ipsum dolor sit amet. ", 20) + "

" + require.Greater(t, len(longHTML), 255) + + require.NoError(t, qasCSV.AddTestCase(TestCase{ + Title: "tc-with-richtext-description", + FolderPath: []string{"richtext"}, + Priority: "medium", + CustomFields: map[string]CustomFieldValue{ + "description": {Value: longHTML}, + }, + })) + + csvStr, err := qasCSV.GenerateCSV() + require.NoError(t, err) + + // Parse the CSV back and verify the value round-trips + records, err := csv.NewReader(strings.NewReader(csvStr)).ReadAll() + require.NoError(t, err) + require.Len(t, records, 2) + + header := records[0] + require.Equal(t, "custom_field_richtext_description", header[len(header)-1]) + + var cfValue CustomFieldValue + require.NoError(t, json.Unmarshal([]byte(records[1][len(header)-1]), &cfValue)) + require.Equal(t, longHTML, cfValue.Value) +} + +func TestLongTextCustomFieldValue(t *testing.T) { + qasCSV := NewQASphereCSV() + require.NoError(t, qasCSV.AddCustomField(CustomField{ + SystemName: "notes", + Type: CustomFieldTypeText, + })) + + // Text custom field values have no length limit + err := qasCSV.AddTestCase(TestCase{ + Title: "tc-with-long-text-value", + FolderPath: []string{"root"}, + Priority: "low", + CustomFields: map[string]CustomFieldValue{ + "notes": {Value: strings.Repeat("a", 600)}, + }, + }) + require.NoError(t, err) +} + func TestFolderSlashEscaping(t *testing.T) { qasCSV := NewQASphereCSV() diff --git a/qascsv.go b/qascsv.go index a4f5166..4b53781 100644 --- a/qascsv.go +++ b/qascsv.go @@ -11,6 +11,7 @@ import ( "os" "strconv" "strings" + "unicode/utf8" "github.com/go-playground/validator/v10" "github.com/hashicorp/go-multierror" @@ -87,17 +88,31 @@ type ParameterValue struct { type CustomFieldType string const ( - CustomFieldTypeText CustomFieldType = "text" + // CustomFieldTypeText is a plain text field. + CustomFieldTypeText CustomFieldType = "text" + // CustomFieldTypeDropdown is a selection field. The value must match one + // of the options defined for the field in QA Sphere (option values are + // limited to 255 characters). CustomFieldTypeDropdown CustomFieldType = "dropdown" + // CustomFieldTypeRichtext is a rich text field (e.g. the Description + // field). Unlike Preconditions and Steps, which take markdown, richtext + // values are HTML, e.g. "

" or "
". + // QA Sphere sanitizes the HTML on import using an allowlist of tags and + // attributes; disallowed markup is stripped. + CustomFieldTypeRichtext CustomFieldType = "richtext" ) type CustomField struct { SystemName string `validate:"required,max=64"` - Type CustomFieldType `validate:"required,oneof=text dropdown"` + Type CustomFieldType `validate:"required,oneof=text dropdown richtext"` } +// CustomFieldValue represents the value of a custom field on a test case. +// QA Sphere does not limit the length of custom field values, but dropdown +// values must match one of the field's options, which are limited to 255 +// characters. type CustomFieldValue struct { - Value string `json:"value" validate:"max=255"` + Value string `json:"value"` IsDefault bool `json:"isDefault" validate:"omitempty"` } @@ -128,8 +143,9 @@ type TestCase struct { // filter or organise related test cases and also helps in creating // test runs. (optional) Tags []string `validate:"dive,required,max=255"` - // The preconditions (or description) for the test case. Markdown is - // supported. (optional) + // The preconditions for the test case. Markdown is supported. (optional) + // For test case descriptions, use a richtext custom field instead — + // see CustomFieldTypeRichtext. Preconditions string // The sequence of (ordered) actions to be performed while executing // the test case. (optional) @@ -311,17 +327,22 @@ func (q *QASphereCSV) validateTestCase(tc TestCase) error { } if tc.CustomFields != nil { - for systemName := range tc.CustomFields { - var found bool - for _, cf := range q.customFields { + for systemName, cfValue := range tc.CustomFields { + var found *CustomField + for i, cf := range q.customFields { if cf.SystemName == systemName { - found = true + found = &q.customFields[i] break } } - if !found { + if found == nil { return errors.Errorf("custom field %s is not defined in QASphereCSV.customFields", systemName) } + // Dropdown values must match an option defined in QA Sphere, + // and options are limited to 255 characters. + if found.Type == CustomFieldTypeDropdown && utf8.RuneCountInString(cfValue.Value) > 255 { + return errors.Errorf("custom field %s: dropdown value must not exceed 255 characters", systemName) + } } }