From e7e33dcc9b0dd0341fb11cf4f7ea8313cb4c381f Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 13 Feb 2025 17:20:40 +0100 Subject: [PATCH 01/46] can create wiki folder structure --- generatedocs/pkg/genmd/flags.go | 10 ++++- generatedocs/pkg/genmd/generate.go | 55 ++++++++++++++++++++++--- generatedocs/pkg/genmd/generate_test.go | 2 +- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/generatedocs/pkg/genmd/flags.go b/generatedocs/pkg/genmd/flags.go index 0749ad6b..21c53b0b 100644 --- a/generatedocs/pkg/genmd/flags.go +++ b/generatedocs/pkg/genmd/flags.go @@ -5,11 +5,17 @@ import ( "os" ) -var output string +var ( + output string + wiki string + verbose bool +) func handleFlags() { flagHelp := flag.Bool("help", false, "shows help") - flag.StringVar(&output, "output", "generatedocs/generated/settingup.md", "path to output file") + flag.StringVar(&output, "output", "", "path to output file") + flag.StringVar(&wiki, "wiki", "", "path to wiki folder") + flag.BoolVar(&verbose, "verbose", false, "verbose print") flag.Parse() diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 4793ba35..1464dc2e 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -2,9 +2,11 @@ package genmd import ( "bytes" + "errors" "fmt" "io" "os" + "path/filepath" "sort" "github.com/qlik-oss/gopherciser/generatedocs/pkg/common" @@ -106,14 +108,23 @@ const ( func GenerateMarkdown(docs *CompiledDocs) { handleFlags() - mdBytes := generateFromCompiled(docs) - if err := os.WriteFile(output, mdBytes, 0644); err != nil { - common.Exit(err, ExitCodeFailedWriteResult) + if wiki == "" && output == "" { + _, _ = os.Stderr.WriteString("must defined at least one of --wiki or --output") + return + } + if output != "" { + mdBytes := generateFullMarkdownFromCompiled(docs) + if err := os.WriteFile(output, mdBytes, 0644); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + fmt.Printf("Generated markdown documentation to output<%s>\n", output) + } + if wiki != "" { + generateWikiFromCompiled(docs) } - fmt.Printf("Generated markdown documentation to output<%s>\n", output) } -func generateFromCompiled(compiledDocs *CompiledDocs) []byte { +func generateFullMarkdownFromCompiled(compiledDocs *CompiledDocs) []byte { main := compiledDocs.Config["main"] mainNode := NewDocNode(DocEntry(main)) addConfigFields(mainNode, compiledDocs) @@ -123,6 +134,40 @@ func generateFromCompiled(compiledDocs *CompiledDocs) []byte { return buf.Bytes() } +func generateWikiFromCompiled(compiledDocs *CompiledDocs) { + if err := createFolder(wiki); err != nil { + _, _ = os.Stderr.WriteString(fmt.Sprintf("failed to create folder<%s>: %v", wiki, err)) + return + } + for _, group := range compiledDocs.Groups { + fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) + if err := createFolder(filepath.Join(wiki, group.Name)); err != nil { + _, _ = os.Stderr.WriteString(fmt.Sprintf("failed to create folder<%s>: %v", wiki, err)) + return + } + for _, action := range group.Actions { + folderpath := filepath.Join(wiki, group.Name, action) + if verbose { + fmt.Printf("creating folder<%s>\n", folderpath) + } + if err := createFolder(folderpath); err != nil { + _, _ = os.Stderr.WriteString(fmt.Sprintf("failed to create folder<%s>: %v", wiki, err)) + return + } + } + } + // TODO warning for ungrouped +} + +func createFolder(path string) error { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return err + } + } + return nil +} + func addActions(node DocNode, compiledDocs *CompiledDocs, actions []string, actionSettings map[string]interface{}) { for _, action := range actions { compiledEntry, ok := compiledDocs.Actions[action] diff --git a/generatedocs/pkg/genmd/generate_test.go b/generatedocs/pkg/genmd/generate_test.go index ee6b4610..09f0360c 100644 --- a/generatedocs/pkg/genmd/generate_test.go +++ b/generatedocs/pkg/genmd/generate_test.go @@ -17,7 +17,7 @@ func TestGenerateMarkDown(t *testing.T) { Groups: generated.Groups, Extra: generated.Extra, } - mdBytes := generateFromCompiled(compiledDocs) + mdBytes := generateFullMarkdownFromCompiled(compiledDocs) markdown := string(mdBytes) expectedMDBytes, err := os.ReadFile("testdata/settingup.md") From e9f9c8c533bb105d1bb1183c0e1a3e328be50aae Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Fri, 14 Feb 2025 09:38:55 +0100 Subject: [PATCH 02/46] can generate action files --- .../actions/commonActions/applybookmark.md | 21 ++ .../actions/commonActions/askhubadvisor.md | 185 ++++++++++++++ .../wiki/actions/commonActions/changesheet.md | 40 +++ docs/wiki/actions/commonActions/clearall.md | 14 ++ docs/wiki/actions/commonActions/clearfield.md | 18 ++ .../commonActions/clickactionbutton.md | 42 ++++ .../actions/commonActions/containertab.md | 49 ++++ .../actions/commonActions/createbookmark.md | 24 ++ .../wiki/actions/commonActions/createsheet.md | 19 ++ .../actions/commonActions/deletebookmark.md | 25 ++ .../wiki/actions/commonActions/deletesheet.md | 25 ++ .../actions/commonActions/disconnectapp.md | 14 ++ .../commonActions/disconnectenvironment.md | 16 ++ docs/wiki/actions/commonActions/dosave.md | 13 + .../actions/commonActions/duplicatesheet.md | 23 ++ docs/wiki/actions/commonActions/getscript.md | 28 +++ docs/wiki/actions/commonActions/iterated.md | 38 +++ .../actions/commonActions/listboxselect.md | 29 +++ .../actions/commonActions/objectsearch.md | 73 ++++++ docs/wiki/actions/commonActions/openapp.md | 49 ++++ .../actions/commonActions/productversion.md | 30 +++ .../actions/commonActions/publishbookmark.md | 37 +++ .../actions/commonActions/publishsheet.md | 22 ++ .../actions/commonActions/randomaction.md | 130 ++++++++++ docs/wiki/actions/commonActions/reload.md | 24 ++ docs/wiki/actions/commonActions/select.md | 95 ++++++++ docs/wiki/actions/commonActions/setscript.md | 17 ++ .../actions/commonActions/setscriptvar.md | 230 ++++++++++++++++++ .../actions/commonActions/setsensevariable.md | 17 ++ .../actions/commonActions/sheetchanger.md | 18 ++ .../wiki/actions/commonActions/smartsearch.md | 154 ++++++++++++ .../actions/commonActions/stepdimension.md | 19 ++ .../actions/commonActions/subscribeobjects.md | 36 +++ docs/wiki/actions/commonActions/thinktime.md | 46 ++++ .../commonActions/unpublishbookmark.md | 37 +++ .../actions/commonActions/unpublishsheet.md | 20 ++ .../commonActions/unsubscribeobjects.md | 34 +++ .../wiki/actions/qseowActions/changestream.md | 36 +++ docs/wiki/actions/qseowActions/deleteodag.md | 17 ++ .../wiki/actions/qseowActions/generateodag.md | 17 ++ docs/wiki/actions/qseowActions/openhub.md | 14 ++ generatedocs/pkg/genmd/generate.go | 54 ++-- 42 files changed, 1829 insertions(+), 20 deletions(-) create mode 100755 docs/wiki/actions/commonActions/applybookmark.md create mode 100755 docs/wiki/actions/commonActions/askhubadvisor.md create mode 100755 docs/wiki/actions/commonActions/changesheet.md create mode 100755 docs/wiki/actions/commonActions/clearall.md create mode 100755 docs/wiki/actions/commonActions/clearfield.md create mode 100755 docs/wiki/actions/commonActions/clickactionbutton.md create mode 100755 docs/wiki/actions/commonActions/containertab.md create mode 100755 docs/wiki/actions/commonActions/createbookmark.md create mode 100755 docs/wiki/actions/commonActions/createsheet.md create mode 100755 docs/wiki/actions/commonActions/deletebookmark.md create mode 100755 docs/wiki/actions/commonActions/deletesheet.md create mode 100755 docs/wiki/actions/commonActions/disconnectapp.md create mode 100755 docs/wiki/actions/commonActions/disconnectenvironment.md create mode 100755 docs/wiki/actions/commonActions/dosave.md create mode 100755 docs/wiki/actions/commonActions/duplicatesheet.md create mode 100755 docs/wiki/actions/commonActions/getscript.md create mode 100755 docs/wiki/actions/commonActions/iterated.md create mode 100755 docs/wiki/actions/commonActions/listboxselect.md create mode 100755 docs/wiki/actions/commonActions/objectsearch.md create mode 100755 docs/wiki/actions/commonActions/openapp.md create mode 100755 docs/wiki/actions/commonActions/productversion.md create mode 100755 docs/wiki/actions/commonActions/publishbookmark.md create mode 100755 docs/wiki/actions/commonActions/publishsheet.md create mode 100755 docs/wiki/actions/commonActions/randomaction.md create mode 100755 docs/wiki/actions/commonActions/reload.md create mode 100755 docs/wiki/actions/commonActions/select.md create mode 100755 docs/wiki/actions/commonActions/setscript.md create mode 100755 docs/wiki/actions/commonActions/setscriptvar.md create mode 100755 docs/wiki/actions/commonActions/setsensevariable.md create mode 100755 docs/wiki/actions/commonActions/sheetchanger.md create mode 100755 docs/wiki/actions/commonActions/smartsearch.md create mode 100755 docs/wiki/actions/commonActions/stepdimension.md create mode 100755 docs/wiki/actions/commonActions/subscribeobjects.md create mode 100755 docs/wiki/actions/commonActions/thinktime.md create mode 100755 docs/wiki/actions/commonActions/unpublishbookmark.md create mode 100755 docs/wiki/actions/commonActions/unpublishsheet.md create mode 100755 docs/wiki/actions/commonActions/unsubscribeobjects.md create mode 100755 docs/wiki/actions/qseowActions/changestream.md create mode 100755 docs/wiki/actions/qseowActions/deleteodag.md create mode 100755 docs/wiki/actions/qseowActions/generateodag.md create mode 100755 docs/wiki/actions/qseowActions/openhub.md diff --git a/docs/wiki/actions/commonActions/applybookmark.md b/docs/wiki/actions/commonActions/applybookmark.md new file mode 100755 index 00000000..c16192d9 --- /dev/null +++ b/docs/wiki/actions/commonActions/applybookmark.md @@ -0,0 +1,21 @@ +## ApplyBookmark action + +Apply a bookmark in the current app. + +**Note:** Specify *either* `title` *or* `id`, not both. + +* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). +* `id`: ID of the bookmark. +* `selectionsonly`: Apply selections only. + +### Example + +```json +{ + "action": "applybookmark", + "settings": { + "title": "My bookmark" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/askhubadvisor.md b/docs/wiki/actions/commonActions/askhubadvisor.md new file mode 100755 index 00000000..5ecace39 --- /dev/null +++ b/docs/wiki/actions/commonActions/askhubadvisor.md @@ -0,0 +1,185 @@ +## AskHubAdvisor action + +Perform a query in the Qlik Sense hub insight advisor. +* `querysource`: The source from which queries will be randomly picked. + * `file`: Read queries from file defined by `file`. + * `querylist`: Read queries from list defined by `querylist`. +* `querylist`: A list of queries. Plain strings are supported and will get a weight of `1`. + * `weight`: A weight to set probablility of query being peformed. + * `query`: A query sentence. +* `lang`: Query language. +* `maxfollowup`: The maximum depth of followup queries asked. A value of `0` means that a query from querysource is performed without followup queries. +* `file`: Path to query file. +* `app`: Optional name of app to pick in followup queries. If not set, a random app is picked. +* `saveimages`: Save images of charts to file. +* `saveimagefile`: File name of saved images. Defaults to server side file name. Supports [Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables). +* `thinktime`: Settings for the `thinktime` action, which is automatically inserted before each followup. Defaults to a uniform distribution with mean=8 and deviation=4. + * `type`: Type of think time + * `static`: Static think time, defined by `delay`. + * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. + * `delay`: Delay (seconds), used with type `static`. + * `mean`: Mean (seconds), used with type `uniform`. + * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. +* `followuptypes`: A list of followup types enabled for followup queries. If omitted, all types are enabled. + * `app`: Enable followup queries which change app. + * `measure`: Enable followups based on measures. + * `dimension`: Enable followups based on dimensions. + * `recommendation`: Enable followups based on recommendations. + * `sentence`: Enable followup queries based on bare sentences. + +### Examples + +#### Pick queries from file + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "file", + "file": "queries.txt" + } +} +``` + +The file `queries.txt` contains one query and an optional weight per line. The line format is `[WEIGHT;]QUERY`. +```txt +show sales per country +5; what is the lowest price of shoes +``` + +#### Pick queries from list + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": ["show sales per country", "what is the lowest price of shoes"] + } +} +``` + +#### Perform followup queries if possible (default: 0) + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": ["show sales per country", "what is the lowest price of shoes"], + "maxfollowup": 3 + } +} +``` + +#### Change lanuage (default: "en") + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": ["show sales per country", "what is the lowest price of shoes"], + "lang": "fr" + } +} +``` + +#### Weights in querylist + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": [ + { + "query": "show sales per country", + "weight": 5, + }, + "what is the lowest price of shoes" + ] + } +} +``` + +#### Thinktime before followup queries + +See detailed examples of settings in the documentation for thinktime action. + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": [ + "what is the lowest price of shoes" + ], + "maxfollowup": 5, + "thinktime": { + "type": "static", + "delay": 5 + } + } +} +``` + +#### Ask followups only based on app selection + + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": [ + "what is the lowest price of shoes" + ], + "maxfollowup": 5, + "followuptypes": ["app"] + } +} +``` + +#### Save chart images to file + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": [ + "show price per shoe type" + ], + "maxfollowup": 5, + "saveimages": true + } +} +``` + +#### Save chart images to file with custom name + +The `saveimagefile` file name template setting supports +[Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables). +You can apart from session variables include the following action local variables in the `saveimagefile` file name template: +- .Local.ImageCount - _the number of images written to file_ +- .Local.ServerFileName - _the server side name of image file_ +- .Local.Query - _the query sentence_ +- .Local.AppName - _the name of app, if any app, where query is asked_ +- .Local.AppID - _the id of app, if any app, where query is asked_ + +```json +{ + "action": "AskHubAdvisor", + "settings": { + "querysource": "querylist", + "querylist": [ + "show price per shoe type" + ], + "maxfollowup": 5, + "saveimages": true, + "saveimagefile": "{{.Local.Query}}--app-{{.Local.AppName}}--user-{{.UserName}}--thread-{{.Thread}}--session-{{.Session}}" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/changesheet.md b/docs/wiki/actions/commonActions/changesheet.md new file mode 100755 index 00000000..84c854a3 --- /dev/null +++ b/docs/wiki/actions/commonActions/changesheet.md @@ -0,0 +1,40 @@ +## ChangeSheet action + +Change to a new sheet, unsubscribe to the currently subscribed objects, and subscribe to all objects on the new sheet. + +The action supports getting data from the following objects: + +* Listbox +* Filter pane +* Bar chart +* Scatter plot +* Map (only the first layer) +* Combo chart +* Table +* Pivot table +* Line chart +* Pie chart +* Tree map +* Text-Image +* KPI +* Gauge +* Box plot +* Distribution plot +* Histogram +* Auto chart (including any support generated visualization from this list) +* Waterfall chart + +* `id`: GUID of the sheet to change to. + +### Example + +```json +{ + "label": "Change Sheet Dashboard", + "action": "ChangeSheet", + "settings": { + "id": "TFJhh" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/clearall.md b/docs/wiki/actions/commonActions/clearall.md new file mode 100755 index 00000000..85de91c5 --- /dev/null +++ b/docs/wiki/actions/commonActions/clearall.md @@ -0,0 +1,14 @@ +## ClearAll action + +Clear all selections in an app. + + +### Example + +```json +{ + "action": "clearall", + "label": "Clear all selections (1)" +} +``` + diff --git a/docs/wiki/actions/commonActions/clearfield.md b/docs/wiki/actions/commonActions/clearfield.md new file mode 100755 index 00000000..26b2b244 --- /dev/null +++ b/docs/wiki/actions/commonActions/clearfield.md @@ -0,0 +1,18 @@ +## ClearField action + +Clear selections in a field. + +* `name`: Name of field to clear. + +### Example + +```json +{ + "action": "clearfield", + "label": "Clear selections in Alpha", + "settings" : { + "name": "Alpha" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/clickactionbutton.md b/docs/wiki/actions/commonActions/clickactionbutton.md new file mode 100755 index 00000000..160530b0 --- /dev/null +++ b/docs/wiki/actions/commonActions/clickactionbutton.md @@ -0,0 +1,42 @@ +## ClickActionButton action + +A `ClickActionButton`-action simulates clicking an _action-button_. An _action-button_ is a sheet item which, when clicked, executes a series of actions. The series of actions contained by an action-button begins with any number _generic button-actions_ and ends with an optional _navigation button-action_. + +### Supported button-actions +#### Generic button-actions +- Apply bookmark +- Move backward in all selections +- Move forward in all selections +- Lock all selections +- Clear all selections +- Lock field +- Unlock field +- Select all in field +- Select alternatives in field +- Select excluded in field +- Select possible in field +- Select values matching search criteria in field +- Clear selection in field +- Toggle selection in field +- Set value of variable + +#### Navigation button-actions +- Change to first sheet +- Change to last sheet +- Change to previous sheet +- Change sheet by name +- Change sheet by ID +* `id`: ID of the action-button to click. + +### Examples + +```json +{ + "label": "ClickActionButton", + "action": "ClickActionButton", + "settings": { + "id": "951e2eee-ad49-4f6a-bdfe-e9e3dddeb2cd" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/containertab.md b/docs/wiki/actions/commonActions/containertab.md new file mode 100755 index 00000000..22894e81 --- /dev/null +++ b/docs/wiki/actions/commonActions/containertab.md @@ -0,0 +1,49 @@ +## Containertab action + +A `Containertab` action simulates switching the active object in a `container` object. + +* `mode`: Mode for container tab switching, one of: `objectid`, `random` or `index`. + * `objectid`: Switch to tab with object defined by `objectid`. + * `random`: Switch to a random visible tab within the container. + * `index`: Switch to tab with zero based index defined but `index`. +* `containerid`: ID of the container object. +* `objectid`: ID of the object to set as active, used with mode `objectid`. +* `index`: Zero based index of tab to switch to, used with mode `index`. + +### Examples + +```json +{ + "label": "Switch to object qwerty in container object XYZ", + "action": "containertab", + "settings": { + "containerid": "xyz", + "mode": "id", + "objectid" : "qwerty" + } +} +``` + +```json +{ + "label": "Switch to random object in container object XYZ", + "action": "containertab", + "settings": { + "containerid": "xyz", + "mode": "random" + } +} +``` + +```json +{ + "label": "Switch to object in first tab in container object XYZ", + "action": "containertab", + "settings": { + "containerid": "xyz", + "mode": "index", + "index": 0 + } +} +``` + diff --git a/docs/wiki/actions/commonActions/createbookmark.md b/docs/wiki/actions/commonActions/createbookmark.md new file mode 100755 index 00000000..ba8178e0 --- /dev/null +++ b/docs/wiki/actions/commonActions/createbookmark.md @@ -0,0 +1,24 @@ +## CreateBookmark action + +Create a bookmark from the current selection and selected sheet. + +**Note:** Both `title` and `id` can be used to identify the bookmark in subsequent actions. + +* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). +* `id`: ID of the bookmark. +* `description`: (optional) Description of the bookmark to create. +* `nosheet`: Do not include the sheet location in the bookmark. +* `savelayout`: Include the layout in the bookmark. + +### Example + +```json +{ + "action": "createbookmark", + "settings": { + "title": "my bookmark", + "description": "This bookmark contains some interesting selections" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/createsheet.md b/docs/wiki/actions/commonActions/createsheet.md new file mode 100755 index 00000000..e7ada68e --- /dev/null +++ b/docs/wiki/actions/commonActions/createsheet.md @@ -0,0 +1,19 @@ +## CreateSheet action + +Create a new sheet in the current app. + +* `id`: (optional) ID to be used to identify the sheet in any subsequent `changesheet`, `duplicatesheet`, `publishsheet` or `unpublishsheet` action. +* `title`: Name of the sheet to create. +* `description`: (optional) Description of the sheet to create. + +### Example + +```json +{ + "action": "createsheet", + "settings": { + "title" : "Generated sheet" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/deletebookmark.md b/docs/wiki/actions/commonActions/deletebookmark.md new file mode 100755 index 00000000..cc8b7d9b --- /dev/null +++ b/docs/wiki/actions/commonActions/deletebookmark.md @@ -0,0 +1,25 @@ +## DeleteBookmark action + +Delete one or more bookmarks in the current app. + +**Note:** Specify *either* `title` *or* `id`, not both. + +* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). +* `id`: ID of the bookmark. +* `mode`: + * `single`: Delete one bookmark that matches the specified `title` or `id` in the current app. + * `matching`: Delete all bookmarks with the specified `title` in the current app. + * `all`: Delete all bookmarks in the current app. + +### Example + +```json +{ + "action": "deletebookmark", + "settings": { + "mode": "single", + "title": "My bookmark" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/deletesheet.md b/docs/wiki/actions/commonActions/deletesheet.md new file mode 100755 index 00000000..ca485e03 --- /dev/null +++ b/docs/wiki/actions/commonActions/deletesheet.md @@ -0,0 +1,25 @@ +## DeleteSheet action + +Delete one or more sheets in the current app. + +**Note:** Specify *either* `title` *or* `id`, not both. + +* `mode`: + * `single`: Delete one sheet that matches the specified `title` or `id` in the current app. + * `matching`: Delete all sheets with the specified `title` in the current app. + * `allunpublished`: Delete all unpublished sheets in the current app. +* `title`: (optional) Name of the sheet to delete. +* `id`: (optional) GUID of the sheet to delete. + +### Example + +```json +{ + "action": "deletesheet", + "settings": { + "mode": "matching", + "title": "Test sheet" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/disconnectapp.md b/docs/wiki/actions/commonActions/disconnectapp.md new file mode 100755 index 00000000..05a7c666 --- /dev/null +++ b/docs/wiki/actions/commonActions/disconnectapp.md @@ -0,0 +1,14 @@ +## DisconnectApp action + +Disconnect from an already connected app. + + +### Example + +```json +{ + "label": "Disconnect from server", + "action" : "disconnectapp" +} +``` + diff --git a/docs/wiki/actions/commonActions/disconnectenvironment.md b/docs/wiki/actions/commonActions/disconnectenvironment.md new file mode 100755 index 00000000..0f5bfb6e --- /dev/null +++ b/docs/wiki/actions/commonActions/disconnectenvironment.md @@ -0,0 +1,16 @@ +## DisconnectEnvironment action + +Disconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environmentsor uses custom actions towards external environment, it should be used directly after the last action towards the environment. + +Since the action also disconnects any open websocket to Sense apps, it does not need to be preceeded with a `disconnectapp` action. + + +### Example + +```json +{ + "label": "Disconnect from environment", + "action" : "disconnectenvironment" +} +``` + diff --git a/docs/wiki/actions/commonActions/dosave.md b/docs/wiki/actions/commonActions/dosave.md new file mode 100755 index 00000000..1ecdc46e --- /dev/null +++ b/docs/wiki/actions/commonActions/dosave.md @@ -0,0 +1,13 @@ +## DoSave action + +`DoSave` issues a command to engine to save the currently open app. If the simulated user does not have permission to save the app it will result in an error. + +### Example + +```json +{ + "label": "Save MyApp", + "action" : "dosave" +} +``` + diff --git a/docs/wiki/actions/commonActions/duplicatesheet.md b/docs/wiki/actions/commonActions/duplicatesheet.md new file mode 100755 index 00000000..02a01c3e --- /dev/null +++ b/docs/wiki/actions/commonActions/duplicatesheet.md @@ -0,0 +1,23 @@ +## DuplicateSheet action + +Duplicate a sheet, including all objects. + +* `id`: ID of the sheet to clone. +* `changesheet`: Clear the objects currently subscribed to and then subribe to all objects on the cloned sheet (which essentially corresponds to using the `changesheet` action to go to the cloned sheet) (`true` / `false`). Defaults to `false`, if omitted. +* `save`: Execute `saveobjects` after the cloning operation to save all modified objects (`true` / `false`). Defaults to `false`, if omitted. +* `cloneid`: (optional) ID to be used to identify the sheet in any subsequent `changesheet`, `duplicatesheet`, `publishsheet` or `unpublishsheet` action. + +### Example + +```json +{ + "action": "duplicatesheet", + "label": "Duplicate sheet1", + "settings":{ + "id" : "mBshXB", + "save": true, + "changesheet": true + } +} +``` + diff --git a/docs/wiki/actions/commonActions/getscript.md b/docs/wiki/actions/commonActions/getscript.md new file mode 100755 index 00000000..0faef778 --- /dev/null +++ b/docs/wiki/actions/commonActions/getscript.md @@ -0,0 +1,28 @@ +## GetScript action + +Get the load script for the app. + + +* `savelog`: Save load script to log file under the INFO log labelled *LoadScript* + +### Example + +Get the load script for the app + +```json +{ + "action": "getscript" +} +``` + +Get the load script for the app and save to log file + +```json +{ + "action": "getscript", + "settings": { + "savelog" : true + } +} +``` + diff --git a/docs/wiki/actions/commonActions/iterated.md b/docs/wiki/actions/commonActions/iterated.md new file mode 100755 index 00000000..93aac4b5 --- /dev/null +++ b/docs/wiki/actions/commonActions/iterated.md @@ -0,0 +1,38 @@ +## Iterated action + +Loop one or more actions. + +**Note:** This action does not require an app context (that is, it does not have to be prepended with an `openapp` action). + +* `iterations`: Number of loops. +* `actions`: Actions to iterate + * `action`: Name of the action to execute. + * `label`: (optional) Custom string set by the user. This can be used to distinguish the action from other actions of the same type when analyzing the test results. + * `disabled`: (optional) Disable action (`true` / `false`). If set to `true`, the action is not executed. + * `settings`: Most, but not all, actions have a settings section with action-specific settings. + +### Example + +```json +//Visit all sheets twice +{ + "action": "iterated", + "label": "", + "settings": { + "iterations" : 2, + "actions" : [ + { + "action": "sheetchanger" + }, + { + "action": "thinktime", + "settings": { + "type": "static", + "delay": 5 + } + } + ] + } +} +``` + diff --git a/docs/wiki/actions/commonActions/listboxselect.md b/docs/wiki/actions/commonActions/listboxselect.md new file mode 100755 index 00000000..59f27cda --- /dev/null +++ b/docs/wiki/actions/commonActions/listboxselect.md @@ -0,0 +1,29 @@ +## ListBoxSelect action + +Perform list object specific selectiontypes in listbox. + + +* `id`: ID of the listbox in which to select values. +* `type`: Selection type. + * `all`: Select all values. + * `alternative`: Select alternative values. + * `excluded`: Select excluded values. + * `possible`: Select possible values. +* `accept`: Accept or abort selection after selection (only used with `wrap`) (`true` / `false`). +* `wrap`: Wrap selection with Begin / End selection requests (`true` / `false`). + +### Examples + +```json +{ + "label": "ListBoxSelect", + "action": "ListBoxSelect", + "settings": { + "id": "951e2eee-ad49-4f6a-bdfe-e9e3dddeb2cd", + "type": "all", + "wrap": true, + "accept": true + } +} +``` + diff --git a/docs/wiki/actions/commonActions/objectsearch.md b/docs/wiki/actions/commonActions/objectsearch.md new file mode 100755 index 00000000..d8d38e37 --- /dev/null +++ b/docs/wiki/actions/commonActions/objectsearch.md @@ -0,0 +1,73 @@ +## ObjectSearch action + +Perform a search select in a listbox, field or master dimension. + + +* `id`: Identifier for the object, this would differ depending on `type`. + * `listbox`: Use the ID of listbox object + * `field`: Use the name of the field + * `dimension`: Use the title of the dimension masterobject. +* `searchterms`: List of search terms to search for. +* `type`: Type of object to search + * `listbox`: (Default) `id` is the ID of a listbox. + * `field`: `id` is the name of a field. + * `dimension`: `id` is the title of a master object dimension. +* `source`: Source of search terms + * `fromlist`: (Default) Use search terms from `searchterms` array. + * `fromfile`: Use search term from file defined by `searchtermsfile` +* `erroronempty`: If set to true and the object search yields an empty result, the action will result in an error. Defaults to false. +* `searchtermsfile`: Path to search terms file when using `source` of type `fromfile`. File should contain one term per row. + +### Examples + +Search a listbox object, all users searches for same thing and gets an error if no result found + +```json +{ + "label": "Search and select Sweden in listbox", + "action": "objectsearch", + "settings": { + "id": "maesVjgte", + "searchterms": ["Sweden"], + "type": "listbox", + "erroronempty": true + } +} +``` + +Search a field. Users use one random search term from the `searchterms` list. + +```json +{ + "label": "Search field", + "action": "objectsearch", + "disabled": false, + "settings": { + "id": "Countries", + "searchterms": [ + "Sweden", + "Germany", + "Liechtenstein" + ], + "type": "field" + } +} +``` + +Search a master object dimension using search terms from a file. + +```json +{ + "label": "Search dimension", + "action": "objectsearch", + "disabled": false, + "settings": { + "id": "Dim1M", + "type": "dimension", + "erroronempty": true, + "source": "fromfile", + "searchtermsfile": "./resources/objectsearchterms.txt" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/openapp.md b/docs/wiki/actions/commonActions/openapp.md new file mode 100755 index 00000000..ba0b5bf8 --- /dev/null +++ b/docs/wiki/actions/commonActions/openapp.md @@ -0,0 +1,49 @@ +## OpenApp action + +Open an app. + +**Note:** If the app name is used to specify which app to open, this action cannot be the first action in the scenario. It must be preceded by an action that can populate the artifact map, such as `openhub`. + +* `appmode`: App selection mode + * `current`: (default) Use the current app, selected by an app selection in a previous action + * `guid`: Use the app GUID specified by the `app` parameter. + * `name`: Use the app name specified by the `app` parameter. + * `random`: Select a random app from the artifact map, which is filled by e.g. `openhub` + * `randomnamefromlist`: Select a random app from a list of app names. The `list` parameter should contain a list of app names. + * `randomguidfromlist`: Select a random app from a list of app GUIDs. The `list` parameter should contain a list of app GUIDs. + * `randomnamefromfile`: Select a random app from a file with app names. The `filename` parameter should contain the path to a file in which each line represents an app name. + * `randomguidfromfile`: Select a random app from a file with app GUIDs. The `filename` parameter should contain the path to a file in which each line represents an app GUID. + * `round`: Select an app from the artifact map according to the round-robin principle. + * `roundnamefromlist`: Select an app from a list of app names according to the round-robin principle. The `list` parameter should contain a list of app names. + * `roundguidfromlist`: Select an app from a list of app GUIDs according to the round-robin principle. The `list` parameter should contain a list of app GUIDs. + * `roundnamefromfile`: Select an app from a file with app names according to the round-robin principle. The `filename` parameter should contain the path to a file in which each line represents an app name. + * `roundguidfromfile`: Select an app from a file with app GUIDs according to the round-robin principle. The `filename` parameter should contain the path to a file in which each line represents an app GUID. +* `app`: App name or app GUID (supports the use of [session variables](#session_variables)). Used with `appmode` set to `guid` or `name`. +* `list`: List of apps. Used with `appmode` set to `randomnamefromlist`, `randomguidfromlist`, `roundnamefromlist` or `roundguidfromlist`. +* `filename`: Path to a file in which each line represents an app. Used with `appmode` set to `randomnamefromfile`, `randomguidfromfile`, `roundnamefromfile` or `roundguidfromfile`. +* `externalhost`: (optional) Sets an external host to be used instead of `server` configured in connection settings. +* `unique`: Create unqiue engine session not re-using session from previous connection with same user. Defaults to false. + +### Examples + +```json +{ + "label": "OpenApp", + "action": "OpenApp", + "settings": { + "appmode": "guid", + "app": "7967af99-68b6-464a-86de-81de8937dd56" + } +} +``` +```json +{ + "label": "OpenApp", + "action": "OpenApp", + "settings": { + "appmode": "randomguidfromlist", + "list": ["7967af99-68b6-464a-86de-81de8937dd56", "ca1a9720-0f42-48e5-baa5-597dd11b6cad"] + } +} +``` + diff --git a/docs/wiki/actions/commonActions/productversion.md b/docs/wiki/actions/commonActions/productversion.md new file mode 100755 index 00000000..25c74e6b --- /dev/null +++ b/docs/wiki/actions/commonActions/productversion.md @@ -0,0 +1,30 @@ +## ProductVersion action + +Request the product version from the server and, optionally, save it to the log. This is a lightweight request that can be used as a keep-alive message in a loop. + +* `log`: Save the product version to the log (`true` / `false`). Defaults to `false`, if omitted. + +### Example + +```json +//Keep-alive loop +{ + "action": "iterated", + "settings" : { + "iterations" : 10, + "actions" : [ + { + "action" : "productversion" + }, + { + "action": "thinktime", + "settings": { + "type": "static", + "delay": 30 + } + } + ] + } +} +``` + diff --git a/docs/wiki/actions/commonActions/publishbookmark.md b/docs/wiki/actions/commonActions/publishbookmark.md new file mode 100755 index 00000000..2bc5be82 --- /dev/null +++ b/docs/wiki/actions/commonActions/publishbookmark.md @@ -0,0 +1,37 @@ +## PublishBookmark action + +Publish a bookmark. + +**Note:** Specify *either* `title` *or* `id`, not both. + +* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). +* `id`: ID of the bookmark. + +### Example + +Publish the bookmark with `id` "bookmark1" that was created earlier on in the script. + +```json +{ + "label" : "Publish bookmark 1", + "action": "publishbookmark", + "disabled" : false, + "settings" : { + "id" : "bookmark1" + } +} +``` + +Publish the bookmark with the `title` "bookmark of testuser", where "testuser" is the username of the simulated user. + +```json +{ + "label" : "Publish bookmark 2", + "action": "publishbookmark", + "disabled" : false, + "settings" : { + "title" : "bookmark of {{.UserName}}" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/publishsheet.md b/docs/wiki/actions/commonActions/publishsheet.md new file mode 100755 index 00000000..c135afaa --- /dev/null +++ b/docs/wiki/actions/commonActions/publishsheet.md @@ -0,0 +1,22 @@ +## PublishSheet action + +Publish sheets in the current app. + +* `mode`: + * `allsheets`: Publish all sheets in the app. + * `sheetids`: Only publish the sheets specified by the `sheetIds` array. +* `sheetIds`: (optional) Array of sheet IDs for the `sheetids` mode. +* `includePublished`: Try to publish already published sheets. + +### Example +```json +{ + "label": "PublishSheets", + "action": "publishsheet", + "settings": { + "mode": "sheetids", + "sheetIds": ["qmGcYS", "bKbmgT"] + } +} +``` + diff --git a/docs/wiki/actions/commonActions/randomaction.md b/docs/wiki/actions/commonActions/randomaction.md new file mode 100755 index 00000000..cac12c5c --- /dev/null +++ b/docs/wiki/actions/commonActions/randomaction.md @@ -0,0 +1,130 @@ +## RandomAction action + +Randomly select other actions to perform. This meta-action can be used as a starting point for your testing efforts, to simplify script authoring or to add background load. + +`randomaction` accepts a list of action types between which to randomize. An execution of `randomaction` executes one or more of the listed actions (as determined by the `iterations` parameter), randomly chosen by a weighted probability. If nothing else is specified, each action has a default random mode that is used. An override is done by specifying one or more parameters of the original action. + +Each action executed by `randomaction` is followed by a customizable `thinktime`. + +**Note:** The recommended way to use this action is to prepend it with an `openapp` and a `changesheet` action as this ensures that a sheet is always in context. + +* `actions`: List of actions from which to randomly pick an action to execute. Each item has a number of possible parameters. + * `type`: Type of action + * `thinktime`: See the `thinktime` action. + * `sheetobjectselection`: Make random selections within objects visible on the current sheet. See the `select` action. + * `changesheet`: See the `changesheet` action. + * `clearall`: See the `clearall` action. + * `weight`: The probabilistic weight of the action, specified as an integer. This number is proportional to the likelihood of the specified action, and is used as a weight in a uniform random selection. + * `overrides`: (optional) Static overrides to the action. The overrides can include any or all of the settings from the original action, as determined by the `type` field. If nothing is specified, the default values are used. +* `thinktimesettings`: Settings for the `thinktime` action, which is automatically inserted after every randomized action. + * `type`: Type of think time + * `static`: Static think time, defined by `delay`. + * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. + * `delay`: Delay (seconds), used with type `static`. + * `mean`: Mean (seconds), used with type `uniform`. + * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. +* `iterations`: Number of random actions to perform. + +### Random action defaults + +The following default values are used for the different actions: + +* `thinktime`: Mirrors the configuration of `thinktimesettings` +* `sheetobjectselection`: + +```json +{ + "settings": + { + "id": , + "type": "RandomFromAll", + "min": 1, + "max": 2, + "accept": true + } +} +``` + +* `changesheet`: + +```json +{ + "settings": + { + "id": + } +} +``` + +* `clearall`: + +```json +{ + "settings": + { + } +} +``` + +### Examples + +#### Generating a background load by executing 5 random actions + +```json +{ + "action": "RandomAction", + "settings": { + "iterations": 5, + "actions": [ + { + "type": "thinktime", + "weight": 1 + }, + { + "type": "sheetobjectselection", + "weight": 3 + }, + { + "type": "changesheet", + "weight": 5 + }, + { + "type": "clearall", + "weight": 1 + } + ], + "thinktimesettings": { + "type": "uniform", + "mean": 10, + "dev": 5 + } + } +} +``` + +#### Making random selections from excluded values + +```json +{ + "action": "RandomAction", + "settings": { + "iterations": 1, + "actions": [ + { + "type": "sheetobjectselection", + "weight": 1, + "overrides": { + "type": "RandomFromExcluded", + "min": 1, + "max": 5 + } + } + ], + "thinktimesettings": { + "type": "static", + "delay": 1 + } + } +} +``` + diff --git a/docs/wiki/actions/commonActions/reload.md b/docs/wiki/actions/commonActions/reload.md new file mode 100755 index 00000000..8178c6a0 --- /dev/null +++ b/docs/wiki/actions/commonActions/reload.md @@ -0,0 +1,24 @@ +## Reload action + +Reload the current app by simulating selecting **Load data** in the Data load editor. To select an app, preceed this action with an `openapp` action. + +* `mode`: Error handling during the reload operation + * `default`: Use the default error handling. + * `abend`: Stop reloading the script, if an error occurs. + * `ignore`: Continue reloading the script even if an error is detected in the script. +* `partial`: Enable partial reload (`true` / `false`). This allows you to add data to an app without reloading all data. Defaults to `false`, if omitted. +* `log`: Save the reload log as a field in the output (`true` / `false`). Defaults to `false`, if omitted. **Note:** This should only be used when needed as the reload log can become very large. +* `nosave`: Do not send a save request for the app after the reload is done. Defaults to saving the app. + +### Example + +```json +{ + "action": "reload", + "settings": { + "mode" : "default", + "partial": false + } +} +``` + diff --git a/docs/wiki/actions/commonActions/select.md b/docs/wiki/actions/commonActions/select.md new file mode 100755 index 00000000..61b60097 --- /dev/null +++ b/docs/wiki/actions/commonActions/select.md @@ -0,0 +1,95 @@ +## Select action + +Select random values in an object. + +See the [Limitations](README.md#limitations) section in the README.md file for limitations related to this action. + +* `id`: ID of the object in which to select values. +* `type`: Selection type + * `randomfromall`: Randomly select within all values of the symbol table. + * `randomfromenabled`: Randomly select within the white and light grey values on the first data page. + * `randomfromexcluded`: Randomly select within the dark grey values on the first data page. + * `randomdeselect`: Randomly deselect values on the first data page. + * `values`: Select specific element values, defined by `values` array. +* `accept`: Accept or abort selection after selection (only used with `wrap`) (`true` / `false`). +* `wrap`: Wrap selection with Begin / End selection requests (`true` / `false`). +* `min`: Minimum number of selections to make. +* `max`: Maximum number of selections to make. +* `dim`: Dimension / column in which to select. +* `values`: Array of element values to select when using selection type `values`. These are the element values for a selection, not the values seen by the user. + +### Example + +Randomly select among all the values in object `RZmvzbF`. + +```json +{ + "label": "ListBox Year", + "action": "Select", + "settings": { + "id": "RZmvzbF", + "type": "RandomFromAll", + "accept": true, + "wrap": false, + "min": 1, + "max": 3, + "dim": 0 + } +} +``` + +Randomly select among all the enabled values (a.k.a "white" values) in object `RZmvzbF`. + +```json +{ + "label": "ListBox Year", + "action": "Select", + "settings": { + "id": "RZmvzbF", + "type": "RandomFromEnabled", + "accept": true, + "wrap": false, + "min": 1, + "max": 3, + "dim": 0 + } +} +``` + +#### Statically selecting specific values + +This example selects specific element values in object `RZmvzbF`. These are the values which can be seen in a selection when e.g. inspecting traffic, it is not the data values presented to the user. E.g. when loading a table in the following order by a Sense loadscript: + +``` +Beta +Alpha +Gamma +``` + +which might be presented to the user sorted as + +``` +Alpha +Beta +Gamma +``` + +The element values will be Beta=0, Alpha=1 and Gamma=2. + +To statically select "Gamma" in this case: + +```json +{ + "label": "Select Gammma", + "action": "Select", + "settings": { + "id": "RZmvzbF", + "type": "values", + "accept": true, + "wrap": false, + "values" : [2], + "dim": 0 + } +} +``` + diff --git a/docs/wiki/actions/commonActions/setscript.md b/docs/wiki/actions/commonActions/setscript.md new file mode 100755 index 00000000..55a8f76f --- /dev/null +++ b/docs/wiki/actions/commonActions/setscript.md @@ -0,0 +1,17 @@ +## SetScript action + +Set the load script for the current app. To load the data from the script, use the `reload` action after the `setscript` action. + +* `script`: Load script for the app (written as a string). + +### Example + +```json +{ + "action": "setscript", + "settings": { + "script" : "Characters:\nLoad Chr(RecNo()+Ord('A')-1) as Alpha, RecNo() as Num autogenerate 26;" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/setscriptvar.md b/docs/wiki/actions/commonActions/setscriptvar.md new file mode 100755 index 00000000..c900afd1 --- /dev/null +++ b/docs/wiki/actions/commonActions/setscriptvar.md @@ -0,0 +1,230 @@ +## SetScriptVar action + +Sets a variable which can be used within the same session. Cannot be accessed across different simulated users. + +* `name`: Name of variable to set. Will overwrite any existing variable with same name. +* `type`: Type of the variable. + * `string`: Variable of type string e.g. `my var value`. + * `int`: Variable of type integer e.g. `6`. + * `array`: Variable of type array e.g. `1,2,3`. +* `value`: Value to set to variable (supports the use of [session variables](#session_variables)). +* `sep`: Separator to use when separating string into array. Defaults to `,`. + +### Example + +Create a variable containing a string and use it in openapp. + +```json +{ + "action": "setscriptvar", + "settings": { + "name": "mylocalvar", + "type": "string", + "value": "My app Name with number for session {{ .Session }}" + } +}, +{ + "action": "openapp", + "settings": { + "appmode": "name", + "app": "{{ .ScriptVars.mylocalvar }}" + } +} +``` + +Create a variable containing an integer and use it in a loop creating bookmarks numbered 1 to 5. Then in a different loop reset variable and delete the bookmarks. + +```json +{ + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "0" + } +}, +{ + "action": "iterated", + "settings": { + "iterations": 5, + "actions": [ + { + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "{{ add .ScriptVars.BookmarkCounter 1 }}" + } + }, + { + "action": "createbookmark", + "settings": { + "title": "Bookmark {{ .ScriptVars.BookmarkCounter }}", + "description": "This bookmark contains some interesting selections" + } + } + + ] + } +}, +{ + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "0" + } +}, +{ + "action": "iterated", + "disabled": false, + "settings": { + "iterations": 3, + "actions": [ + { + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" + } + }, + { + "action": "deletebookmark", + "settings": { + "mode": "single", + "title": "Bookmark {{ $element:=range.ScriptVars.BookmarkCounter }} {{ $element }}{{ end }}" + } + } + ] + } +} +``` + +Combine two variables `MyArrayVar` and `BookmarkCounter` to create 3 bookmarks with the names `Bookmark one`, `Bookmark two` and `Bookmark three`. + +```json +{ + "action": "setscriptvar", + "settings": { + "name": "MyArrayVar", + "type": "array", + "value": "one,two,three,four,five", + "sep": "," + } +}, +{ + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "0" + } +}, +{ + "action": "iterated", + "disabled": false, + "settings": { + "iterations": 3, + "actions": [ + { + "action": "createbookmark", + "settings": { + "title": "Bookmark {{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}", + "description": "This bookmark contains some interesting selections" + } + }, + { + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" + } + } + ] + } +} + ``` + +A more advanced example. + +Create a bookmark "BookmarkX" for each iteration in a loop, and add this to an array "MyArrayVar". After the first `iterated` action this will look like "Bookmark1,Bookmark2,Bookmark3". The second `iterated` action then deletes these bookmarks using the created array. + +Dissecting the first array construction action. The `join` command takes the elements `.ScriptVars.MyArrayVar` and joins them together into a string separated by the separtor `,`. So with an array of [ elem1 elem2 ] this becomes a string as `elem1,elem2`. The `if` statement checks if the value of `.ScriptVars.BookmarkCounter` is 0, if it is 0 (i.e. the first iteration) it sets the string to `Bookmark1`. If it is not 0, it executes the join command on .ScriptVars.MyArrayVar, on iteration 3, the result of this would be `Bookmark1,Bookmark2` then it appends the fixed string `,Bookmark`, so far the string is `Bookmark1,Bookmark2,Bookmark`. Lastly it takes the value of `.ScriptVars.BookmarkCounter`, which is now 2, and adds 1 too it and appends, making the entire string `Bookmark1,Bookmark2,Bookmark3`. + + ```json +{ + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "0" + } +}, +{ + "action": "iterated", + "disabled": false, + "settings": { + "iterations": 3, + "actions": [ + { + "action": "setscriptvar", + "settings": { + "name": "MyArrayVar", + "type": "array", + "value": "{{ if eq 0 .ScriptVars.BookmarkCounter }}Bookmark1{{ else }}{{ join .ScriptVars.MyArrayVar \",\" }},Bookmark{{ .ScriptVars.BookmarkCounter | add 1 }}{{ end }}", + "sep": "," + } + }, + { + "action": "createbookmark", + "settings": { + "title": "{{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}", + "description": "This bookmark contains some interesting selections" + } + }, + { + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" + } + } + ] + } +}, +{ + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "0" + } +}, +{ + "action": "iterated", + "disabled": false, + "settings": { + "iterations": 3, + "actions": [ + { + "action": "deletebookmark", + "settings": { + "mode": "single", + "title": "{{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}" + } + }, + { + "action": "setscriptvar", + "settings": { + "name": "BookmarkCounter", + "type": "int", + "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" + } + } + ] + } +} + ``` diff --git a/docs/wiki/actions/commonActions/setsensevariable.md b/docs/wiki/actions/commonActions/setsensevariable.md new file mode 100755 index 00000000..b333c8d5 --- /dev/null +++ b/docs/wiki/actions/commonActions/setsensevariable.md @@ -0,0 +1,17 @@ +## SetSenseVariable action + +Sets a Qlik Sense variable on a sheet in the open app. + +* `name`: Name of the Qlik Sense variable to set. +* `value`: Value to set the Qlik Sense variable to. (supports the use of [session variables](#session_variables)) + +### Example + +Set a variable to 2000 + +```json +{ + "name": "vSampling", + "value": "2000" +} +``` diff --git a/docs/wiki/actions/commonActions/sheetchanger.md b/docs/wiki/actions/commonActions/sheetchanger.md new file mode 100755 index 00000000..7118eef6 --- /dev/null +++ b/docs/wiki/actions/commonActions/sheetchanger.md @@ -0,0 +1,18 @@ +## SheetChanger action + +Create and execute a `changesheet` action for each sheet in an app. This can be used to cache the inital state for all objects or, by chaining two subsequent `sheetchanger` actions, to measure how well the calculations in an app utilize the cache. + + +### Example + +```json +{ + "label" : "Sheetchanger uncached", + "action": "sheetchanger" +}, +{ + "label" : "Sheetchanger cached", + "action": "sheetchanger" +} +``` + diff --git a/docs/wiki/actions/commonActions/smartsearch.md b/docs/wiki/actions/commonActions/smartsearch.md new file mode 100755 index 00000000..704e7b43 --- /dev/null +++ b/docs/wiki/actions/commonActions/smartsearch.md @@ -0,0 +1,154 @@ +## SmartSearch action + +Perform a Smart Search in Sense app to find suggested selections. + +* `searchtextsource`: Source for list of strings used for searching. + * `searchtextlist` (default) + * `searchtextfile` +* `searchtextlist`: List of of strings used for searching. +* `searchtextfile`: File path to file with one search string per line. +* `pastesearchtext`: + * `true`: Simulate pasting search text. + * `false`: Simulate typing at normal speed (default). +* `makeselection`: Select a random search result. + * `true` + * `false` +* `selectionthinktime`: Think time before selection if `makeselection` is `true`, defaults to a 1 second delay. + * `type`: Type of think time + * `static`: Static think time, defined by `delay`. + * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. + * `delay`: Delay (seconds), used with type `static`. + * `mean`: Mean (seconds), used with type `uniform`. + * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. + + +### Examples + +#### Search with one search term +```json +{ + "action": "smartsearch", + "label": "one term search", + "settings": { + "searchtextlist": [ + "term1" + ] + } +} +``` + +#### Search with two search terms +```json +{ + "action": "smartsearch", + "label": "two term search", + "settings": { + "searchtextlist": [ + "term1 term2" + ] + } +} +``` + +#### Search with random selection of search text from list +```json +{ + "action": "smartsearch", + "settings": { + "searchtextlist": [ + "text1", + "text2", + "text3" + ] + } +} +``` + +#### Search with random selection of search text from file +```json +{ + "action": "smartsearch", + "settings": { + "searchtextsource": "searchtextfile", + "searchtextfile": "data/searchtexts.txt" + } +} +``` +##### `data/searchtexts.txt` +``` +search text +"quoted search text" +another search text +``` + +#### Simulate pasting search text + +The default behavior is to simulate typing at normal speed. +```json +{ + "action": "smartsearch", + "settings": { + "pastesearchtext": true, + "searchtextlist": [ + "text1" + ] + } +} +``` + +#### Make a random selection from search results +```json +{ + "action": "smartsearch", + "settings": { + "searchtextlist": [ + "term1" + ], + "makeselection": true, + "selectionthinktime": { + "type": "static", + "delay": 2 + } + } +} +``` + +#### Search with one search term including spaces +```json +{ + "action": "smartsearch", + "settings": { + "searchtextlist": [ + "\"word1 word2\"" + ] + } +} +``` + +#### Search with two search terms, one of them including spaces +```json +{ + "action": "smartsearch", + "label": "two term search, one including spaces", + "settings": { + "searchtextlist": [ + "\"word1 word2\" term2" + ] + } +} +``` + +#### Search with one search term including double quote +```json +{ + "action": "smartsearch", + "label": "one term search including spaces", + "settings": { + "searchtext": + "searchtextlist": [ + "\\\"hello" + ] + } +} +``` + diff --git a/docs/wiki/actions/commonActions/stepdimension.md b/docs/wiki/actions/commonActions/stepdimension.md new file mode 100755 index 00000000..a51bc030 --- /dev/null +++ b/docs/wiki/actions/commonActions/stepdimension.md @@ -0,0 +1,19 @@ +## StepDimension action + +Cycle a step in a cyclic dimension + +* `id`: library ID of the cyclic dimension + +### Example + +Cycle one step in the dimension with library ID `aBc123`. + +```json +{ + "action": "stepdimension", + "settings":{ + "id": "aBc123" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/subscribeobjects.md b/docs/wiki/actions/commonActions/subscribeobjects.md new file mode 100755 index 00000000..d1373727 --- /dev/null +++ b/docs/wiki/actions/commonActions/subscribeobjects.md @@ -0,0 +1,36 @@ +## Subscribeobjects action + +Subscribe to any object in the currently active app. + +* `clear`: Remove any previously subscribed objects from the subscription list. +* `ids`: List of object IDs to subscribe to. + +### Example + +Subscribe to two objects in the currently active app and remove any previous subscriptions. + +```json +{ + "action" : "subscribeobjects", + "label" : "clear subscriptions and subscribe to mBshXB and f2a50cb3-a7e1-40ac-a015-bc4378773312", + "disabled": false, + "settings" : { + "clear" : true, + "ids" : ["mBshXB", "f2a50cb3-a7e1-40ac-a015-bc4378773312"] + } +} +``` + +Subscribe to an additional single object (or a list of objects) in the currently active app, adding the new subscription to any previous subscriptions. + +```json +{ + "action" : "subscribeobjects", + "label" : "add c430d8e2-0f05-49f1-aa6f-7234e325dc35 to currently subscribed objects", + "disabled": false, + "settings" : { + "clear" : false, + "ids" : ["c430d8e2-0f05-49f1-aa6f-7234e325dc35"] + } +} +``` diff --git a/docs/wiki/actions/commonActions/thinktime.md b/docs/wiki/actions/commonActions/thinktime.md new file mode 100755 index 00000000..9e21ba6e --- /dev/null +++ b/docs/wiki/actions/commonActions/thinktime.md @@ -0,0 +1,46 @@ +## ThinkTime action + +Simulate user think time. + +**Note:** This action does not require an app context (that is, it does not have to be prepended with an `openapp` action). + +* `type`: Type of think time + * `static`: Static think time, defined by `delay`. + * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. +* `delay`: Delay (seconds), used with type `static`. +* `mean`: Mean (seconds), used with type `uniform`. +* `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. + +### Examples + +#### ThinkTime uniform + +This simulates a think time of 10 to 15 seconds. + +```json +{ + "label": "TimerDelay", + "action": "thinktime", + "settings": { + "type": "uniform", + "mean": 12.5, + "dev": 2.5 + } +} +``` + +#### ThinkTime constant + +This simulates a think time of 5 seconds. + +```json +{ + "label": "TimerDelay", + "action": "thinktime", + "settings": { + "type": "static", + "delay": 5 + } +} +``` + diff --git a/docs/wiki/actions/commonActions/unpublishbookmark.md b/docs/wiki/actions/commonActions/unpublishbookmark.md new file mode 100755 index 00000000..417896a7 --- /dev/null +++ b/docs/wiki/actions/commonActions/unpublishbookmark.md @@ -0,0 +1,37 @@ +## UnpublishBookmark action + +Unpublish a bookmark. + +**Note:** Specify *either* `title` *or* `id`, not both. + +* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). +* `id`: ID of the bookmark. + +### Example + +Unpublish the bookmark with `id` "bookmark1" that was created earlier on in the script. + +```json +{ + "label" : "Unpublish bookmark 1", + "action": "unpublishbookmark", + "disabled" : false, + "settings" : { + "id" : "bookmark1" + } +} +``` + +Unpublish the bookmark with the `title` "bookmark of testuser", where "testuser" is the username of the simulated user. + +```json +{ + "label" : "Unpublish bookmark 2", + "action": "unpublishbookmark", + "disabled" : false, + "settings" : { + "title" : "bookmark of {{.UserName}}" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/unpublishsheet.md b/docs/wiki/actions/commonActions/unpublishsheet.md new file mode 100755 index 00000000..57910954 --- /dev/null +++ b/docs/wiki/actions/commonActions/unpublishsheet.md @@ -0,0 +1,20 @@ +## UnpublishSheet action + +Unpublish sheets in the current app. + +* `mode`: + * `allsheets`: Unpublish all sheets in the app. + * `sheetids`: Only unpublish the sheets specified by the `sheetIds` array. +* `sheetIds`: (optional) Array of sheet IDs for the `sheetids` mode. + +### Example +```json +{ + "label": "UnpublishSheets", + "action": "unpublishsheet", + "settings": { + "mode": "allsheets" + } +} +``` + diff --git a/docs/wiki/actions/commonActions/unsubscribeobjects.md b/docs/wiki/actions/commonActions/unsubscribeobjects.md new file mode 100755 index 00000000..82a094e7 --- /dev/null +++ b/docs/wiki/actions/commonActions/unsubscribeobjects.md @@ -0,0 +1,34 @@ +## Unsubscribeobjects action + +Unsubscribe to any currently subscribed object. + +* `ids`: List of object IDs to unsubscribe from. +* `clear`: Remove any previously subscribed objects from the subscription list. + +### Example + +Unsubscribe from a single object (or a list of objects). + +```json +{ + "action" : "unsubscribeobjects", + "label" : "unsubscribe from object maVjt and its children", + "disabled": false, + "settings" : { + "ids" : ["maVjt"] + } +} +``` + +Unsubscribe from all currently subscribed objects. + +```json +{ + "action" : "unsubscribeobjects", + "label" : "unsubscribe from all objects", + "disabled": false, + "settings" : { + "clear": true + } +} +``` diff --git a/docs/wiki/actions/qseowActions/changestream.md b/docs/wiki/actions/qseowActions/changestream.md new file mode 100755 index 00000000..cb28e66c --- /dev/null +++ b/docs/wiki/actions/qseowActions/changestream.md @@ -0,0 +1,36 @@ +## ChangeStream action + +Change to specified stream. This makes the apps in the specified stream selectable by actions such as `openapp`. +* `mode`: Decides what kind of value the `stream` field contains. Defaults to `name`. + * `name`: `stream` is the name of the stream. + * `id`: `stream` is the ID if the stream. +* `stream`: + +### Example + +Make apps in stream `Everyone` selectable by subsequent actions. + +```json +{ + "label": "ChangeStream Everyone", + "action": "changestream", + "settings": { + "mode": "name", + "stream" : "Everyone" + } +} +``` + +Make apps in stream with id `ABSCDFSDFSDFO1231234` selectable subsequent actions. + +```json +{ + "label": "ChangeStream Test1", + "action": "changestream", + "settings": { + "mode": "id", + "stream" : "ABSCDFSDFSDFO1231234" + } +} +``` + diff --git a/docs/wiki/actions/qseowActions/deleteodag.md b/docs/wiki/actions/qseowActions/deleteodag.md new file mode 100755 index 00000000..0ca96496 --- /dev/null +++ b/docs/wiki/actions/qseowActions/deleteodag.md @@ -0,0 +1,17 @@ +## DeleteOdag action + +Delete all user-generated on-demand apps for the current user and the specified On-Demand App Generation (ODAG) link. + +* `linkname`: Name of the ODAG link from which to delete generated apps. The name is displayed in the ODAG navigation bar at the bottom of the *selection app*. + +### Example + +```json +{ + "action": "DeleteOdag", + "settings": { + "linkname": "Drill to Template App" + } +} +``` + diff --git a/docs/wiki/actions/qseowActions/generateodag.md b/docs/wiki/actions/qseowActions/generateodag.md new file mode 100755 index 00000000..5cf6e5c6 --- /dev/null +++ b/docs/wiki/actions/qseowActions/generateodag.md @@ -0,0 +1,17 @@ +## GenerateOdag action + +Generate an on-demand app from an existing On-Demand App Generation (ODAG) link. + +* `linkname`: Name of the ODAG link from which to generate an app. The name is displayed in the ODAG navigation bar at the bottom of the *selection app*. + +### Example + +```json +{ + "action": "GenerateOdag", + "settings": { + "linkname": "Drill to Template App" + } +} +``` + diff --git a/docs/wiki/actions/qseowActions/openhub.md b/docs/wiki/actions/qseowActions/openhub.md new file mode 100755 index 00000000..bcdf701e --- /dev/null +++ b/docs/wiki/actions/qseowActions/openhub.md @@ -0,0 +1,14 @@ +## OpenHub action + +Open the hub in a QSEoW environment. This also makes the apps included in the response for the users `myspace` available for use by subsequent actions. The action `changestream` can be used to only select from apps in a specific stream. + + +### Example + +```json +{ + "action": "OpenHub", + "label": "Open the hub" +} +``` + diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 1464dc2e..6a98c073 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -104,6 +104,8 @@ const ( ExitCodeFailedWriteResult ExitCodeFailedHandleFields ExitCodeFailedHandleParams + ExitCodeFailedCreateFolder + ExitCodeFailedDeleteFolder ) func GenerateMarkdown(docs *CompiledDocs) { @@ -120,6 +122,9 @@ func GenerateMarkdown(docs *CompiledDocs) { fmt.Printf("Generated markdown documentation to output<%s>\n", output) } if wiki != "" { + if err := os.RemoveAll(wiki); err != nil { + common.Exit(err, ExitCodeFailedDeleteFolder) + } generateWikiFromCompiled(docs) } } @@ -136,23 +141,25 @@ func generateFullMarkdownFromCompiled(compiledDocs *CompiledDocs) []byte { func generateWikiFromCompiled(compiledDocs *CompiledDocs) { if err := createFolder(wiki); err != nil { - _, _ = os.Stderr.WriteString(fmt.Sprintf("failed to create folder<%s>: %v", wiki, err)) - return + common.Exit(err, ExitCodeFailedCreateFolder) } for _, group := range compiledDocs.Groups { fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) if err := createFolder(filepath.Join(wiki, group.Name)); err != nil { - _, _ = os.Stderr.WriteString(fmt.Sprintf("failed to create folder<%s>: %v", wiki, err)) - return + common.Exit(err, ExitCodeFailedCreateFolder) } for _, action := range group.Actions { - folderpath := filepath.Join(wiki, group.Name, action) + actionEntry := createActionEntry(compiledDocs, common.Actions(), action) + if actionEntry == nil { + continue + } + file := filepath.Join(wiki, group.Name, action) + file = fmt.Sprintf("%s.md", file) if verbose { - fmt.Printf("creating folder<%s>\n", folderpath) + fmt.Printf("creating file<%s>...\n", file) } - if err := createFolder(folderpath); err != nil { - _, _ = os.Stderr.WriteString(fmt.Sprintf("failed to create folder<%s>: %v", wiki, err)) - return + if err := os.WriteFile(file, []byte(actionEntry.String()), os.ModePerm); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) } } } @@ -170,24 +177,31 @@ func createFolder(path string) error { func addActions(node DocNode, compiledDocs *CompiledDocs, actions []string, actionSettings map[string]interface{}) { for _, action := range actions { - compiledEntry, ok := compiledDocs.Actions[action] - if !ok { - compiledEntry.Description = "*Missing description*\n" - } - actionParams := actionSettings[action] - if actionParams == nil { - os.Stderr.WriteString(fmt.Sprintf("%s gives nil actionparams, skipping...\n", action)) + actionEntry := createActionEntry(compiledDocs, actionSettings, action) + if actionEntry == nil { continue } - actionEntry := &DocEntryWithParams{ - DocEntry: DocEntry(compiledEntry), - Params: MarkdownParams(actionParams, compiledDocs.Params), - } newNode := NewFoldedDocNode(action, actionEntry) node.AddChild(newNode) } } +func createActionEntry(compiledDocs *CompiledDocs, actionSettings map[string]interface{}, action string) *DocEntryWithParams { + compiledEntry, ok := compiledDocs.Actions[action] + if !ok { + compiledEntry.Description = "*Missing description*\n" + } + actionParams := actionSettings[action] + if actionParams == nil { + os.Stderr.WriteString(fmt.Sprintf("%s gives nil actionparams, skipping...\n", action)) + return nil + } + return &DocEntryWithParams{ + DocEntry: DocEntry(compiledEntry), + Params: MarkdownParams(actionParams, compiledDocs.Params), + } +} + func addGroups(node DocNode, compiledDocs *CompiledDocs) { actionSettings := common.Actions() for _, group := range compiledDocs.Groups { From 3dc0c486ee28fcef33ec8f6c99efba54b75c14cc Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 17 Feb 2025 10:46:16 +0100 Subject: [PATCH 03/46] generate actions folder and sidebar only --- .../actions/commonActions/applybookmark.md | 21 -- .../actions/commonActions/askhubadvisor.md | 185 -------------- .../wiki/actions/commonActions/changesheet.md | 40 --- docs/wiki/actions/commonActions/clearall.md | 14 -- docs/wiki/actions/commonActions/clearfield.md | 18 -- .../commonActions/clickactionbutton.md | 42 ---- .../actions/commonActions/containertab.md | 49 ---- .../actions/commonActions/createbookmark.md | 24 -- .../wiki/actions/commonActions/createsheet.md | 19 -- .../actions/commonActions/deletebookmark.md | 25 -- .../wiki/actions/commonActions/deletesheet.md | 25 -- .../actions/commonActions/disconnectapp.md | 14 -- .../commonActions/disconnectenvironment.md | 16 -- docs/wiki/actions/commonActions/dosave.md | 13 - .../actions/commonActions/duplicatesheet.md | 23 -- docs/wiki/actions/commonActions/getscript.md | 28 --- docs/wiki/actions/commonActions/iterated.md | 38 --- .../actions/commonActions/listboxselect.md | 29 --- .../actions/commonActions/objectsearch.md | 73 ------ docs/wiki/actions/commonActions/openapp.md | 49 ---- .../actions/commonActions/productversion.md | 30 --- .../actions/commonActions/publishbookmark.md | 37 --- .../actions/commonActions/publishsheet.md | 22 -- .../actions/commonActions/randomaction.md | 130 ---------- docs/wiki/actions/commonActions/reload.md | 24 -- docs/wiki/actions/commonActions/select.md | 95 -------- docs/wiki/actions/commonActions/setscript.md | 17 -- .../actions/commonActions/setscriptvar.md | 230 ------------------ .../actions/commonActions/setsensevariable.md | 17 -- .../actions/commonActions/sheetchanger.md | 18 -- .../wiki/actions/commonActions/smartsearch.md | 154 ------------ .../actions/commonActions/stepdimension.md | 19 -- .../actions/commonActions/subscribeobjects.md | 36 --- docs/wiki/actions/commonActions/thinktime.md | 46 ---- .../commonActions/unpublishbookmark.md | 37 --- .../actions/commonActions/unpublishsheet.md | 20 -- .../commonActions/unsubscribeobjects.md | 34 --- .../wiki/actions/qseowActions/changestream.md | 36 --- docs/wiki/actions/qseowActions/deleteodag.md | 17 -- .../wiki/actions/qseowActions/generateodag.md | 17 -- docs/wiki/actions/qseowActions/openhub.md | 14 -- generatedocs/pkg/genmd/generate.go | 110 +++++++-- 42 files changed, 91 insertions(+), 1814 deletions(-) delete mode 100755 docs/wiki/actions/commonActions/applybookmark.md delete mode 100755 docs/wiki/actions/commonActions/askhubadvisor.md delete mode 100755 docs/wiki/actions/commonActions/changesheet.md delete mode 100755 docs/wiki/actions/commonActions/clearall.md delete mode 100755 docs/wiki/actions/commonActions/clearfield.md delete mode 100755 docs/wiki/actions/commonActions/clickactionbutton.md delete mode 100755 docs/wiki/actions/commonActions/containertab.md delete mode 100755 docs/wiki/actions/commonActions/createbookmark.md delete mode 100755 docs/wiki/actions/commonActions/createsheet.md delete mode 100755 docs/wiki/actions/commonActions/deletebookmark.md delete mode 100755 docs/wiki/actions/commonActions/deletesheet.md delete mode 100755 docs/wiki/actions/commonActions/disconnectapp.md delete mode 100755 docs/wiki/actions/commonActions/disconnectenvironment.md delete mode 100755 docs/wiki/actions/commonActions/dosave.md delete mode 100755 docs/wiki/actions/commonActions/duplicatesheet.md delete mode 100755 docs/wiki/actions/commonActions/getscript.md delete mode 100755 docs/wiki/actions/commonActions/iterated.md delete mode 100755 docs/wiki/actions/commonActions/listboxselect.md delete mode 100755 docs/wiki/actions/commonActions/objectsearch.md delete mode 100755 docs/wiki/actions/commonActions/openapp.md delete mode 100755 docs/wiki/actions/commonActions/productversion.md delete mode 100755 docs/wiki/actions/commonActions/publishbookmark.md delete mode 100755 docs/wiki/actions/commonActions/publishsheet.md delete mode 100755 docs/wiki/actions/commonActions/randomaction.md delete mode 100755 docs/wiki/actions/commonActions/reload.md delete mode 100755 docs/wiki/actions/commonActions/select.md delete mode 100755 docs/wiki/actions/commonActions/setscript.md delete mode 100755 docs/wiki/actions/commonActions/setscriptvar.md delete mode 100755 docs/wiki/actions/commonActions/setsensevariable.md delete mode 100755 docs/wiki/actions/commonActions/sheetchanger.md delete mode 100755 docs/wiki/actions/commonActions/smartsearch.md delete mode 100755 docs/wiki/actions/commonActions/stepdimension.md delete mode 100755 docs/wiki/actions/commonActions/subscribeobjects.md delete mode 100755 docs/wiki/actions/commonActions/thinktime.md delete mode 100755 docs/wiki/actions/commonActions/unpublishbookmark.md delete mode 100755 docs/wiki/actions/commonActions/unpublishsheet.md delete mode 100755 docs/wiki/actions/commonActions/unsubscribeobjects.md delete mode 100755 docs/wiki/actions/qseowActions/changestream.md delete mode 100755 docs/wiki/actions/qseowActions/deleteodag.md delete mode 100755 docs/wiki/actions/qseowActions/generateodag.md delete mode 100755 docs/wiki/actions/qseowActions/openhub.md diff --git a/docs/wiki/actions/commonActions/applybookmark.md b/docs/wiki/actions/commonActions/applybookmark.md deleted file mode 100755 index c16192d9..00000000 --- a/docs/wiki/actions/commonActions/applybookmark.md +++ /dev/null @@ -1,21 +0,0 @@ -## ApplyBookmark action - -Apply a bookmark in the current app. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. -* `selectionsonly`: Apply selections only. - -### Example - -```json -{ - "action": "applybookmark", - "settings": { - "title": "My bookmark" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/askhubadvisor.md b/docs/wiki/actions/commonActions/askhubadvisor.md deleted file mode 100755 index 5ecace39..00000000 --- a/docs/wiki/actions/commonActions/askhubadvisor.md +++ /dev/null @@ -1,185 +0,0 @@ -## AskHubAdvisor action - -Perform a query in the Qlik Sense hub insight advisor. -* `querysource`: The source from which queries will be randomly picked. - * `file`: Read queries from file defined by `file`. - * `querylist`: Read queries from list defined by `querylist`. -* `querylist`: A list of queries. Plain strings are supported and will get a weight of `1`. - * `weight`: A weight to set probablility of query being peformed. - * `query`: A query sentence. -* `lang`: Query language. -* `maxfollowup`: The maximum depth of followup queries asked. A value of `0` means that a query from querysource is performed without followup queries. -* `file`: Path to query file. -* `app`: Optional name of app to pick in followup queries. If not set, a random app is picked. -* `saveimages`: Save images of charts to file. -* `saveimagefile`: File name of saved images. Defaults to server side file name. Supports [Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables). -* `thinktime`: Settings for the `thinktime` action, which is automatically inserted before each followup. Defaults to a uniform distribution with mean=8 and deviation=4. - * `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. - * `delay`: Delay (seconds), used with type `static`. - * `mean`: Mean (seconds), used with type `uniform`. - * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. -* `followuptypes`: A list of followup types enabled for followup queries. If omitted, all types are enabled. - * `app`: Enable followup queries which change app. - * `measure`: Enable followups based on measures. - * `dimension`: Enable followups based on dimensions. - * `recommendation`: Enable followups based on recommendations. - * `sentence`: Enable followup queries based on bare sentences. - -### Examples - -#### Pick queries from file - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "file", - "file": "queries.txt" - } -} -``` - -The file `queries.txt` contains one query and an optional weight per line. The line format is `[WEIGHT;]QUERY`. -```txt -show sales per country -5; what is the lowest price of shoes -``` - -#### Pick queries from list - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": ["show sales per country", "what is the lowest price of shoes"] - } -} -``` - -#### Perform followup queries if possible (default: 0) - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": ["show sales per country", "what is the lowest price of shoes"], - "maxfollowup": 3 - } -} -``` - -#### Change lanuage (default: "en") - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": ["show sales per country", "what is the lowest price of shoes"], - "lang": "fr" - } -} -``` - -#### Weights in querylist - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - { - "query": "show sales per country", - "weight": 5, - }, - "what is the lowest price of shoes" - ] - } -} -``` - -#### Thinktime before followup queries - -See detailed examples of settings in the documentation for thinktime action. - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "what is the lowest price of shoes" - ], - "maxfollowup": 5, - "thinktime": { - "type": "static", - "delay": 5 - } - } -} -``` - -#### Ask followups only based on app selection - - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "what is the lowest price of shoes" - ], - "maxfollowup": 5, - "followuptypes": ["app"] - } -} -``` - -#### Save chart images to file - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "show price per shoe type" - ], - "maxfollowup": 5, - "saveimages": true - } -} -``` - -#### Save chart images to file with custom name - -The `saveimagefile` file name template setting supports -[Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables). -You can apart from session variables include the following action local variables in the `saveimagefile` file name template: -- .Local.ImageCount - _the number of images written to file_ -- .Local.ServerFileName - _the server side name of image file_ -- .Local.Query - _the query sentence_ -- .Local.AppName - _the name of app, if any app, where query is asked_ -- .Local.AppID - _the id of app, if any app, where query is asked_ - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "show price per shoe type" - ], - "maxfollowup": 5, - "saveimages": true, - "saveimagefile": "{{.Local.Query}}--app-{{.Local.AppName}}--user-{{.UserName}}--thread-{{.Thread}}--session-{{.Session}}" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/changesheet.md b/docs/wiki/actions/commonActions/changesheet.md deleted file mode 100755 index 84c854a3..00000000 --- a/docs/wiki/actions/commonActions/changesheet.md +++ /dev/null @@ -1,40 +0,0 @@ -## ChangeSheet action - -Change to a new sheet, unsubscribe to the currently subscribed objects, and subscribe to all objects on the new sheet. - -The action supports getting data from the following objects: - -* Listbox -* Filter pane -* Bar chart -* Scatter plot -* Map (only the first layer) -* Combo chart -* Table -* Pivot table -* Line chart -* Pie chart -* Tree map -* Text-Image -* KPI -* Gauge -* Box plot -* Distribution plot -* Histogram -* Auto chart (including any support generated visualization from this list) -* Waterfall chart - -* `id`: GUID of the sheet to change to. - -### Example - -```json -{ - "label": "Change Sheet Dashboard", - "action": "ChangeSheet", - "settings": { - "id": "TFJhh" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/clearall.md b/docs/wiki/actions/commonActions/clearall.md deleted file mode 100755 index 85de91c5..00000000 --- a/docs/wiki/actions/commonActions/clearall.md +++ /dev/null @@ -1,14 +0,0 @@ -## ClearAll action - -Clear all selections in an app. - - -### Example - -```json -{ - "action": "clearall", - "label": "Clear all selections (1)" -} -``` - diff --git a/docs/wiki/actions/commonActions/clearfield.md b/docs/wiki/actions/commonActions/clearfield.md deleted file mode 100755 index 26b2b244..00000000 --- a/docs/wiki/actions/commonActions/clearfield.md +++ /dev/null @@ -1,18 +0,0 @@ -## ClearField action - -Clear selections in a field. - -* `name`: Name of field to clear. - -### Example - -```json -{ - "action": "clearfield", - "label": "Clear selections in Alpha", - "settings" : { - "name": "Alpha" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/clickactionbutton.md b/docs/wiki/actions/commonActions/clickactionbutton.md deleted file mode 100755 index 160530b0..00000000 --- a/docs/wiki/actions/commonActions/clickactionbutton.md +++ /dev/null @@ -1,42 +0,0 @@ -## ClickActionButton action - -A `ClickActionButton`-action simulates clicking an _action-button_. An _action-button_ is a sheet item which, when clicked, executes a series of actions. The series of actions contained by an action-button begins with any number _generic button-actions_ and ends with an optional _navigation button-action_. - -### Supported button-actions -#### Generic button-actions -- Apply bookmark -- Move backward in all selections -- Move forward in all selections -- Lock all selections -- Clear all selections -- Lock field -- Unlock field -- Select all in field -- Select alternatives in field -- Select excluded in field -- Select possible in field -- Select values matching search criteria in field -- Clear selection in field -- Toggle selection in field -- Set value of variable - -#### Navigation button-actions -- Change to first sheet -- Change to last sheet -- Change to previous sheet -- Change sheet by name -- Change sheet by ID -* `id`: ID of the action-button to click. - -### Examples - -```json -{ - "label": "ClickActionButton", - "action": "ClickActionButton", - "settings": { - "id": "951e2eee-ad49-4f6a-bdfe-e9e3dddeb2cd" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/containertab.md b/docs/wiki/actions/commonActions/containertab.md deleted file mode 100755 index 22894e81..00000000 --- a/docs/wiki/actions/commonActions/containertab.md +++ /dev/null @@ -1,49 +0,0 @@ -## Containertab action - -A `Containertab` action simulates switching the active object in a `container` object. - -* `mode`: Mode for container tab switching, one of: `objectid`, `random` or `index`. - * `objectid`: Switch to tab with object defined by `objectid`. - * `random`: Switch to a random visible tab within the container. - * `index`: Switch to tab with zero based index defined but `index`. -* `containerid`: ID of the container object. -* `objectid`: ID of the object to set as active, used with mode `objectid`. -* `index`: Zero based index of tab to switch to, used with mode `index`. - -### Examples - -```json -{ - "label": "Switch to object qwerty in container object XYZ", - "action": "containertab", - "settings": { - "containerid": "xyz", - "mode": "id", - "objectid" : "qwerty" - } -} -``` - -```json -{ - "label": "Switch to random object in container object XYZ", - "action": "containertab", - "settings": { - "containerid": "xyz", - "mode": "random" - } -} -``` - -```json -{ - "label": "Switch to object in first tab in container object XYZ", - "action": "containertab", - "settings": { - "containerid": "xyz", - "mode": "index", - "index": 0 - } -} -``` - diff --git a/docs/wiki/actions/commonActions/createbookmark.md b/docs/wiki/actions/commonActions/createbookmark.md deleted file mode 100755 index ba8178e0..00000000 --- a/docs/wiki/actions/commonActions/createbookmark.md +++ /dev/null @@ -1,24 +0,0 @@ -## CreateBookmark action - -Create a bookmark from the current selection and selected sheet. - -**Note:** Both `title` and `id` can be used to identify the bookmark in subsequent actions. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. -* `description`: (optional) Description of the bookmark to create. -* `nosheet`: Do not include the sheet location in the bookmark. -* `savelayout`: Include the layout in the bookmark. - -### Example - -```json -{ - "action": "createbookmark", - "settings": { - "title": "my bookmark", - "description": "This bookmark contains some interesting selections" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/createsheet.md b/docs/wiki/actions/commonActions/createsheet.md deleted file mode 100755 index e7ada68e..00000000 --- a/docs/wiki/actions/commonActions/createsheet.md +++ /dev/null @@ -1,19 +0,0 @@ -## CreateSheet action - -Create a new sheet in the current app. - -* `id`: (optional) ID to be used to identify the sheet in any subsequent `changesheet`, `duplicatesheet`, `publishsheet` or `unpublishsheet` action. -* `title`: Name of the sheet to create. -* `description`: (optional) Description of the sheet to create. - -### Example - -```json -{ - "action": "createsheet", - "settings": { - "title" : "Generated sheet" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/deletebookmark.md b/docs/wiki/actions/commonActions/deletebookmark.md deleted file mode 100755 index cc8b7d9b..00000000 --- a/docs/wiki/actions/commonActions/deletebookmark.md +++ /dev/null @@ -1,25 +0,0 @@ -## DeleteBookmark action - -Delete one or more bookmarks in the current app. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. -* `mode`: - * `single`: Delete one bookmark that matches the specified `title` or `id` in the current app. - * `matching`: Delete all bookmarks with the specified `title` in the current app. - * `all`: Delete all bookmarks in the current app. - -### Example - -```json -{ - "action": "deletebookmark", - "settings": { - "mode": "single", - "title": "My bookmark" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/deletesheet.md b/docs/wiki/actions/commonActions/deletesheet.md deleted file mode 100755 index ca485e03..00000000 --- a/docs/wiki/actions/commonActions/deletesheet.md +++ /dev/null @@ -1,25 +0,0 @@ -## DeleteSheet action - -Delete one or more sheets in the current app. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `mode`: - * `single`: Delete one sheet that matches the specified `title` or `id` in the current app. - * `matching`: Delete all sheets with the specified `title` in the current app. - * `allunpublished`: Delete all unpublished sheets in the current app. -* `title`: (optional) Name of the sheet to delete. -* `id`: (optional) GUID of the sheet to delete. - -### Example - -```json -{ - "action": "deletesheet", - "settings": { - "mode": "matching", - "title": "Test sheet" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/disconnectapp.md b/docs/wiki/actions/commonActions/disconnectapp.md deleted file mode 100755 index 05a7c666..00000000 --- a/docs/wiki/actions/commonActions/disconnectapp.md +++ /dev/null @@ -1,14 +0,0 @@ -## DisconnectApp action - -Disconnect from an already connected app. - - -### Example - -```json -{ - "label": "Disconnect from server", - "action" : "disconnectapp" -} -``` - diff --git a/docs/wiki/actions/commonActions/disconnectenvironment.md b/docs/wiki/actions/commonActions/disconnectenvironment.md deleted file mode 100755 index 0f5bfb6e..00000000 --- a/docs/wiki/actions/commonActions/disconnectenvironment.md +++ /dev/null @@ -1,16 +0,0 @@ -## DisconnectEnvironment action - -Disconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environmentsor uses custom actions towards external environment, it should be used directly after the last action towards the environment. - -Since the action also disconnects any open websocket to Sense apps, it does not need to be preceeded with a `disconnectapp` action. - - -### Example - -```json -{ - "label": "Disconnect from environment", - "action" : "disconnectenvironment" -} -``` - diff --git a/docs/wiki/actions/commonActions/dosave.md b/docs/wiki/actions/commonActions/dosave.md deleted file mode 100755 index 1ecdc46e..00000000 --- a/docs/wiki/actions/commonActions/dosave.md +++ /dev/null @@ -1,13 +0,0 @@ -## DoSave action - -`DoSave` issues a command to engine to save the currently open app. If the simulated user does not have permission to save the app it will result in an error. - -### Example - -```json -{ - "label": "Save MyApp", - "action" : "dosave" -} -``` - diff --git a/docs/wiki/actions/commonActions/duplicatesheet.md b/docs/wiki/actions/commonActions/duplicatesheet.md deleted file mode 100755 index 02a01c3e..00000000 --- a/docs/wiki/actions/commonActions/duplicatesheet.md +++ /dev/null @@ -1,23 +0,0 @@ -## DuplicateSheet action - -Duplicate a sheet, including all objects. - -* `id`: ID of the sheet to clone. -* `changesheet`: Clear the objects currently subscribed to and then subribe to all objects on the cloned sheet (which essentially corresponds to using the `changesheet` action to go to the cloned sheet) (`true` / `false`). Defaults to `false`, if omitted. -* `save`: Execute `saveobjects` after the cloning operation to save all modified objects (`true` / `false`). Defaults to `false`, if omitted. -* `cloneid`: (optional) ID to be used to identify the sheet in any subsequent `changesheet`, `duplicatesheet`, `publishsheet` or `unpublishsheet` action. - -### Example - -```json -{ - "action": "duplicatesheet", - "label": "Duplicate sheet1", - "settings":{ - "id" : "mBshXB", - "save": true, - "changesheet": true - } -} -``` - diff --git a/docs/wiki/actions/commonActions/getscript.md b/docs/wiki/actions/commonActions/getscript.md deleted file mode 100755 index 0faef778..00000000 --- a/docs/wiki/actions/commonActions/getscript.md +++ /dev/null @@ -1,28 +0,0 @@ -## GetScript action - -Get the load script for the app. - - -* `savelog`: Save load script to log file under the INFO log labelled *LoadScript* - -### Example - -Get the load script for the app - -```json -{ - "action": "getscript" -} -``` - -Get the load script for the app and save to log file - -```json -{ - "action": "getscript", - "settings": { - "savelog" : true - } -} -``` - diff --git a/docs/wiki/actions/commonActions/iterated.md b/docs/wiki/actions/commonActions/iterated.md deleted file mode 100755 index 93aac4b5..00000000 --- a/docs/wiki/actions/commonActions/iterated.md +++ /dev/null @@ -1,38 +0,0 @@ -## Iterated action - -Loop one or more actions. - -**Note:** This action does not require an app context (that is, it does not have to be prepended with an `openapp` action). - -* `iterations`: Number of loops. -* `actions`: Actions to iterate - * `action`: Name of the action to execute. - * `label`: (optional) Custom string set by the user. This can be used to distinguish the action from other actions of the same type when analyzing the test results. - * `disabled`: (optional) Disable action (`true` / `false`). If set to `true`, the action is not executed. - * `settings`: Most, but not all, actions have a settings section with action-specific settings. - -### Example - -```json -//Visit all sheets twice -{ - "action": "iterated", - "label": "", - "settings": { - "iterations" : 2, - "actions" : [ - { - "action": "sheetchanger" - }, - { - "action": "thinktime", - "settings": { - "type": "static", - "delay": 5 - } - } - ] - } -} -``` - diff --git a/docs/wiki/actions/commonActions/listboxselect.md b/docs/wiki/actions/commonActions/listboxselect.md deleted file mode 100755 index 59f27cda..00000000 --- a/docs/wiki/actions/commonActions/listboxselect.md +++ /dev/null @@ -1,29 +0,0 @@ -## ListBoxSelect action - -Perform list object specific selectiontypes in listbox. - - -* `id`: ID of the listbox in which to select values. -* `type`: Selection type. - * `all`: Select all values. - * `alternative`: Select alternative values. - * `excluded`: Select excluded values. - * `possible`: Select possible values. -* `accept`: Accept or abort selection after selection (only used with `wrap`) (`true` / `false`). -* `wrap`: Wrap selection with Begin / End selection requests (`true` / `false`). - -### Examples - -```json -{ - "label": "ListBoxSelect", - "action": "ListBoxSelect", - "settings": { - "id": "951e2eee-ad49-4f6a-bdfe-e9e3dddeb2cd", - "type": "all", - "wrap": true, - "accept": true - } -} -``` - diff --git a/docs/wiki/actions/commonActions/objectsearch.md b/docs/wiki/actions/commonActions/objectsearch.md deleted file mode 100755 index d8d38e37..00000000 --- a/docs/wiki/actions/commonActions/objectsearch.md +++ /dev/null @@ -1,73 +0,0 @@ -## ObjectSearch action - -Perform a search select in a listbox, field or master dimension. - - -* `id`: Identifier for the object, this would differ depending on `type`. - * `listbox`: Use the ID of listbox object - * `field`: Use the name of the field - * `dimension`: Use the title of the dimension masterobject. -* `searchterms`: List of search terms to search for. -* `type`: Type of object to search - * `listbox`: (Default) `id` is the ID of a listbox. - * `field`: `id` is the name of a field. - * `dimension`: `id` is the title of a master object dimension. -* `source`: Source of search terms - * `fromlist`: (Default) Use search terms from `searchterms` array. - * `fromfile`: Use search term from file defined by `searchtermsfile` -* `erroronempty`: If set to true and the object search yields an empty result, the action will result in an error. Defaults to false. -* `searchtermsfile`: Path to search terms file when using `source` of type `fromfile`. File should contain one term per row. - -### Examples - -Search a listbox object, all users searches for same thing and gets an error if no result found - -```json -{ - "label": "Search and select Sweden in listbox", - "action": "objectsearch", - "settings": { - "id": "maesVjgte", - "searchterms": ["Sweden"], - "type": "listbox", - "erroronempty": true - } -} -``` - -Search a field. Users use one random search term from the `searchterms` list. - -```json -{ - "label": "Search field", - "action": "objectsearch", - "disabled": false, - "settings": { - "id": "Countries", - "searchterms": [ - "Sweden", - "Germany", - "Liechtenstein" - ], - "type": "field" - } -} -``` - -Search a master object dimension using search terms from a file. - -```json -{ - "label": "Search dimension", - "action": "objectsearch", - "disabled": false, - "settings": { - "id": "Dim1M", - "type": "dimension", - "erroronempty": true, - "source": "fromfile", - "searchtermsfile": "./resources/objectsearchterms.txt" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/openapp.md b/docs/wiki/actions/commonActions/openapp.md deleted file mode 100755 index ba0b5bf8..00000000 --- a/docs/wiki/actions/commonActions/openapp.md +++ /dev/null @@ -1,49 +0,0 @@ -## OpenApp action - -Open an app. - -**Note:** If the app name is used to specify which app to open, this action cannot be the first action in the scenario. It must be preceded by an action that can populate the artifact map, such as `openhub`. - -* `appmode`: App selection mode - * `current`: (default) Use the current app, selected by an app selection in a previous action - * `guid`: Use the app GUID specified by the `app` parameter. - * `name`: Use the app name specified by the `app` parameter. - * `random`: Select a random app from the artifact map, which is filled by e.g. `openhub` - * `randomnamefromlist`: Select a random app from a list of app names. The `list` parameter should contain a list of app names. - * `randomguidfromlist`: Select a random app from a list of app GUIDs. The `list` parameter should contain a list of app GUIDs. - * `randomnamefromfile`: Select a random app from a file with app names. The `filename` parameter should contain the path to a file in which each line represents an app name. - * `randomguidfromfile`: Select a random app from a file with app GUIDs. The `filename` parameter should contain the path to a file in which each line represents an app GUID. - * `round`: Select an app from the artifact map according to the round-robin principle. - * `roundnamefromlist`: Select an app from a list of app names according to the round-robin principle. The `list` parameter should contain a list of app names. - * `roundguidfromlist`: Select an app from a list of app GUIDs according to the round-robin principle. The `list` parameter should contain a list of app GUIDs. - * `roundnamefromfile`: Select an app from a file with app names according to the round-robin principle. The `filename` parameter should contain the path to a file in which each line represents an app name. - * `roundguidfromfile`: Select an app from a file with app GUIDs according to the round-robin principle. The `filename` parameter should contain the path to a file in which each line represents an app GUID. -* `app`: App name or app GUID (supports the use of [session variables](#session_variables)). Used with `appmode` set to `guid` or `name`. -* `list`: List of apps. Used with `appmode` set to `randomnamefromlist`, `randomguidfromlist`, `roundnamefromlist` or `roundguidfromlist`. -* `filename`: Path to a file in which each line represents an app. Used with `appmode` set to `randomnamefromfile`, `randomguidfromfile`, `roundnamefromfile` or `roundguidfromfile`. -* `externalhost`: (optional) Sets an external host to be used instead of `server` configured in connection settings. -* `unique`: Create unqiue engine session not re-using session from previous connection with same user. Defaults to false. - -### Examples - -```json -{ - "label": "OpenApp", - "action": "OpenApp", - "settings": { - "appmode": "guid", - "app": "7967af99-68b6-464a-86de-81de8937dd56" - } -} -``` -```json -{ - "label": "OpenApp", - "action": "OpenApp", - "settings": { - "appmode": "randomguidfromlist", - "list": ["7967af99-68b6-464a-86de-81de8937dd56", "ca1a9720-0f42-48e5-baa5-597dd11b6cad"] - } -} -``` - diff --git a/docs/wiki/actions/commonActions/productversion.md b/docs/wiki/actions/commonActions/productversion.md deleted file mode 100755 index 25c74e6b..00000000 --- a/docs/wiki/actions/commonActions/productversion.md +++ /dev/null @@ -1,30 +0,0 @@ -## ProductVersion action - -Request the product version from the server and, optionally, save it to the log. This is a lightweight request that can be used as a keep-alive message in a loop. - -* `log`: Save the product version to the log (`true` / `false`). Defaults to `false`, if omitted. - -### Example - -```json -//Keep-alive loop -{ - "action": "iterated", - "settings" : { - "iterations" : 10, - "actions" : [ - { - "action" : "productversion" - }, - { - "action": "thinktime", - "settings": { - "type": "static", - "delay": 30 - } - } - ] - } -} -``` - diff --git a/docs/wiki/actions/commonActions/publishbookmark.md b/docs/wiki/actions/commonActions/publishbookmark.md deleted file mode 100755 index 2bc5be82..00000000 --- a/docs/wiki/actions/commonActions/publishbookmark.md +++ /dev/null @@ -1,37 +0,0 @@ -## PublishBookmark action - -Publish a bookmark. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. - -### Example - -Publish the bookmark with `id` "bookmark1" that was created earlier on in the script. - -```json -{ - "label" : "Publish bookmark 1", - "action": "publishbookmark", - "disabled" : false, - "settings" : { - "id" : "bookmark1" - } -} -``` - -Publish the bookmark with the `title` "bookmark of testuser", where "testuser" is the username of the simulated user. - -```json -{ - "label" : "Publish bookmark 2", - "action": "publishbookmark", - "disabled" : false, - "settings" : { - "title" : "bookmark of {{.UserName}}" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/publishsheet.md b/docs/wiki/actions/commonActions/publishsheet.md deleted file mode 100755 index c135afaa..00000000 --- a/docs/wiki/actions/commonActions/publishsheet.md +++ /dev/null @@ -1,22 +0,0 @@ -## PublishSheet action - -Publish sheets in the current app. - -* `mode`: - * `allsheets`: Publish all sheets in the app. - * `sheetids`: Only publish the sheets specified by the `sheetIds` array. -* `sheetIds`: (optional) Array of sheet IDs for the `sheetids` mode. -* `includePublished`: Try to publish already published sheets. - -### Example -```json -{ - "label": "PublishSheets", - "action": "publishsheet", - "settings": { - "mode": "sheetids", - "sheetIds": ["qmGcYS", "bKbmgT"] - } -} -``` - diff --git a/docs/wiki/actions/commonActions/randomaction.md b/docs/wiki/actions/commonActions/randomaction.md deleted file mode 100755 index cac12c5c..00000000 --- a/docs/wiki/actions/commonActions/randomaction.md +++ /dev/null @@ -1,130 +0,0 @@ -## RandomAction action - -Randomly select other actions to perform. This meta-action can be used as a starting point for your testing efforts, to simplify script authoring or to add background load. - -`randomaction` accepts a list of action types between which to randomize. An execution of `randomaction` executes one or more of the listed actions (as determined by the `iterations` parameter), randomly chosen by a weighted probability. If nothing else is specified, each action has a default random mode that is used. An override is done by specifying one or more parameters of the original action. - -Each action executed by `randomaction` is followed by a customizable `thinktime`. - -**Note:** The recommended way to use this action is to prepend it with an `openapp` and a `changesheet` action as this ensures that a sheet is always in context. - -* `actions`: List of actions from which to randomly pick an action to execute. Each item has a number of possible parameters. - * `type`: Type of action - * `thinktime`: See the `thinktime` action. - * `sheetobjectselection`: Make random selections within objects visible on the current sheet. See the `select` action. - * `changesheet`: See the `changesheet` action. - * `clearall`: See the `clearall` action. - * `weight`: The probabilistic weight of the action, specified as an integer. This number is proportional to the likelihood of the specified action, and is used as a weight in a uniform random selection. - * `overrides`: (optional) Static overrides to the action. The overrides can include any or all of the settings from the original action, as determined by the `type` field. If nothing is specified, the default values are used. -* `thinktimesettings`: Settings for the `thinktime` action, which is automatically inserted after every randomized action. - * `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. - * `delay`: Delay (seconds), used with type `static`. - * `mean`: Mean (seconds), used with type `uniform`. - * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. -* `iterations`: Number of random actions to perform. - -### Random action defaults - -The following default values are used for the different actions: - -* `thinktime`: Mirrors the configuration of `thinktimesettings` -* `sheetobjectselection`: - -```json -{ - "settings": - { - "id": , - "type": "RandomFromAll", - "min": 1, - "max": 2, - "accept": true - } -} -``` - -* `changesheet`: - -```json -{ - "settings": - { - "id": - } -} -``` - -* `clearall`: - -```json -{ - "settings": - { - } -} -``` - -### Examples - -#### Generating a background load by executing 5 random actions - -```json -{ - "action": "RandomAction", - "settings": { - "iterations": 5, - "actions": [ - { - "type": "thinktime", - "weight": 1 - }, - { - "type": "sheetobjectselection", - "weight": 3 - }, - { - "type": "changesheet", - "weight": 5 - }, - { - "type": "clearall", - "weight": 1 - } - ], - "thinktimesettings": { - "type": "uniform", - "mean": 10, - "dev": 5 - } - } -} -``` - -#### Making random selections from excluded values - -```json -{ - "action": "RandomAction", - "settings": { - "iterations": 1, - "actions": [ - { - "type": "sheetobjectselection", - "weight": 1, - "overrides": { - "type": "RandomFromExcluded", - "min": 1, - "max": 5 - } - } - ], - "thinktimesettings": { - "type": "static", - "delay": 1 - } - } -} -``` - diff --git a/docs/wiki/actions/commonActions/reload.md b/docs/wiki/actions/commonActions/reload.md deleted file mode 100755 index 8178c6a0..00000000 --- a/docs/wiki/actions/commonActions/reload.md +++ /dev/null @@ -1,24 +0,0 @@ -## Reload action - -Reload the current app by simulating selecting **Load data** in the Data load editor. To select an app, preceed this action with an `openapp` action. - -* `mode`: Error handling during the reload operation - * `default`: Use the default error handling. - * `abend`: Stop reloading the script, if an error occurs. - * `ignore`: Continue reloading the script even if an error is detected in the script. -* `partial`: Enable partial reload (`true` / `false`). This allows you to add data to an app without reloading all data. Defaults to `false`, if omitted. -* `log`: Save the reload log as a field in the output (`true` / `false`). Defaults to `false`, if omitted. **Note:** This should only be used when needed as the reload log can become very large. -* `nosave`: Do not send a save request for the app after the reload is done. Defaults to saving the app. - -### Example - -```json -{ - "action": "reload", - "settings": { - "mode" : "default", - "partial": false - } -} -``` - diff --git a/docs/wiki/actions/commonActions/select.md b/docs/wiki/actions/commonActions/select.md deleted file mode 100755 index 61b60097..00000000 --- a/docs/wiki/actions/commonActions/select.md +++ /dev/null @@ -1,95 +0,0 @@ -## Select action - -Select random values in an object. - -See the [Limitations](README.md#limitations) section in the README.md file for limitations related to this action. - -* `id`: ID of the object in which to select values. -* `type`: Selection type - * `randomfromall`: Randomly select within all values of the symbol table. - * `randomfromenabled`: Randomly select within the white and light grey values on the first data page. - * `randomfromexcluded`: Randomly select within the dark grey values on the first data page. - * `randomdeselect`: Randomly deselect values on the first data page. - * `values`: Select specific element values, defined by `values` array. -* `accept`: Accept or abort selection after selection (only used with `wrap`) (`true` / `false`). -* `wrap`: Wrap selection with Begin / End selection requests (`true` / `false`). -* `min`: Minimum number of selections to make. -* `max`: Maximum number of selections to make. -* `dim`: Dimension / column in which to select. -* `values`: Array of element values to select when using selection type `values`. These are the element values for a selection, not the values seen by the user. - -### Example - -Randomly select among all the values in object `RZmvzbF`. - -```json -{ - "label": "ListBox Year", - "action": "Select", - "settings": { - "id": "RZmvzbF", - "type": "RandomFromAll", - "accept": true, - "wrap": false, - "min": 1, - "max": 3, - "dim": 0 - } -} -``` - -Randomly select among all the enabled values (a.k.a "white" values) in object `RZmvzbF`. - -```json -{ - "label": "ListBox Year", - "action": "Select", - "settings": { - "id": "RZmvzbF", - "type": "RandomFromEnabled", - "accept": true, - "wrap": false, - "min": 1, - "max": 3, - "dim": 0 - } -} -``` - -#### Statically selecting specific values - -This example selects specific element values in object `RZmvzbF`. These are the values which can be seen in a selection when e.g. inspecting traffic, it is not the data values presented to the user. E.g. when loading a table in the following order by a Sense loadscript: - -``` -Beta -Alpha -Gamma -``` - -which might be presented to the user sorted as - -``` -Alpha -Beta -Gamma -``` - -The element values will be Beta=0, Alpha=1 and Gamma=2. - -To statically select "Gamma" in this case: - -```json -{ - "label": "Select Gammma", - "action": "Select", - "settings": { - "id": "RZmvzbF", - "type": "values", - "accept": true, - "wrap": false, - "values" : [2], - "dim": 0 - } -} -``` - diff --git a/docs/wiki/actions/commonActions/setscript.md b/docs/wiki/actions/commonActions/setscript.md deleted file mode 100755 index 55a8f76f..00000000 --- a/docs/wiki/actions/commonActions/setscript.md +++ /dev/null @@ -1,17 +0,0 @@ -## SetScript action - -Set the load script for the current app. To load the data from the script, use the `reload` action after the `setscript` action. - -* `script`: Load script for the app (written as a string). - -### Example - -```json -{ - "action": "setscript", - "settings": { - "script" : "Characters:\nLoad Chr(RecNo()+Ord('A')-1) as Alpha, RecNo() as Num autogenerate 26;" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/setscriptvar.md b/docs/wiki/actions/commonActions/setscriptvar.md deleted file mode 100755 index c900afd1..00000000 --- a/docs/wiki/actions/commonActions/setscriptvar.md +++ /dev/null @@ -1,230 +0,0 @@ -## SetScriptVar action - -Sets a variable which can be used within the same session. Cannot be accessed across different simulated users. - -* `name`: Name of variable to set. Will overwrite any existing variable with same name. -* `type`: Type of the variable. - * `string`: Variable of type string e.g. `my var value`. - * `int`: Variable of type integer e.g. `6`. - * `array`: Variable of type array e.g. `1,2,3`. -* `value`: Value to set to variable (supports the use of [session variables](#session_variables)). -* `sep`: Separator to use when separating string into array. Defaults to `,`. - -### Example - -Create a variable containing a string and use it in openapp. - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "mylocalvar", - "type": "string", - "value": "My app Name with number for session {{ .Session }}" - } -}, -{ - "action": "openapp", - "settings": { - "appmode": "name", - "app": "{{ .ScriptVars.mylocalvar }}" - } -} -``` - -Create a variable containing an integer and use it in a loop creating bookmarks numbered 1 to 5. Then in a different loop reset variable and delete the bookmarks. - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "settings": { - "iterations": 5, - "actions": [ - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ add .ScriptVars.BookmarkCounter 1 }}" - } - }, - { - "action": "createbookmark", - "settings": { - "title": "Bookmark {{ .ScriptVars.BookmarkCounter }}", - "description": "This bookmark contains some interesting selections" - } - } - - ] - } -}, -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - }, - { - "action": "deletebookmark", - "settings": { - "mode": "single", - "title": "Bookmark {{ $element:=range.ScriptVars.BookmarkCounter }} {{ $element }}{{ end }}" - } - } - ] - } -} -``` - -Combine two variables `MyArrayVar` and `BookmarkCounter` to create 3 bookmarks with the names `Bookmark one`, `Bookmark two` and `Bookmark three`. - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "MyArrayVar", - "type": "array", - "value": "one,two,three,four,five", - "sep": "," - } -}, -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "createbookmark", - "settings": { - "title": "Bookmark {{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}", - "description": "This bookmark contains some interesting selections" - } - }, - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - } - ] - } -} - ``` - -A more advanced example. - -Create a bookmark "BookmarkX" for each iteration in a loop, and add this to an array "MyArrayVar". After the first `iterated` action this will look like "Bookmark1,Bookmark2,Bookmark3". The second `iterated` action then deletes these bookmarks using the created array. - -Dissecting the first array construction action. The `join` command takes the elements `.ScriptVars.MyArrayVar` and joins them together into a string separated by the separtor `,`. So with an array of [ elem1 elem2 ] this becomes a string as `elem1,elem2`. The `if` statement checks if the value of `.ScriptVars.BookmarkCounter` is 0, if it is 0 (i.e. the first iteration) it sets the string to `Bookmark1`. If it is not 0, it executes the join command on .ScriptVars.MyArrayVar, on iteration 3, the result of this would be `Bookmark1,Bookmark2` then it appends the fixed string `,Bookmark`, so far the string is `Bookmark1,Bookmark2,Bookmark`. Lastly it takes the value of `.ScriptVars.BookmarkCounter`, which is now 2, and adds 1 too it and appends, making the entire string `Bookmark1,Bookmark2,Bookmark3`. - - ```json -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "setscriptvar", - "settings": { - "name": "MyArrayVar", - "type": "array", - "value": "{{ if eq 0 .ScriptVars.BookmarkCounter }}Bookmark1{{ else }}{{ join .ScriptVars.MyArrayVar \",\" }},Bookmark{{ .ScriptVars.BookmarkCounter | add 1 }}{{ end }}", - "sep": "," - } - }, - { - "action": "createbookmark", - "settings": { - "title": "{{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}", - "description": "This bookmark contains some interesting selections" - } - }, - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - } - ] - } -}, -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "deletebookmark", - "settings": { - "mode": "single", - "title": "{{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}" - } - }, - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - } - ] - } -} - ``` diff --git a/docs/wiki/actions/commonActions/setsensevariable.md b/docs/wiki/actions/commonActions/setsensevariable.md deleted file mode 100755 index b333c8d5..00000000 --- a/docs/wiki/actions/commonActions/setsensevariable.md +++ /dev/null @@ -1,17 +0,0 @@ -## SetSenseVariable action - -Sets a Qlik Sense variable on a sheet in the open app. - -* `name`: Name of the Qlik Sense variable to set. -* `value`: Value to set the Qlik Sense variable to. (supports the use of [session variables](#session_variables)) - -### Example - -Set a variable to 2000 - -```json -{ - "name": "vSampling", - "value": "2000" -} -``` diff --git a/docs/wiki/actions/commonActions/sheetchanger.md b/docs/wiki/actions/commonActions/sheetchanger.md deleted file mode 100755 index 7118eef6..00000000 --- a/docs/wiki/actions/commonActions/sheetchanger.md +++ /dev/null @@ -1,18 +0,0 @@ -## SheetChanger action - -Create and execute a `changesheet` action for each sheet in an app. This can be used to cache the inital state for all objects or, by chaining two subsequent `sheetchanger` actions, to measure how well the calculations in an app utilize the cache. - - -### Example - -```json -{ - "label" : "Sheetchanger uncached", - "action": "sheetchanger" -}, -{ - "label" : "Sheetchanger cached", - "action": "sheetchanger" -} -``` - diff --git a/docs/wiki/actions/commonActions/smartsearch.md b/docs/wiki/actions/commonActions/smartsearch.md deleted file mode 100755 index 704e7b43..00000000 --- a/docs/wiki/actions/commonActions/smartsearch.md +++ /dev/null @@ -1,154 +0,0 @@ -## SmartSearch action - -Perform a Smart Search in Sense app to find suggested selections. - -* `searchtextsource`: Source for list of strings used for searching. - * `searchtextlist` (default) - * `searchtextfile` -* `searchtextlist`: List of of strings used for searching. -* `searchtextfile`: File path to file with one search string per line. -* `pastesearchtext`: - * `true`: Simulate pasting search text. - * `false`: Simulate typing at normal speed (default). -* `makeselection`: Select a random search result. - * `true` - * `false` -* `selectionthinktime`: Think time before selection if `makeselection` is `true`, defaults to a 1 second delay. - * `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. - * `delay`: Delay (seconds), used with type `static`. - * `mean`: Mean (seconds), used with type `uniform`. - * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. - - -### Examples - -#### Search with one search term -```json -{ - "action": "smartsearch", - "label": "one term search", - "settings": { - "searchtextlist": [ - "term1" - ] - } -} -``` - -#### Search with two search terms -```json -{ - "action": "smartsearch", - "label": "two term search", - "settings": { - "searchtextlist": [ - "term1 term2" - ] - } -} -``` - -#### Search with random selection of search text from list -```json -{ - "action": "smartsearch", - "settings": { - "searchtextlist": [ - "text1", - "text2", - "text3" - ] - } -} -``` - -#### Search with random selection of search text from file -```json -{ - "action": "smartsearch", - "settings": { - "searchtextsource": "searchtextfile", - "searchtextfile": "data/searchtexts.txt" - } -} -``` -##### `data/searchtexts.txt` -``` -search text -"quoted search text" -another search text -``` - -#### Simulate pasting search text - -The default behavior is to simulate typing at normal speed. -```json -{ - "action": "smartsearch", - "settings": { - "pastesearchtext": true, - "searchtextlist": [ - "text1" - ] - } -} -``` - -#### Make a random selection from search results -```json -{ - "action": "smartsearch", - "settings": { - "searchtextlist": [ - "term1" - ], - "makeselection": true, - "selectionthinktime": { - "type": "static", - "delay": 2 - } - } -} -``` - -#### Search with one search term including spaces -```json -{ - "action": "smartsearch", - "settings": { - "searchtextlist": [ - "\"word1 word2\"" - ] - } -} -``` - -#### Search with two search terms, one of them including spaces -```json -{ - "action": "smartsearch", - "label": "two term search, one including spaces", - "settings": { - "searchtextlist": [ - "\"word1 word2\" term2" - ] - } -} -``` - -#### Search with one search term including double quote -```json -{ - "action": "smartsearch", - "label": "one term search including spaces", - "settings": { - "searchtext": - "searchtextlist": [ - "\\\"hello" - ] - } -} -``` - diff --git a/docs/wiki/actions/commonActions/stepdimension.md b/docs/wiki/actions/commonActions/stepdimension.md deleted file mode 100755 index a51bc030..00000000 --- a/docs/wiki/actions/commonActions/stepdimension.md +++ /dev/null @@ -1,19 +0,0 @@ -## StepDimension action - -Cycle a step in a cyclic dimension - -* `id`: library ID of the cyclic dimension - -### Example - -Cycle one step in the dimension with library ID `aBc123`. - -```json -{ - "action": "stepdimension", - "settings":{ - "id": "aBc123" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/subscribeobjects.md b/docs/wiki/actions/commonActions/subscribeobjects.md deleted file mode 100755 index d1373727..00000000 --- a/docs/wiki/actions/commonActions/subscribeobjects.md +++ /dev/null @@ -1,36 +0,0 @@ -## Subscribeobjects action - -Subscribe to any object in the currently active app. - -* `clear`: Remove any previously subscribed objects from the subscription list. -* `ids`: List of object IDs to subscribe to. - -### Example - -Subscribe to two objects in the currently active app and remove any previous subscriptions. - -```json -{ - "action" : "subscribeobjects", - "label" : "clear subscriptions and subscribe to mBshXB and f2a50cb3-a7e1-40ac-a015-bc4378773312", - "disabled": false, - "settings" : { - "clear" : true, - "ids" : ["mBshXB", "f2a50cb3-a7e1-40ac-a015-bc4378773312"] - } -} -``` - -Subscribe to an additional single object (or a list of objects) in the currently active app, adding the new subscription to any previous subscriptions. - -```json -{ - "action" : "subscribeobjects", - "label" : "add c430d8e2-0f05-49f1-aa6f-7234e325dc35 to currently subscribed objects", - "disabled": false, - "settings" : { - "clear" : false, - "ids" : ["c430d8e2-0f05-49f1-aa6f-7234e325dc35"] - } -} -``` diff --git a/docs/wiki/actions/commonActions/thinktime.md b/docs/wiki/actions/commonActions/thinktime.md deleted file mode 100755 index 9e21ba6e..00000000 --- a/docs/wiki/actions/commonActions/thinktime.md +++ /dev/null @@ -1,46 +0,0 @@ -## ThinkTime action - -Simulate user think time. - -**Note:** This action does not require an app context (that is, it does not have to be prepended with an `openapp` action). - -* `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. -* `delay`: Delay (seconds), used with type `static`. -* `mean`: Mean (seconds), used with type `uniform`. -* `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. - -### Examples - -#### ThinkTime uniform - -This simulates a think time of 10 to 15 seconds. - -```json -{ - "label": "TimerDelay", - "action": "thinktime", - "settings": { - "type": "uniform", - "mean": 12.5, - "dev": 2.5 - } -} -``` - -#### ThinkTime constant - -This simulates a think time of 5 seconds. - -```json -{ - "label": "TimerDelay", - "action": "thinktime", - "settings": { - "type": "static", - "delay": 5 - } -} -``` - diff --git a/docs/wiki/actions/commonActions/unpublishbookmark.md b/docs/wiki/actions/commonActions/unpublishbookmark.md deleted file mode 100755 index 417896a7..00000000 --- a/docs/wiki/actions/commonActions/unpublishbookmark.md +++ /dev/null @@ -1,37 +0,0 @@ -## UnpublishBookmark action - -Unpublish a bookmark. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. - -### Example - -Unpublish the bookmark with `id` "bookmark1" that was created earlier on in the script. - -```json -{ - "label" : "Unpublish bookmark 1", - "action": "unpublishbookmark", - "disabled" : false, - "settings" : { - "id" : "bookmark1" - } -} -``` - -Unpublish the bookmark with the `title` "bookmark of testuser", where "testuser" is the username of the simulated user. - -```json -{ - "label" : "Unpublish bookmark 2", - "action": "unpublishbookmark", - "disabled" : false, - "settings" : { - "title" : "bookmark of {{.UserName}}" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/unpublishsheet.md b/docs/wiki/actions/commonActions/unpublishsheet.md deleted file mode 100755 index 57910954..00000000 --- a/docs/wiki/actions/commonActions/unpublishsheet.md +++ /dev/null @@ -1,20 +0,0 @@ -## UnpublishSheet action - -Unpublish sheets in the current app. - -* `mode`: - * `allsheets`: Unpublish all sheets in the app. - * `sheetids`: Only unpublish the sheets specified by the `sheetIds` array. -* `sheetIds`: (optional) Array of sheet IDs for the `sheetids` mode. - -### Example -```json -{ - "label": "UnpublishSheets", - "action": "unpublishsheet", - "settings": { - "mode": "allsheets" - } -} -``` - diff --git a/docs/wiki/actions/commonActions/unsubscribeobjects.md b/docs/wiki/actions/commonActions/unsubscribeobjects.md deleted file mode 100755 index 82a094e7..00000000 --- a/docs/wiki/actions/commonActions/unsubscribeobjects.md +++ /dev/null @@ -1,34 +0,0 @@ -## Unsubscribeobjects action - -Unsubscribe to any currently subscribed object. - -* `ids`: List of object IDs to unsubscribe from. -* `clear`: Remove any previously subscribed objects from the subscription list. - -### Example - -Unsubscribe from a single object (or a list of objects). - -```json -{ - "action" : "unsubscribeobjects", - "label" : "unsubscribe from object maVjt and its children", - "disabled": false, - "settings" : { - "ids" : ["maVjt"] - } -} -``` - -Unsubscribe from all currently subscribed objects. - -```json -{ - "action" : "unsubscribeobjects", - "label" : "unsubscribe from all objects", - "disabled": false, - "settings" : { - "clear": true - } -} -``` diff --git a/docs/wiki/actions/qseowActions/changestream.md b/docs/wiki/actions/qseowActions/changestream.md deleted file mode 100755 index cb28e66c..00000000 --- a/docs/wiki/actions/qseowActions/changestream.md +++ /dev/null @@ -1,36 +0,0 @@ -## ChangeStream action - -Change to specified stream. This makes the apps in the specified stream selectable by actions such as `openapp`. -* `mode`: Decides what kind of value the `stream` field contains. Defaults to `name`. - * `name`: `stream` is the name of the stream. - * `id`: `stream` is the ID if the stream. -* `stream`: - -### Example - -Make apps in stream `Everyone` selectable by subsequent actions. - -```json -{ - "label": "ChangeStream Everyone", - "action": "changestream", - "settings": { - "mode": "name", - "stream" : "Everyone" - } -} -``` - -Make apps in stream with id `ABSCDFSDFSDFO1231234` selectable subsequent actions. - -```json -{ - "label": "ChangeStream Test1", - "action": "changestream", - "settings": { - "mode": "id", - "stream" : "ABSCDFSDFSDFO1231234" - } -} -``` - diff --git a/docs/wiki/actions/qseowActions/deleteodag.md b/docs/wiki/actions/qseowActions/deleteodag.md deleted file mode 100755 index 0ca96496..00000000 --- a/docs/wiki/actions/qseowActions/deleteodag.md +++ /dev/null @@ -1,17 +0,0 @@ -## DeleteOdag action - -Delete all user-generated on-demand apps for the current user and the specified On-Demand App Generation (ODAG) link. - -* `linkname`: Name of the ODAG link from which to delete generated apps. The name is displayed in the ODAG navigation bar at the bottom of the *selection app*. - -### Example - -```json -{ - "action": "DeleteOdag", - "settings": { - "linkname": "Drill to Template App" - } -} -``` - diff --git a/docs/wiki/actions/qseowActions/generateodag.md b/docs/wiki/actions/qseowActions/generateodag.md deleted file mode 100755 index 5cf6e5c6..00000000 --- a/docs/wiki/actions/qseowActions/generateodag.md +++ /dev/null @@ -1,17 +0,0 @@ -## GenerateOdag action - -Generate an on-demand app from an existing On-Demand App Generation (ODAG) link. - -* `linkname`: Name of the ODAG link from which to generate an app. The name is displayed in the ODAG navigation bar at the bottom of the *selection app*. - -### Example - -```json -{ - "action": "GenerateOdag", - "settings": { - "linkname": "Drill to Template App" - } -} -``` - diff --git a/docs/wiki/actions/qseowActions/openhub.md b/docs/wiki/actions/qseowActions/openhub.md deleted file mode 100755 index bcdf701e..00000000 --- a/docs/wiki/actions/qseowActions/openhub.md +++ /dev/null @@ -1,14 +0,0 @@ -## OpenHub action - -Open the hub in a QSEoW environment. This also makes the apps included in the response for the users `myspace` available for use by subsequent actions. The action `changestream` can be used to only select from apps in a specific stream. - - -### Example - -```json -{ - "action": "OpenHub", - "label": "Open the hub" -} -``` - diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 6a98c073..74f2c821 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -14,6 +14,10 @@ import ( var unitTestMode = false +const ( + ActionsFolder = "actions" +) + type ( DocNodeStruct struct { doc fmt.Stringer @@ -106,6 +110,7 @@ const ( ExitCodeFailedHandleParams ExitCodeFailedCreateFolder ExitCodeFailedDeleteFolder + ExitCodeFailedDeleteFile ) func GenerateMarkdown(docs *CompiledDocs) { @@ -122,9 +127,12 @@ func GenerateMarkdown(docs *CompiledDocs) { fmt.Printf("Generated markdown documentation to output<%s>\n", output) } if wiki != "" { - if err := os.RemoveAll(wiki); err != nil { + if err := os.RemoveAll(filepath.Join(wiki, ActionsFolder)); err != nil { common.Exit(err, ExitCodeFailedDeleteFolder) } + if err := os.Remove(fmt.Sprintf("%s/_Sidebar.md", wiki)); err != nil && !errors.Is(err, os.ErrNotExist) { + common.Exit(err, ExitCodeFailedDeleteFile) + } generateWikiFromCompiled(docs) } } @@ -140,38 +148,102 @@ func generateFullMarkdownFromCompiled(compiledDocs *CompiledDocs) []byte { } func generateWikiFromCompiled(compiledDocs *CompiledDocs) { - if err := createFolder(wiki); err != nil { + // TODO warning (error?)for ungrouped + + if err := createFolder(filepath.Join(wiki, ActionsFolder), true); err != nil { common.Exit(err, ExitCodeFailedCreateFolder) } + if verbose { + fmt.Println("creating groups sidebar...") + } + groupsSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", wiki)) + defer func() { + if err := groupsSidebar.Close(); err != nil { + os.Stderr.Write([]byte(err.Error())) + } + }() + if err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if verbose { + fmt.Println("creating groups.md...") + } + grouplińks, err := os.Create(fmt.Sprintf("%s/groups.md", filepath.Join(wiki, ActionsFolder))) + defer func() { + if err := grouplińks.Close(); err != nil { + os.Stderr.Write([]byte(err.Error())) + } + }() + if err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + groupsSidebar.WriteString("[Home](home)\n\n- [Groups](groups)\n\n") for _, group := range compiledDocs.Groups { - fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) - if err := createFolder(filepath.Join(wiki, group.Name)); err != nil { + if verbose { + fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) + } + if err := createFolder(filepath.Join(wiki, ActionsFolder, group.Name), false); err != nil { common.Exit(err, ExitCodeFailedCreateFolder) } - for _, action := range group.Actions { - actionEntry := createActionEntry(compiledDocs, common.Actions(), action) - if actionEntry == nil { - continue - } - file := filepath.Join(wiki, group.Name, action) - file = fmt.Sprintf("%s.md", file) - if verbose { - fmt.Printf("creating file<%s>...\n", file) - } - if err := os.WriteFile(file, []byte(actionEntry.String()), os.ModePerm); err != nil { - common.Exit(err, ExitCodeFailedWriteResult) - } + + groupslink := fmt.Sprintf("[%s](%s)\n\n", group.Title, group.Name) + groupsSidebar.WriteString(fmt.Sprintf(" - %s", groupslink)) + grouplińks.WriteString(groupslink) + generateWikiGroup(compiledDocs, group) + } +} + +func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { + file := fmt.Sprintf("%s/%s.md", filepath.Join(wiki, ActionsFolder, group.Name), group.Name) + if verbose { + fmt.Printf("creating file<%s>...\n", file) + } + if err := os.WriteFile(file, []byte(DocEntry(group.DocEntry).String()), os.ModePerm); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + actionsSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, ActionsFolder, group.Name))) + defer func() { + if err := actionsSidebar.Close(); err != nil { + os.Stderr.Write([]byte(err.Error())) + } + }() + if err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + + if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](home)\n\n- [Groups](groups)\n\n - [%s](%s)\n\n", group.Title, group.Name)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + + for _, action := range group.Actions { + actionEntry := createActionEntry(compiledDocs, common.Actions(), action) + if actionEntry == nil { + continue + } + file = fmt.Sprintf("%s/%s.md", filepath.Join(wiki, ActionsFolder, group.Name), action) + if verbose { + fmt.Printf("creating file<%s>...\n", file) + } + if err := os.WriteFile(file, []byte(actionEntry.String()), os.ModePerm); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := actionsSidebar.WriteString(fmt.Sprintf(" - [%s](%s)\n\n", action, action)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) } } - // TODO warning for ungrouped } -func createFolder(path string) error { +func createFolder(path string, footer bool) error { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { if err := os.MkdirAll(path, os.ModePerm); err != nil { return err } } + if footer { + if err := os.WriteFile(fmt.Sprintf("%s/_Footer.md", path), []byte("The file has been generated, do not edit manually\n"), os.ModePerm); err != nil { + return err + } + } return nil } From de8142a7bf219b7b4d71b740c1772f6a53cbd13f Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 17 Feb 2025 11:55:40 +0100 Subject: [PATCH 04/46] fix some linter issues --- generatedocs/pkg/genmd/generate.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 74f2c821..8d88ae9b 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -177,7 +177,10 @@ func generateWikiFromCompiled(compiledDocs *CompiledDocs) { if err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - groupsSidebar.WriteString("[Home](home)\n\n- [Groups](groups)\n\n") + if _, err := groupsSidebar.WriteString("[Home](home)\n\n- [Action groups](groups)\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + for _, group := range compiledDocs.Groups { if verbose { fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) @@ -187,8 +190,12 @@ func generateWikiFromCompiled(compiledDocs *CompiledDocs) { } groupslink := fmt.Sprintf("[%s](%s)\n\n", group.Title, group.Name) - groupsSidebar.WriteString(fmt.Sprintf(" - %s", groupslink)) - grouplińks.WriteString(groupslink) + if _, err := groupsSidebar.WriteString(fmt.Sprintf(" - %s", groupslink)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := grouplińks.WriteString(groupslink); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } generateWikiGroup(compiledDocs, group) } } @@ -211,7 +218,7 @@ func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](home)\n\n- [Groups](groups)\n\n - [%s](%s)\n\n", group.Title, group.Name)); err != nil { + if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](home)\n\n- [Action Groups](groups)\n\n - [%s](%s)\n\n", group.Title, group.Name)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } From 41aafcff2cff9f7fd1ec3b17207bb022d816f28e Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 17 Feb 2025 15:25:38 +0100 Subject: [PATCH 05/46] can generate config except schedulers --- generatedocs/pkg/genmd/generate.go | 154 ++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 25 deletions(-) diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 8d88ae9b..a66264ea 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -15,7 +15,8 @@ import ( var unitTestMode = false const ( - ActionsFolder = "actions" + GeneratedFolder = "generated" + SessionVariableName = "sessionvariables" ) type ( @@ -52,6 +53,13 @@ type ( Groups []common.GroupsEntry Extra map[string]common.DocEntry } + + ConfigSection struct { + Data string + FilePath string + LinkTitle string + LinkName string + } ) func NewDocNode(doc fmt.Stringer) DocNode { @@ -127,7 +135,7 @@ func GenerateMarkdown(docs *CompiledDocs) { fmt.Printf("Generated markdown documentation to output<%s>\n", output) } if wiki != "" { - if err := os.RemoveAll(filepath.Join(wiki, ActionsFolder)); err != nil { + if err := os.RemoveAll(filepath.Join(wiki, GeneratedFolder)); err != nil { common.Exit(err, ExitCodeFailedDeleteFolder) } if err := os.Remove(fmt.Sprintf("%s/_Sidebar.md", wiki)); err != nil && !errors.Is(err, os.ErrNotExist) { @@ -150,65 +158,161 @@ func generateFullMarkdownFromCompiled(compiledDocs *CompiledDocs) []byte { func generateWikiFromCompiled(compiledDocs *CompiledDocs) { // TODO warning (error?)for ungrouped - if err := createFolder(filepath.Join(wiki, ActionsFolder), true); err != nil { + if err := createFolder(filepath.Join(wiki, GeneratedFolder), true); err != nil { common.Exit(err, ExitCodeFailedCreateFolder) } if verbose { fmt.Println("creating groups sidebar...") } - groupsSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", wiki)) + + generateWikiConfigSections(compiledDocs) +} + +func generateWikiConfigSections(compiledDocs *CompiledDocs) { + configfile, err := os.Create(fmt.Sprintf("%s/config.md", filepath.Join(wiki, GeneratedFolder))) defer func() { - if err := groupsSidebar.Close(); err != nil { - os.Stderr.Write([]byte(err.Error())) + if err := configfile.Close(); err != nil { + _, _ = os.Stderr.WriteString(err.Error()) } }() if err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if verbose { - fmt.Println("creating groups.md...") - } - grouplińks, err := os.Create(fmt.Sprintf("%s/groups.md", filepath.Join(wiki, ActionsFolder))) + + configSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, GeneratedFolder))) defer func() { - if err := grouplińks.Close(); err != nil { + if err := configSidebar.Close(); err != nil { os.Stderr.Write([]byte(err.Error())) } }() if err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := groupsSidebar.WriteString("[Home](home)\n\n- [Action groups](groups)\n\n"); err != nil { + if _, err := configSidebar.WriteString("[Home](home)\n\n- [Config](config)\n\n"); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - for _, group := range compiledDocs.Groups { - if verbose { - fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) + configFields, err := common.Fields() + if err != nil { + common.Exit(err, ExitCodeFailedHandleFields) + } + configFields[SessionVariableName] = struct{}{} // TODO adding here for now, but figure out best placement in link structure + for _, name := range sortedKeys(configFields) { + var section ConfigSection + switch name { + case SessionVariableName: + // TODO remove extra expander in session variable section + docEntry, ok := compiledDocs.Extra[SessionVariableName] + if !ok { + common.Exit(fmt.Errorf("\"Extra\" section<%s> not found", SessionVariableName), ExitCodeFailedReadTemplate) + } + section = ConfigSection{ + Data: DocEntry(docEntry).String(), + FilePath: fmt.Sprintf("%s/%s/%s.md", wiki, GeneratedFolder, SessionVariableName), + LinkTitle: SessionVariableName, + LinkName: SessionVariableName, + } + case "scenario": + // action groups + if verbose { + fmt.Println("creating groups.md...") + } + if _, err := configSidebar.WriteString(" - [Action groups](groups)\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + groups := generateWikiGroups(compiledDocs) + grouplinks, err := os.Create(fmt.Sprintf("%s/groups.md", filepath.Join(wiki, GeneratedFolder))) + defer func() { + if err := grouplinks.Close(); err != nil { + os.Stderr.Write([]byte(err.Error())) + } + }() + if err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + + for name, title := range groups { + groupslink := fmt.Sprintf("[%s](%s)\n\n", title, name) + if _, err := configSidebar.WriteString(fmt.Sprintf(" - %s", groupslink)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := grouplinks.WriteString(groupslink); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + } + + section = ConfigSection{ + LinkTitle: "Scenario actions", + LinkName: "groups", + } + case "scheduler": + // addSchedulers(newNode, compiledDocs) + continue // TODO needs special handling + default: + fieldEntry := &DocEntryWithParams{ + DocEntry: DocEntry(compiledDocs.Config[name]), + Params: MarkdownParams(configFields[name], compiledDocs.Params), + } + section = ConfigSection{ + Data: fieldEntry.String(), + FilePath: fmt.Sprintf("%s/%s.md", filepath.Join(wiki, GeneratedFolder), name), + LinkTitle: name, + LinkName: name, + } } - if err := createFolder(filepath.Join(wiki, ActionsFolder, group.Name), false); err != nil { - common.Exit(err, ExitCodeFailedCreateFolder) + + if section.FilePath != "" { + if verbose { + fmt.Printf("creating file<%s>...\n", section.FilePath) + } + sectionfile, err := os.Create(section.FilePath) + defer func() { + if err := sectionfile.Close(); err != nil { + _, _ = os.Stderr.WriteString(fmt.Sprintf("%v", err)) + } + }() + if err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := sectionfile.WriteString(section.Data); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } } - groupslink := fmt.Sprintf("[%s](%s)\n\n", group.Title, group.Name) - if _, err := groupsSidebar.WriteString(fmt.Sprintf(" - %s", groupslink)); err != nil { + linkString := fmt.Sprintf("[%s](%s)\n\n", section.LinkTitle, section.LinkName) + if _, err := configfile.WriteString(linkString); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := grouplińks.WriteString(groupslink); err != nil { + if _, err := configSidebar.WriteString(fmt.Sprintf(" - %s", linkString)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } + } +} + +func generateWikiGroups(compiledDocs *CompiledDocs) map[string]string { + groups := make(map[string]string) + for _, group := range compiledDocs.Groups { + if verbose { + fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) + } + if err := createFolder(filepath.Join(wiki, GeneratedFolder, group.Name), false); err != nil { + common.Exit(err, ExitCodeFailedCreateFolder) + } + groups[group.Name] = group.Title generateWikiGroup(compiledDocs, group) } + return groups } func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { - file := fmt.Sprintf("%s/%s.md", filepath.Join(wiki, ActionsFolder, group.Name), group.Name) + file := fmt.Sprintf("%s/%s.md", filepath.Join(wiki, GeneratedFolder, group.Name), group.Name) if verbose { fmt.Printf("creating file<%s>...\n", file) } if err := os.WriteFile(file, []byte(DocEntry(group.DocEntry).String()), os.ModePerm); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - actionsSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, ActionsFolder, group.Name))) + actionsSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, GeneratedFolder, group.Name))) defer func() { if err := actionsSidebar.Close(); err != nil { os.Stderr.Write([]byte(err.Error())) @@ -218,7 +322,7 @@ func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](home)\n\n- [Action Groups](groups)\n\n - [%s](%s)\n\n", group.Title, group.Name)); err != nil { + if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](home)\n\n- [Config](config)\n\n - [Action Groups](groups)\n\n - [%s](%s)\n\n", group.Title, group.Name)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } @@ -227,14 +331,14 @@ func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { if actionEntry == nil { continue } - file = fmt.Sprintf("%s/%s.md", filepath.Join(wiki, ActionsFolder, group.Name), action) + file = fmt.Sprintf("%s/%s.md", filepath.Join(wiki, GeneratedFolder, group.Name), action) if verbose { fmt.Printf("creating file<%s>...\n", file) } if err := os.WriteFile(file, []byte(actionEntry.String()), os.ModePerm); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := actionsSidebar.WriteString(fmt.Sprintf(" - [%s](%s)\n\n", action, action)); err != nil { + if _, err := actionsSidebar.WriteString(fmt.Sprintf(" - [%s](%s)\n\n", action, action)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } } From c9c632b5ca17e8509ceecc2a6a9e03cf5e075785 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 17 Feb 2025 17:12:09 +0100 Subject: [PATCH 06/46] improved config section --- docs/settingup.md | 123 +++++++++++++++++++++- generatedocs/data/config/main/examples.md | 123 +++++++++++++++++++++- generatedocs/generated/documentation.go | 2 +- generatedocs/pkg/genmd/generate.go | 95 +++++++++++++---- 4 files changed, 322 insertions(+), 21 deletions(-) diff --git a/docs/settingup.md b/docs/settingup.md index eb8c146a..1bde344e 100644 --- a/docs/settingup.md +++ b/docs/settingup.md @@ -5,7 +5,128 @@ A load scenario is defined in a JSON file with a number of sections. ## Example -* [Load scenario example](./examples/configuration_example.json) +```json +{ + "settings": { + "timeout": 300, + "logs": { + "filename": "scenarioresult.tsv" + }, + "outputs": { + "dir": "" + } + }, + "loginSettings": { + "type": "prefix", + "settings": { + "prefix": "testuser" + } + }, + "connectionSettings": { + "mode": "ws", + "server": "localhost", + "virtualproxy": "header", + "security": true, + "allowuntrusted": true, + "headers": { + "Qlik-User-Header": "{{.UserName}}" + } + }, + "scheduler": { + "type": "simple", + "iterationtimebuffer": { + "mode": "onerror", + "duration": "10s" + }, + "instance": 1, + "reconnectsettings": { + "reconnect": false, + "backoff": null + }, + "settings": { + "executionTime": -1, + "iterations": 10, + "rampupDelay": 7, + "concurrentUsers": 10, + "reuseUsers": false, + "onlyinstanceseed": false + } + }, + "scenario": [ + { + "action": "openhub", + "label": "open hub", + "disabled": false, + "settings": {} + }, + { + "action": "thinktime", + "label": "think for 10-15s", + "disabled": false, + "settings": { + "type": "uniform", + "mean": 15, + "dev": 5 + } + }, + { + "action": "openapp", + "label": "open app", + "disabled": false, + "settings": { + "appmode": "name", + "app": "myapp", + "filename": "", + "unique": false + } + }, + { + "action": "thinktime", + "label": "think for 10-15s", + "disabled": false, + "settings": { + "type": "uniform", + "mean": 15, + "dev": 5 + } + }, + { + "action": "changesheet", + "label": "change sheet to analysis sheet", + "disabled": false, + "settings": { + "id": "QWERTY" + } + }, + { + "action": "thinktime", + "label": "think for 10-15s", + "disabled": false, + "settings": { + "type": "uniform", + "mean": 15, + "dev": 5 + } + }, + { + "action": "select", + "label": "select 1-10 values in object uvxyz", + "disabled": false, + "settings": { + "id": "uvxyz", + "type": "randomfromenabled", + "accept": false, + "wrap": false, + "min": 1, + "max": 10, + "dim": 0, + "values": null + } + } + ] +} +``` +
connectionSettings diff --git a/generatedocs/data/config/main/examples.md b/generatedocs/data/config/main/examples.md index d5735960..b8044d3d 100644 --- a/generatedocs/data/config/main/examples.md +++ b/generatedocs/data/config/main/examples.md @@ -1,4 +1,125 @@ ## Example -* [Load scenario example](./examples/configuration_example.json) +```json +{ + "settings": { + "timeout": 300, + "logs": { + "filename": "scenarioresult.tsv" + }, + "outputs": { + "dir": "" + } + }, + "loginSettings": { + "type": "prefix", + "settings": { + "prefix": "testuser" + } + }, + "connectionSettings": { + "mode": "ws", + "server": "localhost", + "virtualproxy": "header", + "security": true, + "allowuntrusted": true, + "headers": { + "Qlik-User-Header": "{{.UserName}}" + } + }, + "scheduler": { + "type": "simple", + "iterationtimebuffer": { + "mode": "onerror", + "duration": "10s" + }, + "instance": 1, + "reconnectsettings": { + "reconnect": false, + "backoff": null + }, + "settings": { + "executionTime": -1, + "iterations": 10, + "rampupDelay": 7, + "concurrentUsers": 10, + "reuseUsers": false, + "onlyinstanceseed": false + } + }, + "scenario": [ + { + "action": "openhub", + "label": "open hub", + "disabled": false, + "settings": {} + }, + { + "action": "thinktime", + "label": "think for 10-15s", + "disabled": false, + "settings": { + "type": "uniform", + "mean": 15, + "dev": 5 + } + }, + { + "action": "openapp", + "label": "open app", + "disabled": false, + "settings": { + "appmode": "name", + "app": "myapp", + "filename": "", + "unique": false + } + }, + { + "action": "thinktime", + "label": "think for 10-15s", + "disabled": false, + "settings": { + "type": "uniform", + "mean": 15, + "dev": 5 + } + }, + { + "action": "changesheet", + "label": "change sheet to analysis sheet", + "disabled": false, + "settings": { + "id": "QWERTY" + } + }, + { + "action": "thinktime", + "label": "think for 10-15s", + "disabled": false, + "settings": { + "type": "uniform", + "mean": 15, + "dev": 5 + } + }, + { + "action": "select", + "label": "select 1-10 values in object uvxyz", + "disabled": false, + "settings": { + "id": "uvxyz", + "type": "randomfromenabled", + "accept": false, + "wrap": false, + "min": 1, + "max": 10, + "dim": 0, + "values": null + } + } + ] +} +``` + diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index 8174f039..67732187 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -384,7 +384,7 @@ var ( }, "main": { Description: "# Setting up load scenarios\n\nA load scenario is defined in a JSON file with a number of sections.\n", - Examples: "\n## Example\n\n* [Load scenario example](./examples/configuration_example.json)\n", + Examples: "\n## Example\n\n```json\n{\n \"settings\": {\n \"timeout\": 300,\n \"logs\": {\n \"filename\": \"scenarioresult.tsv\"\n },\n \"outputs\": {\n \"dir\": \"\"\n }\n },\n \"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"prefix\": \"testuser\"\n }\n },\n \"connectionSettings\": {\n \"mode\": \"ws\",\n \"server\": \"localhost\",\n \"virtualproxy\": \"header\",\n \"security\": true,\n \"allowuntrusted\": true,\n \"headers\": {\n \"Qlik-User-Header\": \"{{.UserName}}\"\n }\n },\n \"scheduler\": {\n \"type\": \"simple\",\n \"iterationtimebuffer\": {\n \"mode\": \"onerror\",\n \"duration\": \"10s\"\n },\n \"instance\": 1,\n \"reconnectsettings\": {\n \"reconnect\": false,\n \"backoff\": null\n },\n \"settings\": {\n \"executionTime\": -1,\n \"iterations\": 10,\n \"rampupDelay\": 7,\n \"concurrentUsers\": 10,\n \"reuseUsers\": false,\n \"onlyinstanceseed\": false\n }\n },\n \"scenario\": [\n {\n \"action\": \"openhub\",\n \"label\": \"open hub\",\n \"disabled\": false,\n \"settings\": {}\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"openapp\",\n \"label\": \"open app\",\n \"disabled\": false,\n \"settings\": {\n \"appmode\": \"name\",\n \"app\": \"myapp\",\n \"filename\": \"\",\n \"unique\": false\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"changesheet\",\n \"label\": \"change sheet to analysis sheet\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"QWERTY\"\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"select\",\n \"label\": \"select 1-10 values in object uvxyz\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"uvxyz\",\n \"type\": \"randomfromenabled\",\n \"accept\": false,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 10,\n \"dim\": 0,\n \"values\": null\n }\n }\n ]\n}\n```\n\n", }, "scenario": { Description: "## Scenario section\n\nThis section of the JSON file contains the actions that are performed in the load scenario.\n\n### Structure of an action entry\n\nAll actions follow the same basic structure: \n", diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index a66264ea..ee413008 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -60,6 +60,11 @@ type ( LinkTitle string LinkName string } + + SortedDocEntryWithParams struct { + *DocEntryWithParams + Name string + } ) func NewDocNode(doc fmt.Stringer) DocNode { @@ -179,6 +184,14 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } + configEntry := DocEntry(compiledDocs.Config["main"]) + if _, err := configfile.WriteString(configEntry.Description); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := configfile.WriteString("## Sections\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + configSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, GeneratedFolder))) defer func() { if err := configSidebar.Close(); err != nil { @@ -213,13 +226,15 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { LinkName: SessionVariableName, } case "scenario": - // action groups - if verbose { - fmt.Println("creating groups.md...") + if _, err := configSidebar.WriteString(" - [scenario](groups)\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := configSidebar.WriteString(" - [Action groups](groups)\n\n"); err != nil { + if _, err := configfile.WriteString("scenario\n\n"); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } + if verbose { + fmt.Println("creating groups.md...") + } groups := generateWikiGroups(compiledDocs) grouplinks, err := os.Create(fmt.Sprintf("%s/groups.md", filepath.Join(wiki, GeneratedFolder))) defer func() { @@ -239,15 +254,14 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { if _, err := grouplinks.WriteString(groupslink); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } + if _, err := configfile.WriteString(fmt.Sprintf("- %s", groupslink)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } } - - section = ConfigSection{ - LinkTitle: "Scenario actions", - LinkName: "groups", - } + continue case "scheduler": - // addSchedulers(newNode, compiledDocs) - continue // TODO needs special handling + generateWikiSchedulers(compiledDocs, configfile, configSidebar) + continue default: fieldEntry := &DocEntryWithParams{ DocEntry: DocEntry(compiledDocs.Config[name]), @@ -287,6 +301,10 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } } + + if _, err := configfile.WriteString(configEntry.Examples); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } } func generateWikiGroups(compiledDocs *CompiledDocs) map[string]string { @@ -344,6 +362,33 @@ func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { } } +func generateWikiSchedulers(compiledDocs *CompiledDocs, configFile, sidebar *os.File) { + if _, err := sidebar.WriteString(" - Schedulers\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := configFile.WriteString("scheduler\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + for _, entry := range createSchedulerEntrys(compiledDocs) { + if verbose { + fmt.Printf("Generating wiki scheduler entry for %s...\n", entry.Name) + } + file := fmt.Sprintf("%s/%s.md", filepath.Join(wiki, GeneratedFolder), entry.Name) + if verbose { + fmt.Printf("creating file<%s>...\n", file) + } + if err := os.WriteFile(file, []byte(entry.DocEntryWithParams.String()), os.ModePerm); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := sidebar.WriteString(fmt.Sprintf(" - [%s](%s)\n\n", entry.Name, entry.Name)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := configFile.WriteString(fmt.Sprintf("- [%s](%s)\n\n", entry.Name, entry.Name)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + } +} + func createFolder(path string, footer bool) error { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { if err := os.MkdirAll(path, os.ModePerm); err != nil { @@ -411,12 +456,24 @@ func addGroups(node DocNode, compiledDocs *CompiledDocs) { } func addSchedulers(node DocNode, compiledDocs *CompiledDocs) { + entries := createSchedulerEntrys(compiledDocs) + for _, entry := range entries { + newNode := NewFoldedDocNode(entry.Name, entry.DocEntryWithParams) + node.AddChild(newNode) + } +} + +func createSchedulerEntrys(compiledDocs *CompiledDocs) []SortedDocEntryWithParams { schedulerSettings := common.Schedulers() + + // sort schedulers := make([]string, 0, len(schedulerSettings)) for sched := range schedulerSettings { schedulers = append(schedulers, sched) } sort.Strings(schedulers) + + schedulerEntries := make([]SortedDocEntryWithParams, 0, len(schedulers)) for _, sched := range schedulers { compiledEntry, ok := compiledDocs.Schedulers[sched] if !ok { @@ -427,13 +484,15 @@ func addSchedulers(node DocNode, compiledDocs *CompiledDocs) { os.Stderr.WriteString(fmt.Sprintf("%s gives nil schedparams, skipping...\n", sched)) continue } - schedEntry := &DocEntryWithParams{ - DocEntry: DocEntry(compiledEntry), - Params: MarkdownParams(schedParams, compiledDocs.Params), - } - newNode := NewFoldedDocNode(sched, schedEntry) - node.AddChild(newNode) - } + schedulerEntries = append(schedulerEntries, SortedDocEntryWithParams{ + Name: sched, + DocEntryWithParams: &DocEntryWithParams{ + DocEntry: DocEntry(compiledEntry), + Params: MarkdownParams(schedParams, compiledDocs.Params), + }, + }) + } + return schedulerEntries } func addExtra(node DocNode, compiledDocs *CompiledDocs, name string) { From 432056401334a70ae48242ae05a5f02280c21b81 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 17 Feb 2025 17:27:19 +0100 Subject: [PATCH 07/46] expander for config example --- docs/settingup.md | 5 ++++- generatedocs/data/config/main/examples.md | 4 ++++ generatedocs/generated/documentation.go | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/settingup.md b/docs/settingup.md index 1bde344e..85548d4e 100644 --- a/docs/settingup.md +++ b/docs/settingup.md @@ -5,6 +5,9 @@ A load scenario is defined in a JSON file with a number of sections. ## Example +
+ + ```json { "settings": { @@ -127,7 +130,7 @@ A load scenario is defined in a JSON file with a number of sections. } ``` - +
connectionSettings diff --git a/generatedocs/data/config/main/examples.md b/generatedocs/data/config/main/examples.md index b8044d3d..822d8130 100644 --- a/generatedocs/data/config/main/examples.md +++ b/generatedocs/data/config/main/examples.md @@ -1,6 +1,9 @@ ## Example +
+ + ```json { "settings": { @@ -123,3 +126,4 @@ } ``` +
\ No newline at end of file diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index 67732187..e727ef8f 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -384,7 +384,7 @@ var ( }, "main": { Description: "# Setting up load scenarios\n\nA load scenario is defined in a JSON file with a number of sections.\n", - Examples: "\n## Example\n\n```json\n{\n \"settings\": {\n \"timeout\": 300,\n \"logs\": {\n \"filename\": \"scenarioresult.tsv\"\n },\n \"outputs\": {\n \"dir\": \"\"\n }\n },\n \"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"prefix\": \"testuser\"\n }\n },\n \"connectionSettings\": {\n \"mode\": \"ws\",\n \"server\": \"localhost\",\n \"virtualproxy\": \"header\",\n \"security\": true,\n \"allowuntrusted\": true,\n \"headers\": {\n \"Qlik-User-Header\": \"{{.UserName}}\"\n }\n },\n \"scheduler\": {\n \"type\": \"simple\",\n \"iterationtimebuffer\": {\n \"mode\": \"onerror\",\n \"duration\": \"10s\"\n },\n \"instance\": 1,\n \"reconnectsettings\": {\n \"reconnect\": false,\n \"backoff\": null\n },\n \"settings\": {\n \"executionTime\": -1,\n \"iterations\": 10,\n \"rampupDelay\": 7,\n \"concurrentUsers\": 10,\n \"reuseUsers\": false,\n \"onlyinstanceseed\": false\n }\n },\n \"scenario\": [\n {\n \"action\": \"openhub\",\n \"label\": \"open hub\",\n \"disabled\": false,\n \"settings\": {}\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"openapp\",\n \"label\": \"open app\",\n \"disabled\": false,\n \"settings\": {\n \"appmode\": \"name\",\n \"app\": \"myapp\",\n \"filename\": \"\",\n \"unique\": false\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"changesheet\",\n \"label\": \"change sheet to analysis sheet\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"QWERTY\"\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"select\",\n \"label\": \"select 1-10 values in object uvxyz\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"uvxyz\",\n \"type\": \"randomfromenabled\",\n \"accept\": false,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 10,\n \"dim\": 0,\n \"values\": null\n }\n }\n ]\n}\n```\n\n", + Examples: "\n## Example\n\n
\n\n\n```json\n{\n \"settings\": {\n \"timeout\": 300,\n \"logs\": {\n \"filename\": \"scenarioresult.tsv\"\n },\n \"outputs\": {\n \"dir\": \"\"\n }\n },\n \"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"prefix\": \"testuser\"\n }\n },\n \"connectionSettings\": {\n \"mode\": \"ws\",\n \"server\": \"localhost\",\n \"virtualproxy\": \"header\",\n \"security\": true,\n \"allowuntrusted\": true,\n \"headers\": {\n \"Qlik-User-Header\": \"{{.UserName}}\"\n }\n },\n \"scheduler\": {\n \"type\": \"simple\",\n \"iterationtimebuffer\": {\n \"mode\": \"onerror\",\n \"duration\": \"10s\"\n },\n \"instance\": 1,\n \"reconnectsettings\": {\n \"reconnect\": false,\n \"backoff\": null\n },\n \"settings\": {\n \"executionTime\": -1,\n \"iterations\": 10,\n \"rampupDelay\": 7,\n \"concurrentUsers\": 10,\n \"reuseUsers\": false,\n \"onlyinstanceseed\": false\n }\n },\n \"scenario\": [\n {\n \"action\": \"openhub\",\n \"label\": \"open hub\",\n \"disabled\": false,\n \"settings\": {}\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"openapp\",\n \"label\": \"open app\",\n \"disabled\": false,\n \"settings\": {\n \"appmode\": \"name\",\n \"app\": \"myapp\",\n \"filename\": \"\",\n \"unique\": false\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"changesheet\",\n \"label\": \"change sheet to analysis sheet\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"QWERTY\"\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"select\",\n \"label\": \"select 1-10 values in object uvxyz\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"uvxyz\",\n \"type\": \"randomfromenabled\",\n \"accept\": false,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 10,\n \"dim\": 0,\n \"values\": null\n }\n }\n ]\n}\n```\n\n
", }, "scenario": { Description: "## Scenario section\n\nThis section of the JSON file contains the actions that are performed in the load scenario.\n\n### Structure of an action entry\n\nAll actions follow the same basic structure: \n", From 0943da3aa283e0e28dfa7aa1301b54fd57c7282b Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 17 Feb 2025 17:29:10 +0100 Subject: [PATCH 08/46] no longer delete sidebar --- generatedocs/pkg/genmd/generate.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index ee413008..99bfb7c1 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -143,9 +143,6 @@ func GenerateMarkdown(docs *CompiledDocs) { if err := os.RemoveAll(filepath.Join(wiki, GeneratedFolder)); err != nil { common.Exit(err, ExitCodeFailedDeleteFolder) } - if err := os.Remove(fmt.Sprintf("%s/_Sidebar.md", wiki)); err != nil && !errors.Is(err, os.ErrNotExist) { - common.Exit(err, ExitCodeFailedDeleteFile) - } generateWikiFromCompiled(docs) } } From 31bd9e768ddd1d811c1f0b8d7ab2a01dba33df98 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Tue, 18 Feb 2025 14:52:50 +0100 Subject: [PATCH 09/46] make sure wiki groups order same order + remove expander title --- docs/settingup.md | 4 +--- generatedocs/data/config/main/examples.md | 4 +--- generatedocs/generated/documentation.go | 2 +- generatedocs/pkg/genmd/generate.go | 14 +++++++++++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/settingup.md b/docs/settingup.md index 85548d4e..4f1608cb 100644 --- a/docs/settingup.md +++ b/docs/settingup.md @@ -3,10 +3,8 @@ A load scenario is defined in a JSON file with a number of sections. -## Example -
- +Example ```json { diff --git a/generatedocs/data/config/main/examples.md b/generatedocs/data/config/main/examples.md index 822d8130..7b352bf6 100644 --- a/generatedocs/data/config/main/examples.md +++ b/generatedocs/data/config/main/examples.md @@ -1,8 +1,6 @@ -## Example -
- +Example ```json { diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index e727ef8f..084302af 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -384,7 +384,7 @@ var ( }, "main": { Description: "# Setting up load scenarios\n\nA load scenario is defined in a JSON file with a number of sections.\n", - Examples: "\n## Example\n\n
\n\n\n```json\n{\n \"settings\": {\n \"timeout\": 300,\n \"logs\": {\n \"filename\": \"scenarioresult.tsv\"\n },\n \"outputs\": {\n \"dir\": \"\"\n }\n },\n \"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"prefix\": \"testuser\"\n }\n },\n \"connectionSettings\": {\n \"mode\": \"ws\",\n \"server\": \"localhost\",\n \"virtualproxy\": \"header\",\n \"security\": true,\n \"allowuntrusted\": true,\n \"headers\": {\n \"Qlik-User-Header\": \"{{.UserName}}\"\n }\n },\n \"scheduler\": {\n \"type\": \"simple\",\n \"iterationtimebuffer\": {\n \"mode\": \"onerror\",\n \"duration\": \"10s\"\n },\n \"instance\": 1,\n \"reconnectsettings\": {\n \"reconnect\": false,\n \"backoff\": null\n },\n \"settings\": {\n \"executionTime\": -1,\n \"iterations\": 10,\n \"rampupDelay\": 7,\n \"concurrentUsers\": 10,\n \"reuseUsers\": false,\n \"onlyinstanceseed\": false\n }\n },\n \"scenario\": [\n {\n \"action\": \"openhub\",\n \"label\": \"open hub\",\n \"disabled\": false,\n \"settings\": {}\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"openapp\",\n \"label\": \"open app\",\n \"disabled\": false,\n \"settings\": {\n \"appmode\": \"name\",\n \"app\": \"myapp\",\n \"filename\": \"\",\n \"unique\": false\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"changesheet\",\n \"label\": \"change sheet to analysis sheet\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"QWERTY\"\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"select\",\n \"label\": \"select 1-10 values in object uvxyz\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"uvxyz\",\n \"type\": \"randomfromenabled\",\n \"accept\": false,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 10,\n \"dim\": 0,\n \"values\": null\n }\n }\n ]\n}\n```\n\n
", + Examples: "\n
\nExample\n\n```json\n{\n \"settings\": {\n \"timeout\": 300,\n \"logs\": {\n \"filename\": \"scenarioresult.tsv\"\n },\n \"outputs\": {\n \"dir\": \"\"\n }\n },\n \"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"prefix\": \"testuser\"\n }\n },\n \"connectionSettings\": {\n \"mode\": \"ws\",\n \"server\": \"localhost\",\n \"virtualproxy\": \"header\",\n \"security\": true,\n \"allowuntrusted\": true,\n \"headers\": {\n \"Qlik-User-Header\": \"{{.UserName}}\"\n }\n },\n \"scheduler\": {\n \"type\": \"simple\",\n \"iterationtimebuffer\": {\n \"mode\": \"onerror\",\n \"duration\": \"10s\"\n },\n \"instance\": 1,\n \"reconnectsettings\": {\n \"reconnect\": false,\n \"backoff\": null\n },\n \"settings\": {\n \"executionTime\": -1,\n \"iterations\": 10,\n \"rampupDelay\": 7,\n \"concurrentUsers\": 10,\n \"reuseUsers\": false,\n \"onlyinstanceseed\": false\n }\n },\n \"scenario\": [\n {\n \"action\": \"openhub\",\n \"label\": \"open hub\",\n \"disabled\": false,\n \"settings\": {}\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"openapp\",\n \"label\": \"open app\",\n \"disabled\": false,\n \"settings\": {\n \"appmode\": \"name\",\n \"app\": \"myapp\",\n \"filename\": \"\",\n \"unique\": false\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"changesheet\",\n \"label\": \"change sheet to analysis sheet\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"QWERTY\"\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"select\",\n \"label\": \"select 1-10 values in object uvxyz\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"uvxyz\",\n \"type\": \"randomfromenabled\",\n \"accept\": false,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 10,\n \"dim\": 0,\n \"values\": null\n }\n }\n ]\n}\n```\n\n
", }, "scenario": { Description: "## Scenario section\n\nThis section of the JSON file contains the actions that are performed in the load scenario.\n\n### Structure of an action entry\n\nAll actions follow the same basic structure: \n", diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 99bfb7c1..26e81af9 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "slices" "sort" "github.com/qlik-oss/gopherciser/generatedocs/pkg/common" @@ -306,7 +307,18 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { func generateWikiGroups(compiledDocs *CompiledDocs) map[string]string { groups := make(map[string]string) - for _, group := range compiledDocs.Groups { + + // make sure generated same order every time + groupNames := make([]string, len(compiledDocs.Groups)) + mapForSorting := make(map[string]common.GroupsEntry, len(compiledDocs.Groups)) + for i := 0; i < len(compiledDocs.Groups); i++ { + groupNames[i] = compiledDocs.Groups[i].Name + mapForSorting[compiledDocs.Groups[i].Name] = compiledDocs.Groups[i] + } + slices.Sort(groupNames) + + for _, groupName := range groupNames { + group := mapForSorting[groupName] if verbose { fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) } From b397cf857e0b24fa5ae126ecdac6da3297a45f28 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Tue, 18 Feb 2025 17:05:31 +0100 Subject: [PATCH 10/46] remove non developer documentation from repo --- README.md | 6 +- docs/README.md | 354 --------------------------- docs/availability-testing.md | 38 --- docs/example-qseow-header.md | 148 ----------- docs/examples.md | 10 - docs/extending-gopherciser.md | 7 - docs/images/availability-testing.png | Bin 65119 -> 0 bytes docs/pre-caching.md | 27 -- docs/random-qseow.md | 143 ----------- docs/sense-object-definitions.md | 300 ----------------------- 10 files changed, 1 insertion(+), 1032 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/availability-testing.md delete mode 100644 docs/example-qseow-header.md delete mode 100644 docs/examples.md delete mode 100644 docs/extending-gopherciser.md delete mode 100644 docs/images/availability-testing.png delete mode 100644 docs/pre-caching.md delete mode 100644 docs/random-qseow.md delete mode 100644 docs/sense-object-definitions.md diff --git a/README.md b/README.md index b780a70b..00ad2365 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,7 @@ Gopherciser is used for load testing (that is, stress testing and performance me Gopherciser can run standalone, but is also included in the Qlik Sense Enterprise Scalability Tools (QSEST), which are available for download [here](https://community.qlik.com/t5/Qlik-Scalability/Qlik-Sense-Enterprise-Scalability-Tools/gpm-p/1579916). -More information on Gopherciser is available here: - -* [Load testing - an introduction](./docs/README.md) -* [Setting up load scenarios](./docs/settingup.md) -* [Architecture and code structure](./architecture.md) +For more information on how to perform load testing with Gopherciser see the [wiki](https://github.com/qlik-oss/gopherciser/wiki/introduction), this readme documents building and development of gopherciser. ## Building Gopherciser diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 646e2e5a..00000000 --- a/docs/README.md +++ /dev/null @@ -1,354 +0,0 @@ -![](images/logo.png) - -# Load testing - an introduction - -To get reliable measurements when performing load testing, the following must be taken into consideration: - -* The test environment should be dedicated to load tests only. -* Routers and firewalls must not constitue bottlenecks or interpret load tests as Denial of Service attacks. - -Because of the above, the load client should be physically positioned as close as possible to the Qlik Sense® Enterprise deployment under test. - -To benchmark the capacity of a Qlik Sense Enterprise deployment in terms of number of clicks per second or response times, load test scenarios should resemble real usage as closely as possible. So, when creating scenarios, it is important to select realistic inter-departure times (that is, think times between clicks) for the requests from virtual users. The think times in between actions are often too short and do not reflect the behavior of real users who might, for example, perform their analysis while in a meeting or a phone call or for some other reason need more think time between the actions than initially expected (this is especially important to consider when simulating large amounts of users as the actions and think times greatly impact the load and thereby the number of users that the system can accommodate without becoming saturated). - -Another challenging task is to create an average scenario that replicates the load generated by many users in total when scaled up. The load can be tuned by changing: - -* The number of actions in a load test scenario -* The inter-departure times between adjacent actions -* The number of concurrent virtual users - -The first two bullets are covered by the design of the load test scenario, whereas the number of concurrent virtual users can be tuned during the load testing session. - -## Using Gopherciser - -The performance testing is based on load scenarios, which are sequences of actions carried out by virtual Qlik Sense Enterprise users. - -A load scenario is defined in a JSON file and can be executed sequentially or in parallel with other load scenarios to simulate a realistic user scenario that can be used to investigate the performance of a Qlik Sense Enterprise deployment. - -### Overview of commands - -`gopherciser [command]` - -Commands: - -* `execute` (or `x`): Run a load scenario towards a Qlik Sense Enterprise deployment. -* `help`: Show the help. -* `objdef` (or `od`): Export and validate object definitions files. -* `script` (or `s`): Execute script command. -* `version` (or `ver`): Show the version information. -* `completion` : Generate command line completion script. - -Flags: - -* `-h`, `--help`: Show the help for a command (`gopherciser [command] --help`). - -#### Completion command -`gopherciser completion [bash|zsh|fish|powershell]` - -Run `gopherciser completion --help` and follow the instructions to install command line completion for your shell. - - -#### Execute command - -`gopherciser execute [flags]` - -`gopherciser x [flags]` - -Flags: - -* `-c`, `--config string`: Load the specified scenario setup file. -* `--debug`: Log debug information. -* `-d`, `--definitions`: Custom object definitions and overrides. -* `-h`, `--help`: Show the help for the `execute` command. -* `--logformat string`: Set the specified log format. The log format specified in the scenario setup file is used by default. If no log format is specified, `tsvfile` is used. - * `0` or `tsvfile`: TSV file - * `1` or `tsvconsole`: TSV console - * `2` or `jsonfile`: JSON file - * `3` or `jsonconsole`: JSON console - * `4` or `console`: Console - * `5` or `combined`: Combined (TSV file + JSON console) - * `6` or `no`: Default logs and status output turned off. - * `7` or `onlystatus`: Default logs turned off, but status output turned on. -* `--metricslevel int`: Set level of Prometheus metrics to export/expose when Gopherciser is running. 0 - default off, 1 - Pull, 2 - Push without api, 3 - Push with api. -* `--metricstarget string`: (Prometheus only) Depends on metricslevel > 0. For pull needs to be an int for port, for push is the full target URL. -* `--metricslabel string`: (Prometheus PUSH only) A label (Prometheus job) to be used when pushing metrics to remote Prometheus -* `--metricsgroupingkey key=value`, `-g key=value`: (Prometheus PUSH only) This flag, which can be supplied multiple times, sets Prometheus grouping keys (in key=value format). -* `--profile string`: Start the specified profiler. - * `1` or `cpu`: CPU - * `2` or `block`: Block - * `3` or `goroutine`: Goroutine - * `4` or `threadcreate`: Threadcreate - * `5` or `heap`: Heap - * `6` or `mutex`: Mutex - * `7` or `trace`: Trace - * `8` or `mem`: Mem -* `--regression`: Log data needed to run regression analysis. **Note:** Do not log regression data when testing performance. -* `-s`, `--set`: Override a value in script with key.path=value. See [Using script overrides](#using-script-overrides) for further explanation. -* `--summary string`: Set the type of summary to display after the test run. Defaults to `simple` for minimal performance impact. - * `0` or `undefined`: Simple, single-row summary - * `1` or `none`: No summary - * `2` or `simple`: Simple, single-row summary - * `3` or `extended`: Extended summary that includes statistics on each unique combination of `action`, `label` and `app GUID` - * `4` or `full`: Same as `extended`, but with statistics on each unique combination of `method` and `endpoint` added - * `5` or `file`: Saves basic counters to a file `summary.json`. -* `-t`, `--traffic`: Log traffic information. **Note:** This should only be used for debugging purposes as traffic logging is resource-consuming. -* `-m`, `--trafficmetrics`: Log metrics information. - -Exit codes: - -* `0`: Execution OK -* `1` - `127`: Number of errors during the execution (`127` means 127 errors or more) -* `128`: Error during the execution (ExitCodeExecutionError) -* `129`: Error when parsing the JSON config (ExitCodeJSONParseError) -* `130`: Error when validating the JSON config (ExitCodeJSONValidateError) -* `131`: Error when resolving the log format (ExitCodeLogFormatError) -* `132`: Error when reading the object definitions (ExitCodeObjectDefError) -* `133`: Error when starting the profiling (ExitCodeProfilingError) -* `134`: Error when starting Prometheus (ExitCodeMetricError) -* `135`: Error when interacting with host OS (ExitCodeOsError) -* `136`: Error when using incorrect summary type (ExitCodeSummaryTypeError) -* `137`: Error during test connection (ExitCodeConnectionError) -* `138`: Error during during get app structure (ExitCodeConnectionError) -* `139`: Error when missing parameter (ExitCodeMissingParameter) -* `140`: Process was force quit (ExitCodeForceQuit) -* `141`: Error count exceeded `maxerrors` setting - -#### Objdef command - -`gopherciser objdef [sub-commands]` - -`gopherciser od [sub-commands]` - -Sub-commands: - -* `generate`: Generate an object definitions file from the default values. -* `validate`: Validate the object definitions in a definitions file. - -`generate` command flags: - -* `-d`, `--definitions`: (mandatory) Name of the definitions file to create. -* `-f`, `--force`: Overwrite an existing definitions file. -* `-h`, `--help`: Show the help for the `generate` command. -* `-o`, `--object strings`: (optional) List of objects to include in the definitions file. Defaults to all. - -`validate` command flags: - -* `-d`, `--definitions`: (mandatory) Name of the definitions file to validate. -* `-h`, `--help`: Show the help for the `validate` command. -* `-v`, `--verbose`: Display a summary of the validation. - -For more information on how to use the `objdef` command, see [Supporting extensions and overriding defaults](./sense-object-definitions.md). - -#### Script command - -`gopherciser script [sub-commands] [flags]` - -`gopherciser s [sub-commands] [flags]` - -Sub-commands: - -* `connect` (or `c`): Test the connection using the settings provided in the config file. -* `structure` (or `s`): Get the app structure using the settings provided in the config file. -* `validate` (or `v`): Validate a scenario script. -* `template` (or `tmpl` or `t`): Generate a template scenario script. - -`connect` command flags: - -* `-c`, `--config string`: Connect using the specified scenario config file. -* `-h`, `--help`: Show the help for the `connect` command. -* `-s`, `--set`: Override a value in script with key.path=value. See [Using script overrides](#using-script-overrides) for further explanation. - -`structure` command flags: - -* `-c`, `--config string`: Connect using the specified scenario config file. -* `--debug`: Log debug information. -* `-h`, `--help`: Show the help for the `structure` command. -* `--logformat string`: Set the specified log format. The log format specified in the scenario setup file is used by default. If no log format is specified, `tsvfile` is used. - * `0` or `tsvfile`: TSV file. - * `1` or `tsvconsole`: TSV console. - * `2` or `jsonfile`: JSON file. - * `3` or `jsonconsole`: JSON console. - * `4` or `console`: Console. - * `5` or `combined`: Combined (TSV file + JSON console). - * `6` or `no`: Default logs and status output turned off. - * `7` or `onlystatus`: Default logs turned off, but status output turned on. -* `-o` or `--output string`: Script output folder. Defaults to working folder. -* `-r` or `--raw`: Include raw properties in the structure. -* `--summary string`: Set the type of summary to display after the test run. Defaults to `simple`. - * `0` or `undefined`: Simple summary, includes the number of objects and warnings and lists all warnings. - * `1` or `none`: No summary. - * `2` or `simple`: Simple summary, includes the number of objects and warnings and lists all warnings. - * `3` or `extended`: Extended summary, includes a list of all objects in the structure. - * `4` or `full`: Currently the same as the `extended` summary, includes a list of all objects in the structure. - * `5` or `file`: Saves basic counters to a file `summary.json`. -* `-t`, `--traffic`: Log traffic information. -* `-m`, `--trafficmetrics`: Log metrics information. -* `-s`, `--set`: Override a value in script with key.path=value. See [Using script overrides](#using-script-overrides) for further explanation. -* `--setfromfile`: Override values from file where each row is path/to/key=value. - -`validate` command flags: - -* `-c`, `--config string`: Load the specified scenario setup file. -* `-h`, `--help`: Show the help for the `validate` command. -* `-s`, `--set`: Override a value in script with key.path=value. See [Using script overrides](#using-script-overrides) for further explanation. -* `--setfromfile`: Override values from file where each row is path/to/key=value. - -`template` command flags: - -* `-c`, `--config string`: (optional) Create the specified scenario setup file. Defaults to `template.json`. -* `-f`, `--force`: Overwrite existing scenario setup file. -* `-h`, `--help`: Show the help for the `template` command. - -##### Piping stdin - -Config file and overrides file can be piped from stdin. If no config is set stdin is assumed to be the config file, if config file is set, stdin is assumed to be the overrides file. - -This would execute the sheetchanger example from stdin: - -```bash -cat ./docs/examples/sheetChangerQlikCore.json | ./gopherciser x -``` - -This would execute overrides from stdin: - -```bash -cat overrides.txt | ./gopherciser x -c ./docs/examples/sheetChangerQlikCore.json -``` - -Advanced example. Use `jq` to disable all `sheetchanger` actions then run the sheet changer example script, this would now only do the openapp action: - -```bash -jq '(.scenario[] | select(.action=="sheetchanger") | .settings.disabled) = true' ./docs/examples/sheetChangerQlikCore.json| ./gopherciser x -``` - -#### Using script overrides - -Script overrides overrides a value pointed to by a path to its key. If the key doesn't exist in the script there will an error, even if it's a valid value according to config. - -The syntax is path/to/key=value. A common thing to override would be the settings of the simple scheduler. - -```json -"scheduler": { - "type": "simple", - "settings": { - "executiontime": -1, - "iterations": 1, - "rampupdelay": 1.0, - "concurrentusers": 1 - } -} -``` - -`scheduler` is in the root of the JSON, so the path to the key of `concurrentusers` would be `scheduler/settings/concurrentusers`. To override concurrent users from command line: - -```bash -./gopherciser x -c ./docs/examples/sheetChangerQlikCore.json -s 'scheduler/settings/concurrentusers=2' -``` - -Overriding a string, such as the server the servername requires it to be wrapped with double quotes. E.g. to override the server: - -```bash -./gopherciser x -c ./docs/examples/sheetChangerQlikCore.json -s 'connectionSettings/server="127.0.0.1"' -``` - -Do note that the path is case sensitive. It needs to be `connectionSettings/server` as `connectionsettings/server` would try, and fail, to add new key called `connectionsettings`. - -Overrides could also be used to with more advanced paths. If the position in `scenario` is known for `openapp` we could replace e.g. the app opened, assuming `openapp` is the first action in `scenario`: - -```bash -./gopherciser x -c ./docs/examples/sheetChangerQlikCore.json -s 'scenario/[0]/settings/app="mynewapp"' -``` - -It could even replace an entire JSON object such as the `connectionSettings` with one replace call: - -```bash -./gopherciser x -c ./docs/examples/sheetChangerQlikCore.json -s 'connectionSettings={"mode":"ws","server":"127.0.0.1","port":19076}' -``` - -Overrides could also be defined in a file. Each row in the file should be in the same format as when using overrides from command line, although should not be wrapped with single quotes as this is for command line interpretation purposes. Using the same overrides as above, the file could look like the following: - -``` -connectionSettings/server="1.2.3.4" -scenario/[0]/settings/app="mynewapp" -connectionSettings={"mode":"ws","server":"127.0.0.1","port":19076} -``` - -Overrides will be executed from top to button, as such the third line will override the `server` overriden by the first line and script will execute towards `127.0.0.1:19076`. - -Any command line overrides will be executed _after_ the overrides defined in file. - -## Analyzing the test results - -A log file is recorded during each test execution. The `logs.filename` setting in the `settings` section of the load test script specifies the name of and the path to the log file (see [Setting up load scenarios](./settingup.md)). If a file with the specified filename already exists, a number is appended to the filename (for example, if the file `xxxxx.yyy` already exists, the log file is stored as `xxxxx-001.yyy`). - -The contents of the log file differ depending on the type of logging selected. Examples of rows that typically can be found in the log file include: - -* `result`: The result of an action (complete with timestamp, response time and information whether or not the action was successful). -* `info`: Information related to the test execution (for example, the total number of errors, actions and requests during the test execution). -* `error`: Information related to errors during the test execution. - -The test results (that is, log files) can be analyzed using the [Scalability Results Analyzer](https://community.qlik.com/t5/Qlik-Scalability/Scalability-Results-Analyzer/gpm-p/1493648). - -## Regression Analysis - -Gopherciser is able to produce regression logs consumed by the regression -analyzer in Qlik Senese Enterprise Scalability Tools (QSEST). Enable -Regression logging with the `--regression` flag or in the script settings -(see `settings.logs.regression` in [settingup.md](./settingup.md)). -Regression logs are written to a separate file with a `.regression` filename -extension, in the same directory and with the same base name as the test -results. - -The regression log contains a snapshot of the subscribed Qlik Sense objects -after each action in the scenario. The regression analyzer in QSEST can then -compare these snapshots to find any differences. Typically you run the same -script with regession logging enabled, towards two versions of the same app. -Then you use regression analyzer in QSEST to gain insight in how the app has -changed. - -**Note** Do not enable regression logging when running performance tests. The -regression logging introduces a delay after each action in the executed -scenario. - -**Note** With regression logging enabled, the the scheduler is implicitly set -to execute the scenario as one user for one iteration. - - -## Complementary manual measurements - -To capture the full end user experience, manual measurements are needed. A web browser in combination with an optional measurement method can be used to get a snapshot of the full response times including rendering of visualizations etc. - -Perform the actions defined in the load test scenario and measure the time for each action to complete (using, for example, Fiddler). To measure the user-perceived response times under specific load, perform the measurements while the load test scenario is executed. - -## Limitations - -These are the current limitations in Gopherciser: - -* Not supported: - * Variance waterfall chart - * P&L pivot chart object - * Trellis container extension - * Chart suggestions (that is, auto-charts) are supported, but only if the objects were created with Qlik Sense Enterprise June 2020 or later. Auto-chart objects created with earlier versions have to be manually updated in your app. -* Pivot table: - * The only supported selection type is `randomfromall` - * Values are randomly selected from all values in the table -* Map: - * Selections can only be made in the first layer (that is, layer 0) -* Visualization bundle: - * Selections are not fully supported in the Heatmap chart. - * Selections not supported in Grid chart. -* Dashboard bundle: - * Changing variables using variable input not supported. - * Selections done by animator not supported. - * Selections done using date picker not supported. - -## Links - -- [Building a Docker image](./buildingdockerimage.md) -- [Setting up load scenarios](./settingup.md) -- [Supporting extensions and overriding defaults](./sense-object-definitions.md) -- [Running load scenarios in Kubernetes](./kubernetes-job.md) -- [Example scenarios](./examples.md) -- [Using Gopherciser for pre-caching](./pre-caching.md) -- [Using Gopherciser for availability testing](./availability-testing.md) -- [Extending Gopherciser](./extending-gopherciser.md) diff --git a/docs/availability-testing.md b/docs/availability-testing.md deleted file mode 100644 index c7467e5c..00000000 --- a/docs/availability-testing.md +++ /dev/null @@ -1,38 +0,0 @@ -# Using Gopherciser for availability testing - -An availability test is used to apply a low, constant workload towards a deployment in order to: -* Make sure that the deployment is up and running -* Identify long-term performance trends -* Detect concurrency-related problems -* Evaluate the response time for key user actions - -Do the following to set up an availability test using Gopherciser: - -1. Create a test scenario. - - When creating an availability test scenario, keep the following in mind: - * Keep the user flow low. - * Make sure to include key (important) actions. - - Save the test scenario as a script file (`.json` file). - -2. Create a script file (for example, a batch file, `.bat`, in case of Microsoft Windows, or a shell script file, `.sh`, in case of Linux) that executes the availability test scenario. - - Example of file contents (in case of a Microsoft Windows batch file): - ``` - C:\performancetests\gopherciser\gopherciser execute -c C:\performancetests\gopherciser\Scenarios\AvailabilityTestScript.json - ``` - -3. Select a scheduling mechanism (for example, the Task Scheduler in Microsoft Windows or the cron job scheduler in Linux) and configure it to run the batch / shell script file. - - **Note:** As the availability test scenario is to run on a regular basis, make sure to schedule enough time in between the executions, so that the next iteration is not started before the previous one has finished. - -4. After each iteration, copy the test log file to a folder of your choice. - -5. Create an availability test analyzer app that reloads the data from the test log file after each iteration. - -6. (Optional:) Implement alerting based on metrics of interest (such as response times, errors, or failing actions). - - The following figure shows an example setup where Qlik NPrinting® and Grafana are used for alerting. - - ![Availability testing](images/availability-testing.png) \ No newline at end of file diff --git a/docs/example-qseow-header.md b/docs/example-qseow-header.md deleted file mode 100644 index 4090df0a..00000000 --- a/docs/example-qseow-header.md +++ /dev/null @@ -1,148 +0,0 @@ -# Example: Running against QSEoW with header authentication - -This step-by-step example shows how to set up and run a randomworker scenario against a Qlik Sense® Enterprise for Windows (QSEoW) deployment with header authentication enabled. - -**Note:** Header authentication can be set up in different ways. The settings used in this example may not be valid for all QSEoW deployments. - -## Requirements - -The following is required for this procedure: - -* A QSEoW deployment -* A server with Gopherciser installed (referred to as the "load client") - -## Creating suitable access rules with enough tokens - -First, create access rules that allow allocation of licenses to the virtual users created by Gopherciser. - -
- -Example - -In this example, a login access rule is used to allocate licenses to the virtual users created by Gopherciser. The rule allows users from a specific user directory ("anydir") to access the QSEoW deployment. - -Do the following in the QSEoW deployment: - -1. Open the Qlik® Management Console (QMC). -2. Go to **License management** > **Login access rules** > **Create new**. -3. Enter a name for the new login access rule in the **Name** field. -4. Enter the number of tokens allocated to the login access rule in the **Allocated tokens** field. -5. Select **Apply**. The **Create license rule** dialog appears with a default license rule selected. -6. Select **Advanced** under **Properties** to display the code for the license rule. -7. Select **userDirectory** in the **name** drop-down. -8. Enter the name of the user directory ("anydir") in the empty field next to the **value** drop-down. -9. Check that the code in the **Advanced** section is similar to the following: `((user.userDirectory="anydir"))` -10. Select **Apply** to create the login access rule. - -For more information on how to create login access rules, see the [Qlik help](https://help.qlik.com/en-US/sense/Subsystems/ManagementConsole/Content/Sense_QMC/create-login-access.htm). - -
- -## Adding a virtual proxy for authentication of the virtual users - -The next step is to create a virtual proxy to handle the authentication, session handling, and load balancing of the virtual users created by Gopherciser. - -
- -Example - -Do the following in the QSEoW deployment: - -1. Open the QMC. -2. Go to **Proxies**. -3. Select the proxy on the central node (**Central**) and then **Edit**. -4. Select **Virtual proxies** under **Associated items**. -5. Select **Add** > **Create new**. -6. Select **Authentication** and **Load balancing** under **Properties**. -7. Fill in the following in the **Identification** section: - * **Description**: Enter a name for the new virtual proxy that will handle the virtual users ("virtualproxy" in this example). - * **Prefix**: Enter the prefix to use for the new virtual proxy in the URL ("vp" in this example). - * **Session cookie header name**: Enter the name of the http header to use for the session cookie ("X-Qlik-Session-header" in this example). -8. Fill in the following in the **Authentication** section: - * **Anonymous access mode**: Select **No anonymous user** in the drop-down. - * **Authentication method**: Select **Header authentication static user directory** (meaning that the user directory is set in the QMC - see **Header authentication static user directory** below) in the drop-down. - * **Header authentication header name**: Enter the name of the http header that identifies users ("X-Sense-User" in this example). - * **Header authentication static user directory**: Enter the name of the user directory where additional information can be fetched for header authenticated users ("anydir" in this example). -9. Select **Add new server node** in the **Load balancing** section. -10. Select the engine nodes to load balance to and then select **Add**. -11. Select **Apply** to create the virtual proxy. - -For information on how to create a virtual proxy, see the [Qlik help](https://help.qlik.com/en-US/sense/Subsystems/ManagementConsole/Content/Sense_QMC/create-virtual-proxy.htm). - -
- -## Importing and publishing the test apps - -Import the test apps to the QSEoW deployment. Make sure to publish the apps, so that they are available to all users. - -For information on how to publish apps, see the [Qlik help](https://help.qlik.com/en-US/sense/Subsystems/ManagementConsole/Content/Sense_QMC/publish-apps.htm). - -## Testing the header authentication - -The next step is to make sure that the header authentication is correctly configured. - -
- -Example - -Do the following on the load client: - -1. Install a plug-in that allows modification of http headers in the web browser (for example, "ModHeader" for the Google Chrome browser). -2. Enter the header name ("X-Sense-User" in this example) in the **Header name** field in the browser plug-in. -3. Enter the name of the user ("anyuser" in this example) in the **Header value** field in the browser plug-in. -4. Go to the hub of the QSEoW deployment using the following URI (using "vp" as `` in this example): `//hub/` -5. If you can access the hub and the username entered in the **Header value** field is displayed, the virtual proxy with header authentication works. - -
- -## Modifying the sample test script - -The sample test script is available here: [General randomworker example with header authentication](examples/random-qseow-header.json) - -
- -Example - -Do the following on the load client: - -1. Download the sample test script. -2. Modify the following fields to match the QSEoW setup configured above: - * `connectionSettings.server`: The hostname of the QSEoW deployment. - * `connectionSettings.virtualproxy`: The prefix for the virtual proxy that handles the virtual users ("vp" in this example). - * `connectionSettings.headers`: The name of the http header that identifies users ("X-Sense-User" in this example). - * `loginSettings.settings.directory`: The name of the user directory ("anydir" in this example). The directory name is used by the login access rule to allocate licenses. - * `scenario.action: OpenApp.settings.randomapps`: The names of the test apps. -3. Save the changes to the script. - -
- -## Running the test script - -Run the test script. - -
- -Example - -Do the following on the load client: - -1. Open a Command Prompt. -2. Execute the following command (the actual command differs depending on platform - the example below is based on Linux Bash): - -``` -./gopherciser execute -c random-qseow-header.json -``` - -
- -The `settings.logs.filename` field in the test script specifies the name of and the path to the log file stored during the test execution. - -## (Optional:) Viewing metrics in Grafana - -To show continuous live [Prometheus](https://prometheus.io/) metrics during the execution, start Gopherciser with the following flag: -``` ---metrics int -``` -The exposed metrics include action metrics (such as response times per app and action), test warnings and test errors. - -The metrics are available at `http://localhost:port/metrics` during the test. Replace `port` with the port number specified by the `--metrics` flag. diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index f6fa2313..00000000 --- a/docs/examples.md +++ /dev/null @@ -1,10 +0,0 @@ -# Example scenarios - -The following list contains example scenarios for Qlik Sense® Enterprise on Windows (QSEoW) deployments. - -
- -- [Running against QSEoW with header authentication](./example-qseow-header.md) -- [Running against QSEoW with JWT authentication](./random-qseow.md) - -
diff --git a/docs/extending-gopherciser.md b/docs/extending-gopherciser.md deleted file mode 100644 index af8d33f0..00000000 --- a/docs/extending-gopherciser.md +++ /dev/null @@ -1,7 +0,0 @@ -# Extending Gopherciser - -Gopherciser can be extended without having to alter its base code. A template repo showcasing how to do this can be found [here](https://github.com/qlik-oss/gopherciser-extended-example), including example custom actions. - -## Adding support for extensions and overriding settings for default objects - -Gopherciser supports configuring how to get data and make selections in standard Qlik Sense® objects. The file can be extended with functionality for custom objects. For more information, see [Supporting extensions and overriding defaults](./sense-object-definitions.md). diff --git a/docs/images/availability-testing.png b/docs/images/availability-testing.png deleted file mode 100644 index 2945f083dfa8a43b5d19afa954afbdb438dc3f8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65119 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 - -Example - -In this example, a login access rule is used to allocate licenses to the virtual users created by Gopherciser. The rule allows users from a specific user directory ("anydir" in this example) to access the QSEoW deployment. - -Do the following in the QSEoW deployment: - -1. Open the Qlik® Management Console (QMC). -2. Go to **License management** > **Login access rules** > **Create new**. -3. Enter a name for the new login access rule in the **Name** field. -4. Enter the number of tokens allocated to the login access rule in the **Allocated tokens** field. -5. Select **Apply**. The **Create license rule** dialog appears with a default license rule selected. -6. Select **Advanced** under **Properties** to display the code for the license rule. -7. Select **userDirectory** in the **name** drop-down. -8. Enter the name of the user directory, "anydir", in the empty field next to the **value** drop-down. -9. Check that the code in the **Advanced** section is similar to the following: `((user.userDirectory="anydir"))` -10. Select **Apply** to create the login access rule. - -For more information on how to create login access rules, see the [Qlik help](https://help.qlik.com/en-US/sense/Subsystems/ManagementConsole/Content/Sense_QMC/create-login-access.htm). - -
- -## Adding a virtual proxy for authentication of the virtual users - -The next step is to create a virtual proxy to handle the authentication, session handling, and load balancing of the virtual users created by Gopherciser. - -
- -Example - -Do the following in the QSEoW deployment: - -1. Open the QMC. -2. Go to **Proxies**. -3. Select the proxy on the central node (**Central**) and then **Edit**. -4. Select **Virtual proxies** under **Associated items**. -5. Select **Add** > **Create new**. -6. Select **Authentication** and **Load balancing** under **Properties**. -7. Fill in the following in the **Identification** section: - * **Description**: Enter a name for the new virtual proxy that will handle the virtual users ("virtualproxy" in this example). - * **Prefix**: Enter the prefix to use for the new virtual proxy in the URL ("jwt" in this example). - * **Session cookie header name**: Enter the name of the http header to use for the session cookie ("X-Qlik-Session-header" in this example). -8. Fill in the following in the **Authentication** section: - * **Anonymous access mode**: Select **No anonymous user** in the drop-down. - * **Authentication method**: Select **JWT** in the drop-down. - * **JWT certificate**: Paste the JWT X.509 public key certificate in PEM format. - * **JWT attribute for user ID**: Enter the JWT attribute name of the attribute that describes the user ID ("user" in this example). - * **JWT attribute for user directory**: Enter the JWT attribute name of the attribute that describes the user directory ("directory" in this example). -9. Select **Add new server node** in the **Load balancing** section. -10. Select the engine nodes to load balance to and then select **Add**. -11. Select **Apply** to create the virtual proxy. - -For information on how to create a virtual proxy, see the [Qlik help](https://help.qlik.com/en-US/sense/Subsystems/ManagementConsole/Content/Sense_QMC/create-virtual-proxy.htm). - -
- -## Importing and publishing the test apps - -Import the test apps to the QSEoW deployment. Make sure to publish the apps, so that they are available to all users. - -For information on how to publish apps, see the [Qlik help](https://help.qlik.com/en-US/sense/Subsystems/ManagementConsole/Content/Sense_QMC/publish-apps.htm). - -## Modifying the sample test script - -The sample script is available here: [General randomworker example with JWT authentication](examples/random-qseow.json) - -
- -Example - -Do the following on the load client: - -1. Download the sample test script. -2. Modify the following fields to match the QSEoW setup configured above: - * `connectionSettings.server`: The hostname of the QSEoW deployment. - * `connectionSettings.virtualproxy`: The prefix for the virtual proxy that handles the virtual users ("jwt" in this example). - * `connectionSettings.jwtsettings.keypath`: The path to the private key (`mock.pem` in this example as the key is stored in the same folder as Gopherciser). - * `connectionSettings.jwtsettings.claims`: The JWT claims as an escaped JSON string (`{\"user\":\"{{.UserName}}\",\"directory\":\"{{.Directory}}\"}` in this example). The claims must correspond to the **JWT attribute for user ID** and **JWT attribute for user directory** settings in the QMC. - * `loginSettings.settings.directory`: The name of the user directory ("anydir" in this example). The directory name is used by the login access rule to allocate licenses. - * `scenario.action: OpenApp.settings.randomapps`: The names of the test apps. -3. Save the changes to the script. - -
- -## Running the test script - -Run the test script. - -
- -Example - -Do the following on the load client: - -1. Open a Command Prompt. -2. Execute the following command (the actual command differs depending on platform - the example below is based on Linux Bash): - -``` -./gopherciser execute -c random-qseow.json -``` - -
- -The `settings.logs.filename` field in the test script specifies the name of and the path to the log file stored during the test execution. - -## (Optional:) Viewing metrics in Grafana - -To show continuous live [Prometheus](https://prometheus.io/) metrics during the execution, start Gopherciser with the following flag: -``` ---metrics int -``` -The exposed metrics include action metrics (such as response times per app and action), test warnings and test errors. - -The metrics are available at `http://localhost:port/metrics` during the test. Replace `port` with the port number specified by the `--metrics` flag. diff --git a/docs/sense-object-definitions.md b/docs/sense-object-definitions.md deleted file mode 100644 index b5a1a9bd..00000000 --- a/docs/sense-object-definitions.md +++ /dev/null @@ -1,300 +0,0 @@ -# Supporting extensions and overriding defaults - -Gopherciser supports custom definitions for how to get data and make selections in standard Qlik Sense® objects. Defaults can be overridden and custom extensions added when it comes to getting data and making selections. - -## Importing custom object definitions - -Definitions are imported from a JSON file using the `--definitions` (or `-d`) flag with the `execute` command. Only the definitions to be overridden or added need to be included in the definitions file. - -```bash -./gopherciser execute -c localtests/QlikCoreCtrl00Select.json -d defs.json -``` - -## Objdef command - -The `objdef` command handles export and validation of definitions files. - -```bash -╰─➤ ./gopherciser objdef -Handles sense object data definitions for gopherciser. -Use to export default values or examples or to validate custom definitions or overrides. - -Usage: - gopherciser objdef [flags] - gopherciser objdef [command] - -Aliases: - objdef, od - -Available Commands: - generate Generate object definitions from default values. - validate Validate object definitions in file. - -Flags: - -h, --help help for objdef - -Use "gopherciser objdef [command] --help" for more information about a command. -``` - -### Exporting default definitions - -```bash -╰─➤ ./gopherciser objdef generate -Generate object definitions from default values, either a full json with all defaults or defined objects. - -Usage: - gopherciser objdef generate [flags] - -Aliases: - generate, gen, g - -Flags: - -d, --definitions string (mandatory) definitions file. - -f, --force overwrite definitions file if existing. - -h, --help help for generate - -o, --objects strings (optional) list of objects, defaults to all. -``` - -The `generate` command generates a JSON definitions file (defined by the `--definitions` flag) with all default object definitions. - -```bash -╰─➤ ./gopherciser objdef generate -d defs.json -defs.json written successfully. -``` - -If only one or a few objects are to be overridden, they can be defined in a list. Only the objects to be overridden or added need to be in the definitions file. If the definitions file already exists, the `--force` flag is used to overwrite the file. - -```bash -╰─➤ ./gopherciser objdef generate -d defs.json -o listbox,barchart -f -defs.json written successfully. -``` - -The above produces a definitions file similar to the following: - -```JSON -{ - "barchart": { - "datadef": { - "type": "hypercube", - "path": "/qHyperCube" - }, - "data": [ - { - "requests": [ - { - "type": "hypercubereduceddata", - "path": "/qHyperCubeDef", - "height": 500 - } - ] - } - ], - "select": { - "type": "hypercubevalues", - "path": "/qHyperCubeDef" - } - }, - "listbox": { - "datadef": { - "type": "listobject", - "path": "/qListObject" - }, - "data": [ - { - "requests": [ - { - "type": "listobjectdata", - "path": "/qListObjectDef", - "height": 500 - } - ] - } - ], - "select": { - "type": "listobjectvalues", - "path": "/qListObjectDef" - } - } -} -``` - -### Validating a definitions file - -The `validate` command is used to perform basic structural validation of definitions files. - -```bash -╰─➤ ./gopherciser objdef validate -Validate object definitions from provided JSON file. Will print how many definitions where found, -it's recommended to use to -v verbose flag and verify all parameters where interpreted correctly' - -Usage: - gopherciser objdef validate [flags] - -Aliases: - validate, val, v - -Flags: - -d, --definitions string (mandatory) definitions file. - -h, --help help for validate - -v, --verbose print summary of definitions. -``` - -It is recommended to use the `--verbose` (or `-v`) flag to display a summary when validating a definitions file. - -```bash -╰─➤ ./gopherciser objdef validate -d defs.json -v -2 object definitions found - -[barchart] -/ 1 data constraint entry. -| -| Constraint: Default -| 1 data request: -| [0]: Type: hypercubereduceddata -| Path: /qHyperCubeDef -| Height: 500 -| -| -/ DataDef Type: hypercube -| Path: /qHyperCube -| -/ Select Type: hypercubevalues -| Path: /qHyperCubeDef -* - -[listbox] -/ 1 data constraint entry. -| -| Constraint: Default -| 1 data request: -| [0]: Type: listobjectdata -| Path: /qListObjectDef -| Height: 500 -| -| -/ DataDef Type: listobject -| Path: /qListObject -| -/ Select Type: listobjectvalues -| Path: /qListObjectDef -* -``` - -## Object definition - -An object definition consists of the following sections: - -* `datadef`: Defines the type of data and where in the object structure to find the data -* `data`: Defines the types of data requests to send and under which circumstances -* `select`: Defines the select method and path to use for the object - -### Datadef - -* `type`: Type of data - * `listobject`: Data carrier is a list object (for example, a listbox). - * `hypercube`: Data carrier is a hypercube (used for most charts). - * `nodata`: Object does not contain any data. -* `path`: Path to the data carrier within the object structure. - -```json -"datadef": { - "type": "hypercube", - "path": "/qHyperCube" -} -``` - -### Data - -A list of data requests to send for an object, with the possibility to send different requests depending on different constraints. The list of constraints is evaluated top-down, where the first constraint fulfilled is used. "Nil" requests are always considered to be true (that is, if the list of constraints starts with an entry without a constraint, the subsequent constraints are not used). It is therefore important to start the list with the constraint to be evaluated first. In the above `scatterplot` example, the constraint "qcy > 1000" is evaluated before performing the default operation. - -* `constraints`: A list of constraints for sending the defined set of data requests. An empty or omitted constraint is always considered to be true. - * `path`: Path to the value to evaluate in the object structure. - * `value`: Value constraint definition. The first character must be `<`, `>`, `=`, `!` or `~` followed by a number, string or the words `true` / `false`. The `~` operator is only applicable to arrays and is defined as `contains`. - * `required`: Require the constraint to be evaluated and return an error if the evaluation fails (for example, if the path in the object structure is not traversable). Defaults to `false`. -* `requests`: List of data requests to send if the constraint is successfully evaluated. A request is defined as: - * `type`: Data request type - * `layout`: Get data from layout. - * `listobjectdata`: Get data from listobject data. - * `hypercubedata`: Get data from hypercube. - * `hypercubereduceddata`: Get hypercube reduced data. - * `hypercubebinneddata`: Get hypercube binned data. - * `hypercubestackdata`: Get hypercube stacked data. - * `hypercubedatacolumns`: Get data from hypercube "as columns". - * `hypercubecontinuousdata`: Get hypercube continuous data. - * `path`: Path to be sent in the get data request. - * `height`: Height of data. The default height is used, if omitted or set to `0`. - -### Select - -* `type`: Type of select request - * `listobjectvalues`: Send a `SelectListObjectValues` request. - * `hypercubevalues`: Send a `SelectHyperCubeValues` request unless the data is binned, in which case a `MultiRangeSelectHyperCubeValues` request is sent. - * `hypercubecolumnvalues`: Same as `hypercubevalues`, except that the data is considered to be in columned format when using select methods such as `RandomFromEnabled`, `RandomFromExcluded` etc. -* `path`: Path to send in the select request. - -### Examples - -Simple example of an object with the data in Layout message only: - -```json -{ - "qlik-word-cloud": { - "datadef": { - "type": "hypercube", - "path": "/qHyperCube" - }, - "data": [ - { - "requests": [ - { - "type": "layout" - } - ] - } - ] - } -} -``` - -An object that sends different data requests depending on the size of the data: - -```json -{ - "scatterplot": { - "datadef": { - "type": "hypercube", - "path": "/qHyperCube" - }, - "data": [ - { - "constraints": [ - { - "path": "/qHyperCube/qSize/qcy", - "value": ">2000", - "required": true - } - ], - "requests": [ - { - "type": "hypercubebinneddata", - "path": "/qHyperCubeDef" - } - ] - }, - { - "requests": [ - { - "type": "hypercubedata", - "path": "/qHyperCubeDef", - "height": 2000 - } - ] - } - ], - "select": { - "type": "hypercubevalues", - "path": "/qHyperCubeDef" - } - } -} -``` From d6b811de7e33bdc19eee09d1f4d23714db689542 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 12:16:52 +0100 Subject: [PATCH 11/46] move session variable section --- docs/settingup.md | 5 -- .../extra/sessionvariables/description.md | 5 -- generatedocs/generated/documentation.go | 2 +- generatedocs/pkg/genmd/generate.go | 49 +++++++++++-------- 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/docs/settingup.md b/docs/settingup.md index 4f1608cb..b4ac2c06 100644 --- a/docs/settingup.md +++ b/docs/settingup.md @@ -2528,9 +2528,6 @@ Make apps in stream with id `ABSCDFSDFSDFO1231234` selectable subsequent action This section describes the session variables that can be used with some of the actions. -
-Session variables - Some action parameters support session variables. A session variable is defined by putting the variable, prefixed by a dot, within double curly brackets, such as `{{.UserName}}`. The following session variables are supported in actions: @@ -2651,8 +2648,6 @@ Very similar case as above but apps have number suffix from 1 to 4. This can be } ``` -
-
diff --git a/generatedocs/data/extra/sessionvariables/description.md b/generatedocs/data/extra/sessionvariables/description.md index cd0eca0a..606acb3f 100644 --- a/generatedocs/data/extra/sessionvariables/description.md +++ b/generatedocs/data/extra/sessionvariables/description.md @@ -3,9 +3,6 @@ This section describes the session variables that can be used with some of the actions. -
-Session variables - Some action parameters support session variables. A session variable is defined by putting the variable, prefixed by a dot, within double curly brackets, such as `{{.UserName}}`. The following session variables are supported in actions: @@ -125,5 +122,3 @@ Very similar case as above but apps have number suffix from 1 to 4. This can be } } ``` - -
diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index 084302af..d446e43b 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -423,7 +423,7 @@ var ( Extra = map[string]common.DocEntry{ "sessionvariables": { - Description: "\n## Session variables\n\nThis section describes the session variables that can be used with some of the actions.\n\n
\nSession variables\n\nSome action parameters support session variables. A session variable is defined by putting the variable, prefixed by a dot, within double curly brackets, such as `{{.UserName}}`.\n\nThe following session variables are supported in actions:\n\n* `UserName`: The simulated username. This is not the same as the authenticated user, but rather how the username was defined by [Login settings](#login_settings). \n* `Session`: The enumeration of the currently simulated session.\n* `Thread`: The enumeration of the currently simulated \"thread\" or \"concurrent user\".\n* `ScriptVars`: A map containing script variables added by the action `setscriptvar`.\n* `Artifacts`:\n * `GetIDByTypeAndName`: A function that accepts the two string arguments,\n `artifactType` and `artifactName`, and returns the resource id of the artifact.\n * `GetNameByTypeAndID`: A function that accepts the two string arguments,\n `artifactType` and `artifactID`, and returns the name of the artifact.\n\n\nThe following variable is supported in the filename of the log file:\n\n* `ConfigFile`: The filename of the config file, without file extension.\n\nThe following functions are supported:\n\n* `now`: Evaluates Golang [time.Now()](https://golang.org/pkg/time/). \n* `hostname`: Hostname of the local machine.\n* `timestamp`: Timestamp in `yyyyMMddhhmmss` format.\n* `uuid`: Generate an uuid.\n* `env`: Retrieve a specific environment variable. Takes one argument - the name of the environment variable to expand.\n* `add`: Adds two integer values together and outputs the sum. E.g. `{{ add 1 2 }}`.\n* `join`: Joins array elements together to a string separated by defined separator. E.g. `{{ join .ScriptVars.MyArray \\\",\\\" }}`.\n* `modulo`: Returns modulo of two integer values and output the result. E.g. `{{ modulo 10 4 }}` (will return 2)\n\n### Example\n\n```json\n{\n \"label\" : \"Create bookmark\",\n \"action\": \"createbookmark\",\n \"settings\": {\n \"title\": \"my bookmark {{.Thread}}-{{.Session}} ({{.UserName}})\",\n \"description\": \"This bookmark contains some interesting selections\"\n }\n},\n{\n \"label\" : \"Publish created bookmark\",\n \"action\": \"publishbookmark\",\n \"disabled\" : false,\n \"settings\" : {\n \"title\": \"my bookmark {{.Thread}}-{{.Session}} ({{.UserName}})\",\n }\n}\n\n```\n\n```json\n{\n \"action\": \"createbookmark\",\n \"settings\": {\n \"title\": \"{{env \\\"TITLE\\\"}}\",\n \"description\": \"This bookmark contains some interesting selections\"\n }\n}\n```\n\n```json\n{\n \"action\": \"setscriptvar\",\n \"settings\": {\n \"name\": \"BookmarkCounter\",\n \"type\": \"int\",\n \"value\": \"1\"\n }\n},\n{\n \"action\": \"createbookmark\",\n \"settings\": {\n \"title\": \"Bookmark no {{ add .ScriptVars.BookmarkCounter 1 }}\",\n \"description\": \"This bookmark will have the title Bookmark no 2\"\n }\n}\n```\n\n```json\n{\n \"action\": \"setscriptvar\",\n \"settings\": {\n \"name\": \"MyAppId\",\n \"type\": \"string\",\n \"value\": \"{{.Artifacts.GetIDByTypeAndName \\\"app\\\" (print \\\"an-app-\\\" .Session)}}\"\n }\n}\n```\n\nLet's assume the case there are 4 apps to be used in the test, all ending with number 0 to 3. The use of modulo in the example will cycle through the app suffix number in following order: 1, 2, 3, 0.\n\n```json\n{\n \"action\": \"elastictriggersubscription\",\n \"label\": \"trigger reporting task\",\n \"settings\": {\n \"subscriptiontype\": \"template-sharing\",\n \"limitperpage\": 100,\n \"appname\": \"PS-18566_Test_Levels_Pages- {{ modulo .Session 4}}\",\n \"subscriptionmode\": \"random\",\n }\n}\n```\n\nVery similar case as above but apps have number suffix from 1 to 4. This can be handled combining `modulo` and `add` functions. The cycle through the suffix number will be done in following order: 2, 3, 4, 1.\n```json\n{\n \"action\": \"elastictriggersubscription\",\n \"label\": \"trigger reporting task\",\n \"settings\": {\n \"subscriptiontype\": \"template-sharing\",\n \"limitperpage\": 100,\n \"appname\": \"PS-18566_Test_Levels_Pages- {{ modulo .Session 4 | add 1 }}\",\n \"subscriptionmode\": \"random\",\n }\n}\n```\n\n
\n", + Description: "\n## Session variables\n\nThis section describes the session variables that can be used with some of the actions.\n\nSome action parameters support session variables. A session variable is defined by putting the variable, prefixed by a dot, within double curly brackets, such as `{{.UserName}}`.\n\nThe following session variables are supported in actions:\n\n* `UserName`: The simulated username. This is not the same as the authenticated user, but rather how the username was defined by [Login settings](#login_settings). \n* `Session`: The enumeration of the currently simulated session.\n* `Thread`: The enumeration of the currently simulated \"thread\" or \"concurrent user\".\n* `ScriptVars`: A map containing script variables added by the action `setscriptvar`.\n* `Artifacts`:\n * `GetIDByTypeAndName`: A function that accepts the two string arguments,\n `artifactType` and `artifactName`, and returns the resource id of the artifact.\n * `GetNameByTypeAndID`: A function that accepts the two string arguments,\n `artifactType` and `artifactID`, and returns the name of the artifact.\n\n\nThe following variable is supported in the filename of the log file:\n\n* `ConfigFile`: The filename of the config file, without file extension.\n\nThe following functions are supported:\n\n* `now`: Evaluates Golang [time.Now()](https://golang.org/pkg/time/). \n* `hostname`: Hostname of the local machine.\n* `timestamp`: Timestamp in `yyyyMMddhhmmss` format.\n* `uuid`: Generate an uuid.\n* `env`: Retrieve a specific environment variable. Takes one argument - the name of the environment variable to expand.\n* `add`: Adds two integer values together and outputs the sum. E.g. `{{ add 1 2 }}`.\n* `join`: Joins array elements together to a string separated by defined separator. E.g. `{{ join .ScriptVars.MyArray \\\",\\\" }}`.\n* `modulo`: Returns modulo of two integer values and output the result. E.g. `{{ modulo 10 4 }}` (will return 2)\n\n### Example\n\n```json\n{\n \"label\" : \"Create bookmark\",\n \"action\": \"createbookmark\",\n \"settings\": {\n \"title\": \"my bookmark {{.Thread}}-{{.Session}} ({{.UserName}})\",\n \"description\": \"This bookmark contains some interesting selections\"\n }\n},\n{\n \"label\" : \"Publish created bookmark\",\n \"action\": \"publishbookmark\",\n \"disabled\" : false,\n \"settings\" : {\n \"title\": \"my bookmark {{.Thread}}-{{.Session}} ({{.UserName}})\",\n }\n}\n\n```\n\n```json\n{\n \"action\": \"createbookmark\",\n \"settings\": {\n \"title\": \"{{env \\\"TITLE\\\"}}\",\n \"description\": \"This bookmark contains some interesting selections\"\n }\n}\n```\n\n```json\n{\n \"action\": \"setscriptvar\",\n \"settings\": {\n \"name\": \"BookmarkCounter\",\n \"type\": \"int\",\n \"value\": \"1\"\n }\n},\n{\n \"action\": \"createbookmark\",\n \"settings\": {\n \"title\": \"Bookmark no {{ add .ScriptVars.BookmarkCounter 1 }}\",\n \"description\": \"This bookmark will have the title Bookmark no 2\"\n }\n}\n```\n\n```json\n{\n \"action\": \"setscriptvar\",\n \"settings\": {\n \"name\": \"MyAppId\",\n \"type\": \"string\",\n \"value\": \"{{.Artifacts.GetIDByTypeAndName \\\"app\\\" (print \\\"an-app-\\\" .Session)}}\"\n }\n}\n```\n\nLet's assume the case there are 4 apps to be used in the test, all ending with number 0 to 3. The use of modulo in the example will cycle through the app suffix number in following order: 1, 2, 3, 0.\n\n```json\n{\n \"action\": \"elastictriggersubscription\",\n \"label\": \"trigger reporting task\",\n \"settings\": {\n \"subscriptiontype\": \"template-sharing\",\n \"limitperpage\": 100,\n \"appname\": \"PS-18566_Test_Levels_Pages- {{ modulo .Session 4}}\",\n \"subscriptionmode\": \"random\",\n }\n}\n```\n\nVery similar case as above but apps have number suffix from 1 to 4. This can be handled combining `modulo` and `add` functions. The cycle through the suffix number will be done in following order: 2, 3, 4, 1.\n```json\n{\n \"action\": \"elastictriggersubscription\",\n \"label\": \"trigger reporting task\",\n \"settings\": {\n \"subscriptiontype\": \"template-sharing\",\n \"limitperpage\": 100,\n \"appname\": \"PS-18566_Test_Levels_Pages- {{ modulo .Session 4 | add 1 }}\",\n \"subscriptionmode\": \"random\",\n }\n}\n```\n", Examples: "", }, } diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 26e81af9..da792049 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -186,9 +186,6 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { if _, err := configfile.WriteString(configEntry.Description); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := configfile.WriteString("## Sections\n\n"); err != nil { - common.Exit(err, ExitCodeFailedWriteResult) - } configSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, GeneratedFolder))) defer func() { @@ -203,26 +200,42 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } + // TODO remove extra expander in session variable section + docEntry, ok := compiledDocs.Extra[SessionVariableName] + if !ok { + common.Exit(fmt.Errorf("\"Extra\" section<%s> not found", SessionVariableName), ExitCodeFailedReadTemplate) + } + filename := fmt.Sprintf("%s/%s/%s.md", wiki, GeneratedFolder, SessionVariableName) + if verbose { + fmt.Printf("creating file<%s>...\n", filename) + } + if err := os.WriteFile(filename, []byte(DocEntry(docEntry).String()), os.ModePerm); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + + linkString := fmt.Sprintf("[%s](%s)\n\n", SessionVariableName, SessionVariableName) + if _, err := configfile.WriteString(fmt.Sprintf("\nSome settings support the use of %s", linkString)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + if _, err := configSidebar.WriteString(fmt.Sprintf(" - %s", linkString)); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + + if _, err := configfile.WriteString(configEntry.Examples); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + + if _, err := configfile.WriteString("\n\n## Sections\n\n"); err != nil { + common.Exit(err, ExitCodeFailedWriteResult) + } + configFields, err := common.Fields() if err != nil { common.Exit(err, ExitCodeFailedHandleFields) } - configFields[SessionVariableName] = struct{}{} // TODO adding here for now, but figure out best placement in link structure for _, name := range sortedKeys(configFields) { var section ConfigSection switch name { - case SessionVariableName: - // TODO remove extra expander in session variable section - docEntry, ok := compiledDocs.Extra[SessionVariableName] - if !ok { - common.Exit(fmt.Errorf("\"Extra\" section<%s> not found", SessionVariableName), ExitCodeFailedReadTemplate) - } - section = ConfigSection{ - Data: DocEntry(docEntry).String(), - FilePath: fmt.Sprintf("%s/%s/%s.md", wiki, GeneratedFolder, SessionVariableName), - LinkTitle: SessionVariableName, - LinkName: SessionVariableName, - } case "scenario": if _, err := configSidebar.WriteString(" - [scenario](groups)\n\n"); err != nil { common.Exit(err, ExitCodeFailedWriteResult) @@ -299,10 +312,6 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } } - - if _, err := configfile.WriteString(configEntry.Examples); err != nil { - common.Exit(err, ExitCodeFailedWriteResult) - } } func generateWikiGroups(compiledDocs *CompiledDocs) map[string]string { From d1d8b4998fa28135cb573f70779c5c5cacce68ad Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 12:55:17 +0100 Subject: [PATCH 12/46] hack to fix link in session variables section --- generatedocs/pkg/genmd/generate.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index da792049..7e088ce4 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -9,6 +9,7 @@ import ( "path/filepath" "slices" "sort" + "strings" "github.com/qlik-oss/gopherciser/generatedocs/pkg/common" ) @@ -209,6 +210,8 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { if verbose { fmt.Printf("creating file<%s>...\n", filename) } + // hack to fix login_settings link for wiki, remove if settingup.md generation is no longer used + docEntry.Description = strings.Replace(docEntry.Description, "#login_settings", "loginSettings", 1) if err := os.WriteFile(filename, []byte(DocEntry(docEntry).String()), os.ModePerm); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } From a9b6e72f8a6c196213d05683083f0e200dce221c Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 14:04:41 +0100 Subject: [PATCH 13/46] add wiki as submodule and add entry to readme --- .gitmodules | 3 +++ README.md | 14 ++++++++++++++ gopherciser.wiki | 1 + 3 files changed, 18 insertions(+) create mode 100644 .gitmodules create mode 160000 gopherciser.wiki diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..00a225d1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "gopherciser.wiki"] + path = gopherciser.wiki + url = git@github.com:qlik-oss/gopherciser.wiki.git diff --git a/README.md b/README.md index 00ad2365..ea6c1311 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,20 @@ Gopherciser can run standalone, but is also included in the Qlik Sense Enterpris For more information on how to perform load testing with Gopherciser see the [wiki](https://github.com/qlik-oss/gopherciser/wiki/introduction), this readme documents building and development of gopherciser. +## Cloning repo + +This repo contains the wiki as a submodule, to clone sub modules when cloning the project + +```bash +git clone --recurse-submodules git@github.com:qlik-oss/gopherciser.wiki.git +``` + +If repo was cloned manually, the wiki submodule can be checkd out using + +```bash +git submodule update --init --recursive +``` + ## Building Gopherciser ### Prerequisites diff --git a/gopherciser.wiki b/gopherciser.wiki new file mode 160000 index 00000000..fc7a0ef3 --- /dev/null +++ b/gopherciser.wiki @@ -0,0 +1 @@ +Subproject commit fc7a0ef3a67b5a28da5e354f076e4d66aa29756b From 0cf46710c6b8d7eb1917ea2a5b3c4b4d1618a648 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 14:15:48 +0100 Subject: [PATCH 14/46] update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ea6c1311..f3273002 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ If repo was cloned manually, the wiki submodule can be checkd out using git submodule update --init --recursive ``` +**Note** the submodule will by default be in it's `master` branch. Any changes done and pushed in the submodule master branch will instantly update the wiki (i.e. don't make changes intended for a PR directly here). + ## Building Gopherciser ### Prerequisites From 0413dfb1945cedbbe51c5e53bb14596fad1d433a Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 15:41:44 +0100 Subject: [PATCH 15/46] add make command to generate wiki --- Makefile | 10 ++++++++++ README.md | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/Makefile b/Makefile index 4fb582ef..9327f257 100644 --- a/Makefile +++ b/Makefile @@ -63,3 +63,13 @@ alltests: # Run quickbuild test and linting. Good to run e.g. before pushing to remote verify: quickbuild test lint-min + +# init submodule and get latest +initwiki: + git submodule update --init --recursive + +# generate config and action documenation +genwiki: initwiki + set -e + go generate + go run ./generatedocs/cmd/generatemarkdown --wiki ./gopherciser.wiki \ No newline at end of file diff --git a/README.md b/README.md index f3273002..63f11193 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,17 @@ If you use Git Bash, but do not have `make.exe` installed, do the following to i #### Building the documentation The documentation can be generated from json with: + ```bash go generate ``` + +To generate wiki run + +```bash +make genwiki +``` + For more information, see [Generating Gopherciser documentation](./generatedocs/README.md). ### Build commands From 0ebb461a3f63d0782adfc8dff6b1dd9732c9a225 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 16:29:15 +0100 Subject: [PATCH 16/46] give error generation wiki if ungrouped action exists --- generatedocs/pkg/common/common.go | 3 ++- generatedocs/pkg/genmd/generate.go | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/generatedocs/pkg/common/common.go b/generatedocs/pkg/common/common.go index 71577520..68810fa3 100644 --- a/generatedocs/pkg/common/common.go +++ b/generatedocs/pkg/common/common.go @@ -1,6 +1,7 @@ package common import ( + "fmt" "os" "reflect" "sort" @@ -167,7 +168,7 @@ func ReadFile(path string) ([]byte, error) { // Exit prints errors message and exits program with code func Exit(err error, code int) { - _, _ = os.Stderr.WriteString(err.Error()) + _, _ = os.Stderr.WriteString(fmt.Sprintf("%v\n", err.Error())) os.Exit(code) } diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 7e088ce4..84e6ae1b 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -123,6 +123,7 @@ const ( ExitCodeFailedWriteResult ExitCodeFailedHandleFields ExitCodeFailedHandleParams + ExitCodeFailedHandleGroups ExitCodeFailedCreateFolder ExitCodeFailedDeleteFolder ExitCodeFailedDeleteFile @@ -160,19 +161,25 @@ func generateFullMarkdownFromCompiled(compiledDocs *CompiledDocs) []byte { } func generateWikiFromCompiled(compiledDocs *CompiledDocs) { - // TODO warning (error?)for ungrouped - + if verbose { + fmt.Printf("creating %s...\n", GeneratedFolder) + } if err := createFolder(filepath.Join(wiki, GeneratedFolder), true); err != nil { common.Exit(err, ExitCodeFailedCreateFolder) } - if verbose { - fmt.Println("creating groups sidebar...") + + ungroupedActions := UngroupedActions(compiledDocs.Groups) + if len(ungroupedActions) > 0 { + common.Exit(fmt.Errorf("found ungrouped actions, add this to a group: %s", strings.Join(ungroupedActions, ",")), ExitCodeFailedHandleGroups) } generateWikiConfigSections(compiledDocs) } func generateWikiConfigSections(compiledDocs *CompiledDocs) { + if verbose { + fmt.Println("creating config.md...") + } configfile, err := os.Create(fmt.Sprintf("%s/config.md", filepath.Join(wiki, GeneratedFolder))) defer func() { if err := configfile.Close(); err != nil { @@ -188,6 +195,9 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } + if verbose { + fmt.Println("creating config sidebar...") + } configSidebar, err := os.Create(fmt.Sprintf("%s/_Sidebar.md", filepath.Join(wiki, GeneratedFolder))) defer func() { if err := configSidebar.Close(); err != nil { @@ -201,7 +211,6 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } - // TODO remove extra expander in session variable section docEntry, ok := compiledDocs.Extra[SessionVariableName] if !ok { common.Exit(fmt.Errorf("\"Extra\" section<%s> not found", SessionVariableName), ExitCodeFailedReadTemplate) From 3be2e5c9c24d09c73fe5257e263f9bf025e4f8a7 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 16:31:08 +0100 Subject: [PATCH 17/46] remove generation of settingup.json via go generate --- docs/settingup.md | 2821 +-------------------------------------------- main.go | 3 - 2 files changed, 1 insertion(+), 2823 deletions(-) diff --git a/docs/settingup.md b/docs/settingup.md index b4ac2c06..7643c48b 100644 --- a/docs/settingup.md +++ b/docs/settingup.md @@ -1,2820 +1 @@ -# Setting up load scenarios - -A load scenario is defined in a JSON file with a number of sections. - - -
-Example - -```json -{ - "settings": { - "timeout": 300, - "logs": { - "filename": "scenarioresult.tsv" - }, - "outputs": { - "dir": "" - } - }, - "loginSettings": { - "type": "prefix", - "settings": { - "prefix": "testuser" - } - }, - "connectionSettings": { - "mode": "ws", - "server": "localhost", - "virtualproxy": "header", - "security": true, - "allowuntrusted": true, - "headers": { - "Qlik-User-Header": "{{.UserName}}" - } - }, - "scheduler": { - "type": "simple", - "iterationtimebuffer": { - "mode": "onerror", - "duration": "10s" - }, - "instance": 1, - "reconnectsettings": { - "reconnect": false, - "backoff": null - }, - "settings": { - "executionTime": -1, - "iterations": 10, - "rampupDelay": 7, - "concurrentUsers": 10, - "reuseUsers": false, - "onlyinstanceseed": false - } - }, - "scenario": [ - { - "action": "openhub", - "label": "open hub", - "disabled": false, - "settings": {} - }, - { - "action": "thinktime", - "label": "think for 10-15s", - "disabled": false, - "settings": { - "type": "uniform", - "mean": 15, - "dev": 5 - } - }, - { - "action": "openapp", - "label": "open app", - "disabled": false, - "settings": { - "appmode": "name", - "app": "myapp", - "filename": "", - "unique": false - } - }, - { - "action": "thinktime", - "label": "think for 10-15s", - "disabled": false, - "settings": { - "type": "uniform", - "mean": 15, - "dev": 5 - } - }, - { - "action": "changesheet", - "label": "change sheet to analysis sheet", - "disabled": false, - "settings": { - "id": "QWERTY" - } - }, - { - "action": "thinktime", - "label": "think for 10-15s", - "disabled": false, - "settings": { - "type": "uniform", - "mean": 15, - "dev": 5 - } - }, - { - "action": "select", - "label": "select 1-10 values in object uvxyz", - "disabled": false, - "settings": { - "id": "uvxyz", - "type": "randomfromenabled", - "accept": false, - "wrap": false, - "min": 1, - "max": 10, - "dim": 0, - "values": null - } - } - ] -} -``` - -
-
-connectionSettings - -## Connection settings section - -This section of the JSON file contains connection information. - -JSON Web Token (JWT), an open standard for creation of access tokens, or WebSocket can be used for authentication. When using JWT, the private key must be available in the path defined by `jwtsettings.keypath`. - -* `mode`: Authentication mode - * `jwt`: JSON Web Token - * `ws`: WebSocket -* `jwtsettings`: (JWT only) Settings for the JWT connection. - * `keypath`: Local path to the JWT key file. - * `jwtheader`: JWT headers as an escaped JSON string. Custom headers to be added to the JWT header. - * `claims`: JWT claims as an escaped JSON string. - * `alg`: The signing method used for the JWT. Defaults to `RS512`, if omitted. - * For keyfiles in RSA format, supports `RS256`, `RS384` or `RS512`. - * For keyfiles in EC format, supports `ES256`, `ES384` or `ES512`. -* `wssettings`: (WebSocket only) Settings for the WebSocket connection. -* `server`: Qlik Sense host. -* `virtualproxy`: Prefix for the virtual proxy that handles the virtual users. -* `rawurl`: Define the connect URL manually instead letting the `openapp` action do it. **Note**: The protocol must be `wss://` or `ws://`. -* `port`: Set another port than default (`80` for http and `443` for https). -* `security`: Use TLS (SSL) (`true` / `false`). -* `allowuntrusted`: Allow untrusted (for example, self-signed) certificates (`true` / `false`). Defaults to `false`, if omitted. -* `appext`: Replace `app` in the connect URL for the `openapp` action. Defaults to `app`, if omitted. -* `headers`: Headers to use in requests. -* `maxframesize`: (Default 0 - No limit). Max size in bytes allowed to be read on sense websocket. - -### Examples - -#### JWT authentication - -```json -"connectionSettings": { - "server": "myserver.com", - "mode": "jwt", - "virtualproxy": "jwt", - "security": true, - "allowuntrusted": false, - "jwtsettings": { - "keypath": "mock.pem", - "claims": "{\"user\":\"{{.UserName}}\",\"directory\":\"{{.Directory}}\"}" - } -} -``` - -* `jwtsettings`: - -The strings for `reqheader`, `jwtheader` and `claims` are processed as a GO template where the `User` struct can be used as data: -```golang -struct { - UserName string - Password string - Directory string - } -``` -There is also support for the `time.Now` method using the function `now`. - -* `jwtheader`: - -The entries for message authentication code algorithm, `alg`, and token type, `typ`, are added automatically to the header and should not be included. - -**Example:** To add a key ID header, `kid`, add the following string: -```json -{ - "jwtheader": "{\"kid\":\"myKeyId\"}" -} -``` - -* `claims`: - -**Example:** For on-premise JWT authentication (with the user and directory set as keys in the QMC), add the following string: -```json -{ - "claims": "{\"user\": \"{{.UserName}}\",\"directory\": \"{{.Directory}}\"}" -} -``` -**Example:** To add the time at which the JWT was issued, `iat` ("issued at"), add the following string: -```json -{ - "claims": "{\"iat\":{{now.Unix}}" -} -``` -**Example:** To add the expiration time, `exp`, with 5 hours expiration (time.Now uses nanoseconds), add the following string: -```json -{ - "claims": "{\"exp\":{{(now.Add 18000000000000).Unix}}}" -} -``` - -#### Static header authentication - -```json -connectionSettings": { - "server": "myserver.com", - "mode": "ws", - "security": true, - "virtualproxy" : "header", - "headers" : { - "X-Sense-User" : "{{.UserName}}" -} -``` - -
- -
-hooks - -## Hooks section - -This section contains the possibility to define hooks, which will send requests to a defined endpoint before and after a test execution. - -* `preexecute`: Pre execution hook. Can be used to send a request to an endpoint before a test starts. - * `url`: Url to send a request towards. - * `method`: Method of request, defaults to none. - * `payload`: (optional) Content of request. - * `respcodes`: Accepted response codes, defaults to 200. - * `contenttype`: Request content-type header. Defaults to application/json. - * `extractors`: Extractors, can be used to extract a value from the response to be used on subsequent hook, or to validate that a that part of a response has a specific value. - * `Name`: Name of extractor, this name is what is later used to when accessing the extracted data in a template such as {{ .Vars.MyExtractorName }}. - * `path`: Path to data to extract, e.g. /id to extract the data my-id from from a parameter *id* in JSON root. - * `faillevel`: Defines how to report data extraction or validation failure. - * `none`: Do nothing. - * `info`: Log an info log row. - * `warning`: Log a warning log row. - * `error`: Log a error row and abort script. - * `validator`: Validate that part of the response has a specific value - * `type`: Value should be of this type. - * `none`: Default type, no validation of value will be done. - * `bool`: Value should be a boolean. - * `number`: Value should be a number. - * `string`: Value should be a string. - * `value`: Validate the value is exactly equal to this. - * `headers`: Custom headers to add to the request. - * `name`: Name of header. - * `value`: Value of header. -* `postexecute`: Post execution hook. Can be used to send a request to an endpoint after a test is done. - * `url`: Url to send a request towards. - * `method`: Method of request, defaults to none. - * `payload`: (optional) Content of request. - * `respcodes`: Accepted response codes, defaults to 200. - * `contenttype`: Request content-type header. Defaults to application/json. - * `extractors`: Extractors, can be used to extract a value from the response to be used on subsequent hook, or to validate that a that part of a response has a specific value. - * `Name`: Name of extractor, this name is what is later used to when accessing the extracted data in a template such as {{ .Vars.MyExtractorName }}. - * `path`: Path to data to extract, e.g. /id to extract the data my-id from from a parameter *id* in JSON root. - * `faillevel`: Defines how to report data extraction or validation failure. - * `none`: Do nothing. - * `info`: Log an info log row. - * `warning`: Log a warning log row. - * `error`: Log a error row and abort script. - * `validator`: Validate that part of the response has a specific value - * `type`: Value should be of this type. - * `none`: Default type, no validation of value will be done. - * `bool`: Value should be a boolean. - * `number`: Value should be a number. - * `string`: Value should be a string. - * `value`: Validate the value is exactly equal to this. - * `headers`: Custom headers to add to the request. - * `name`: Name of header. - * `value`: Value of header. - -### Example - -#### Send a request to slack that a test is starting. - -```json -"hooks": { - "preexecute": { - "url": "https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYYYY/ZZZZZZZZZZZZZZZZZZZZZZZZ", - "method": "POST", - "payload": "{ \"text\": \"Running test with {{ .Scheduler.ConcurrentUsers }} concurrent users and {{ .Scheduler.Iterations }} iterations towards {{ .ConnectionSettings.Server }}.\"}", - "contenttype": "application/json" - }, - "postexecute": { - "url": "https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYYYY/ZZZZZZZZZZZZZZZZZZZZZZZZ", - "method": "POST", - "payload": "{ \"text\": \"Test finished with {{ .Counters.Errors }} errors and {{ .Counters.Warnings }} warnings. Total Sessions: {{ .Counters.Sessions }}\"}" - } -} -``` - -This will send a message on test startup such as: - -```text -Running test with 10 concurrent users and 2 iterations towards MyServer.com. -``` - -And a message on test finished such as: - -```text -Test finished with 4 errors and 12 warnings. Total Sessions: 20. -``` - -#### Ask an endpoint before execution if test is ok to run - -```json -"hooks": { - "preexecute": { - "url": "http://myserver:8080/oktoexecute", - "method": "POST", - "headers": [ - { - "name" : "someheader", - "value": "headervalue" - } - ], - "payload": "{\"testID\": \"12345\",\"startAt\": \"{{now.Format \"2006-01-02T15:04:05Z07:00\"}}\"}", - "extractors": [ - { - "name": "oktorun", - "path" : "/oktorun", - "faillevel": "error", - "validator" : { - "type": "bool", - "value": "true" - } - } - ] - } -} -``` - -This will POST a request to `http://myserver:8080/oktoexecute` with the body: - -```json -{ - "testID": "12345", - "startAt": "2021-05-06T08:00:00Z01:00" -} -``` - -For a test started at `2021-05-06T08:00:00` in timezone UTC+1. - -Let's assume the response from this endpoint is: - -```json -{ - "oktorun": false -} -``` - -The validator with path `/oktorun` will extract the value `false` and compare to the value defined in the validator, in this case `true`. Since the they are not equal the test will stop with error before starting exection. - -
- -
-loginSettings - -## Login settings section - -This section of the JSON file contains information on the login settings. - -* `type`: Type of login request - * `prefix`: Add a prefix (specified by the `prefix` setting below) to the username, so that it will be `prefix_{session}`. - * `userlist`: List of users as specified by the `userList` setting below. - * `fromfile`: List of users from a file with 1 user per row and the format `username;directory;password` - * `none`: Do not add a prefix to the username, so that it will be `{session}`. -* `settings`: - * `userList`: List of users for the `userlist` login request type. Directory and password can be specified per user or outside the list of usernames, which means that they are inherited by all users. - * `filename`: Path to file with users. - * `prefix`: Prefix to add to the username, so that it will be `prefix_{session}`. - * `directory`: Directory to set for the users. - -### Examples - -#### Prefix login request type - -```json -"loginSettings": { - "type": "prefix", - "settings": { - "directory": "anydir", - "prefix": "Nunit" - } -} -``` - -#### Userlist login request type - -```json -"loginSettings": { - "type": "userlist", - "settings": { - "userList": [ - { - "username": "sim1@myhost.example", - "directory": "anydir1", - "password": "MyPassword1" - }, - { - "username": "sim2@myhost.example" - } - ], - "directory": "anydir2", - "password": "MyPassword2" - } -} -``` - -#### Fromfile login request type - -Reads a user list from file. 1 User per row of the and with the format `username;directory;password`. `directory` and `password` are optional, if none are defined for a user it will use the default values on settings (i.e. `defaultdir` and `defaultpassword`). If the used authentication type doesn't use `directory` or `password` these can be omitted. - -Definition with default values: - -```json -"loginSettings": { - "type": "fromfile", - "settings": { - "filename": "./myusers.txt", - "directory": "defaultdir", - "password": "defaultpassword" - } -} -``` - -Definition without default values: - -```json -"loginSettings": { - "type": "fromfile", - "settings": { - "filename": "./myusers.txt" - } -} -``` - -This is a valid format of a file. - -```text -testuser1 -testuser2;myspecialdirectory -testuser3;;somepassword -testuser4;specialdir;anotherpassword -testuser5;;A;d;v;a;n;c;e;d;;P;a;s;s;w;o;r;d; -``` - -*testuser1* will get default `directory` and `password`, *testuser3* and *testuser5* will get default `directory`. - -
- -
-scenario - -## Scenario section - -This section of the JSON file contains the actions that are performed in the load scenario. - -### Structure of an action entry - -All actions follow the same basic structure: - -* `action`: Name of the action to execute. -* `label`: (optional) Custom string set by the user. This can be used to distinguish the action from other actions of the same type when analyzing the test results. -* `disabled`: (optional) Disable action (`true` / `false`). If set to `true`, the action is not executed. -* `settings`: Most, but not all, actions have a settings section with action-specific settings. - -### Example - -```json -{ - "action": "actioname", - "label": "custom label for analysis purposes", - "disabled": false, - "settings": { - - } -} -``` - -
-Common actions - -# Common actions - -These actions are applicable for most types of Qlik Sense deployments. - -**Note:** It is recommended to prepend the actions listed here with an `openapp` action as most of them perform operations in an app context (such as making selections or changing sheets). - - -
-applybookmark - -## ApplyBookmark action - -Apply a bookmark in the current app. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. -* `selectionsonly`: Apply selections only. - -### Example - -```json -{ - "action": "applybookmark", - "settings": { - "title": "My bookmark" - } -} -``` - -
- -
-askhubadvisor - -## AskHubAdvisor action - -Perform a query in the Qlik Sense hub insight advisor. -* `querysource`: The source from which queries will be randomly picked. - * `file`: Read queries from file defined by `file`. - * `querylist`: Read queries from list defined by `querylist`. -* `querylist`: A list of queries. Plain strings are supported and will get a weight of `1`. - * `weight`: A weight to set probablility of query being peformed. - * `query`: A query sentence. -* `lang`: Query language. -* `maxfollowup`: The maximum depth of followup queries asked. A value of `0` means that a query from querysource is performed without followup queries. -* `file`: Path to query file. -* `app`: Optional name of app to pick in followup queries. If not set, a random app is picked. -* `saveimages`: Save images of charts to file. -* `saveimagefile`: File name of saved images. Defaults to server side file name. Supports [Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables). -* `thinktime`: Settings for the `thinktime` action, which is automatically inserted before each followup. Defaults to a uniform distribution with mean=8 and deviation=4. - * `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. - * `delay`: Delay (seconds), used with type `static`. - * `mean`: Mean (seconds), used with type `uniform`. - * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. -* `followuptypes`: A list of followup types enabled for followup queries. If omitted, all types are enabled. - * `app`: Enable followup queries which change app. - * `measure`: Enable followups based on measures. - * `dimension`: Enable followups based on dimensions. - * `recommendation`: Enable followups based on recommendations. - * `sentence`: Enable followup queries based on bare sentences. - -### Examples - -#### Pick queries from file - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "file", - "file": "queries.txt" - } -} -``` - -The file `queries.txt` contains one query and an optional weight per line. The line format is `[WEIGHT;]QUERY`. -```txt -show sales per country -5; what is the lowest price of shoes -``` - -#### Pick queries from list - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": ["show sales per country", "what is the lowest price of shoes"] - } -} -``` - -#### Perform followup queries if possible (default: 0) - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": ["show sales per country", "what is the lowest price of shoes"], - "maxfollowup": 3 - } -} -``` - -#### Change lanuage (default: "en") - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": ["show sales per country", "what is the lowest price of shoes"], - "lang": "fr" - } -} -``` - -#### Weights in querylist - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - { - "query": "show sales per country", - "weight": 5, - }, - "what is the lowest price of shoes" - ] - } -} -``` - -#### Thinktime before followup queries - -See detailed examples of settings in the documentation for thinktime action. - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "what is the lowest price of shoes" - ], - "maxfollowup": 5, - "thinktime": { - "type": "static", - "delay": 5 - } - } -} -``` - -#### Ask followups only based on app selection - - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "what is the lowest price of shoes" - ], - "maxfollowup": 5, - "followuptypes": ["app"] - } -} -``` - -#### Save chart images to file - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "show price per shoe type" - ], - "maxfollowup": 5, - "saveimages": true - } -} -``` - -#### Save chart images to file with custom name - -The `saveimagefile` file name template setting supports -[Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables). -You can apart from session variables include the following action local variables in the `saveimagefile` file name template: -- .Local.ImageCount - _the number of images written to file_ -- .Local.ServerFileName - _the server side name of image file_ -- .Local.Query - _the query sentence_ -- .Local.AppName - _the name of app, if any app, where query is asked_ -- .Local.AppID - _the id of app, if any app, where query is asked_ - -```json -{ - "action": "AskHubAdvisor", - "settings": { - "querysource": "querylist", - "querylist": [ - "show price per shoe type" - ], - "maxfollowup": 5, - "saveimages": true, - "saveimagefile": "{{.Local.Query}}--app-{{.Local.AppName}}--user-{{.UserName}}--thread-{{.Thread}}--session-{{.Session}}" - } -} -``` - -
- -
-changesheet - -## ChangeSheet action - -Change to a new sheet, unsubscribe to the currently subscribed objects, and subscribe to all objects on the new sheet. - -The action supports getting data from the following objects: - -* Listbox -* Filter pane -* Bar chart -* Scatter plot -* Map (only the first layer) -* Combo chart -* Table -* Pivot table -* Line chart -* Pie chart -* Tree map -* Text-Image -* KPI -* Gauge -* Box plot -* Distribution plot -* Histogram -* Auto chart (including any support generated visualization from this list) -* Waterfall chart - -* `id`: GUID of the sheet to change to. - -### Example - -```json -{ - "label": "Change Sheet Dashboard", - "action": "ChangeSheet", - "settings": { - "id": "TFJhh" - } -} -``` - -
- -
-clearall - -## ClearAll action - -Clear all selections in an app. - - -### Example - -```json -{ - "action": "clearall", - "label": "Clear all selections (1)" -} -``` - -
- -
-clearfield - -## ClearField action - -Clear selections in a field. - -* `name`: Name of field to clear. - -### Example - -```json -{ - "action": "clearfield", - "label": "Clear selections in Alpha", - "settings" : { - "name": "Alpha" - } -} -``` - -
- -
-clickactionbutton - -## ClickActionButton action - -A `ClickActionButton`-action simulates clicking an _action-button_. An _action-button_ is a sheet item which, when clicked, executes a series of actions. The series of actions contained by an action-button begins with any number _generic button-actions_ and ends with an optional _navigation button-action_. - -### Supported button-actions -#### Generic button-actions -- Apply bookmark -- Move backward in all selections -- Move forward in all selections -- Lock all selections -- Clear all selections -- Lock field -- Unlock field -- Select all in field -- Select alternatives in field -- Select excluded in field -- Select possible in field -- Select values matching search criteria in field -- Clear selection in field -- Toggle selection in field -- Set value of variable - -#### Navigation button-actions -- Change to first sheet -- Change to last sheet -- Change to previous sheet -- Change sheet by name -- Change sheet by ID -* `id`: ID of the action-button to click. - -### Examples - -```json -{ - "label": "ClickActionButton", - "action": "ClickActionButton", - "settings": { - "id": "951e2eee-ad49-4f6a-bdfe-e9e3dddeb2cd" - } -} -``` - -
- -
-containertab - -## Containertab action - -A `Containertab` action simulates switching the active object in a `container` object. - -* `mode`: Mode for container tab switching, one of: `objectid`, `random` or `index`. - * `objectid`: Switch to tab with object defined by `objectid`. - * `random`: Switch to a random visible tab within the container. - * `index`: Switch to tab with zero based index defined but `index`. -* `containerid`: ID of the container object. -* `objectid`: ID of the object to set as active, used with mode `objectid`. -* `index`: Zero based index of tab to switch to, used with mode `index`. - -### Examples - -```json -{ - "label": "Switch to object qwerty in container object XYZ", - "action": "containertab", - "settings": { - "containerid": "xyz", - "mode": "id", - "objectid" : "qwerty" - } -} -``` - -```json -{ - "label": "Switch to random object in container object XYZ", - "action": "containertab", - "settings": { - "containerid": "xyz", - "mode": "random" - } -} -``` - -```json -{ - "label": "Switch to object in first tab in container object XYZ", - "action": "containertab", - "settings": { - "containerid": "xyz", - "mode": "index", - "index": 0 - } -} -``` - -
- -
-createbookmark - -## CreateBookmark action - -Create a bookmark from the current selection and selected sheet. - -**Note:** Both `title` and `id` can be used to identify the bookmark in subsequent actions. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. -* `description`: (optional) Description of the bookmark to create. -* `nosheet`: Do not include the sheet location in the bookmark. -* `savelayout`: Include the layout in the bookmark. - -### Example - -```json -{ - "action": "createbookmark", - "settings": { - "title": "my bookmark", - "description": "This bookmark contains some interesting selections" - } -} -``` - -
- -
-createsheet - -## CreateSheet action - -Create a new sheet in the current app. - -* `id`: (optional) ID to be used to identify the sheet in any subsequent `changesheet`, `duplicatesheet`, `publishsheet` or `unpublishsheet` action. -* `title`: Name of the sheet to create. -* `description`: (optional) Description of the sheet to create. - -### Example - -```json -{ - "action": "createsheet", - "settings": { - "title" : "Generated sheet" - } -} -``` - -
- -
-deletebookmark - -## DeleteBookmark action - -Delete one or more bookmarks in the current app. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. -* `mode`: - * `single`: Delete one bookmark that matches the specified `title` or `id` in the current app. - * `matching`: Delete all bookmarks with the specified `title` in the current app. - * `all`: Delete all bookmarks in the current app. - -### Example - -```json -{ - "action": "deletebookmark", - "settings": { - "mode": "single", - "title": "My bookmark" - } -} -``` - -
- -
-deletesheet - -## DeleteSheet action - -Delete one or more sheets in the current app. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `mode`: - * `single`: Delete one sheet that matches the specified `title` or `id` in the current app. - * `matching`: Delete all sheets with the specified `title` in the current app. - * `allunpublished`: Delete all unpublished sheets in the current app. -* `title`: (optional) Name of the sheet to delete. -* `id`: (optional) GUID of the sheet to delete. - -### Example - -```json -{ - "action": "deletesheet", - "settings": { - "mode": "matching", - "title": "Test sheet" - } -} -``` - -
- -
-disconnectapp - -## DisconnectApp action - -Disconnect from an already connected app. - - -### Example - -```json -{ - "label": "Disconnect from server", - "action" : "disconnectapp" -} -``` - -
- -
-disconnectenvironment - -## DisconnectEnvironment action - -Disconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environmentsor uses custom actions towards external environment, it should be used directly after the last action towards the environment. - -Since the action also disconnects any open websocket to Sense apps, it does not need to be preceeded with a `disconnectapp` action. - - -### Example - -```json -{ - "label": "Disconnect from environment", - "action" : "disconnectenvironment" -} -``` - -
- -
-dosave - -## DoSave action - -`DoSave` issues a command to engine to save the currently open app. If the simulated user does not have permission to save the app it will result in an error. - -### Example - -```json -{ - "label": "Save MyApp", - "action" : "dosave" -} -``` - -
- -
-duplicatesheet - -## DuplicateSheet action - -Duplicate a sheet, including all objects. - -* `id`: ID of the sheet to clone. -* `changesheet`: Clear the objects currently subscribed to and then subribe to all objects on the cloned sheet (which essentially corresponds to using the `changesheet` action to go to the cloned sheet) (`true` / `false`). Defaults to `false`, if omitted. -* `save`: Execute `saveobjects` after the cloning operation to save all modified objects (`true` / `false`). Defaults to `false`, if omitted. -* `cloneid`: (optional) ID to be used to identify the sheet in any subsequent `changesheet`, `duplicatesheet`, `publishsheet` or `unpublishsheet` action. - -### Example - -```json -{ - "action": "duplicatesheet", - "label": "Duplicate sheet1", - "settings":{ - "id" : "mBshXB", - "save": true, - "changesheet": true - } -} -``` - -
- -
-getscript - -## GetScript action - -Get the load script for the app. - - -* `savelog`: Save load script to log file under the INFO log labelled *LoadScript* - -### Example - -Get the load script for the app - -```json -{ - "action": "getscript" -} -``` - -Get the load script for the app and save to log file - -```json -{ - "action": "getscript", - "settings": { - "savelog" : true - } -} -``` - -
- -
-iterated - -## Iterated action - -Loop one or more actions. - -**Note:** This action does not require an app context (that is, it does not have to be prepended with an `openapp` action). - -* `iterations`: Number of loops. -* `actions`: Actions to iterate - * `action`: Name of the action to execute. - * `label`: (optional) Custom string set by the user. This can be used to distinguish the action from other actions of the same type when analyzing the test results. - * `disabled`: (optional) Disable action (`true` / `false`). If set to `true`, the action is not executed. - * `settings`: Most, but not all, actions have a settings section with action-specific settings. - -### Example - -```json -//Visit all sheets twice -{ - "action": "iterated", - "label": "", - "settings": { - "iterations" : 2, - "actions" : [ - { - "action": "sheetchanger" - }, - { - "action": "thinktime", - "settings": { - "type": "static", - "delay": 5 - } - } - ] - } -} -``` - -
- -
-listboxselect - -## ListBoxSelect action - -Perform list object specific selectiontypes in listbox. - - -* `id`: ID of the listbox in which to select values. -* `type`: Selection type. - * `all`: Select all values. - * `alternative`: Select alternative values. - * `excluded`: Select excluded values. - * `possible`: Select possible values. -* `accept`: Accept or abort selection after selection (only used with `wrap`) (`true` / `false`). -* `wrap`: Wrap selection with Begin / End selection requests (`true` / `false`). - -### Examples - -```json -{ - "label": "ListBoxSelect", - "action": "ListBoxSelect", - "settings": { - "id": "951e2eee-ad49-4f6a-bdfe-e9e3dddeb2cd", - "type": "all", - "wrap": true, - "accept": true - } -} -``` - -
- -
-objectsearch - -## ObjectSearch action - -Perform a search select in a listbox, field or master dimension. - - -* `id`: Identifier for the object, this would differ depending on `type`. - * `listbox`: Use the ID of listbox object - * `field`: Use the name of the field - * `dimension`: Use the title of the dimension masterobject. -* `searchterms`: List of search terms to search for. -* `type`: Type of object to search - * `listbox`: (Default) `id` is the ID of a listbox. - * `field`: `id` is the name of a field. - * `dimension`: `id` is the title of a master object dimension. -* `source`: Source of search terms - * `fromlist`: (Default) Use search terms from `searchterms` array. - * `fromfile`: Use search term from file defined by `searchtermsfile` -* `erroronempty`: If set to true and the object search yields an empty result, the action will result in an error. Defaults to false. -* `searchtermsfile`: Path to search terms file when using `source` of type `fromfile`. File should contain one term per row. - -### Examples - -Search a listbox object, all users searches for same thing and gets an error if no result found - -```json -{ - "label": "Search and select Sweden in listbox", - "action": "objectsearch", - "settings": { - "id": "maesVjgte", - "searchterms": ["Sweden"], - "type": "listbox", - "erroronempty": true - } -} -``` - -Search a field. Users use one random search term from the `searchterms` list. - -```json -{ - "label": "Search field", - "action": "objectsearch", - "disabled": false, - "settings": { - "id": "Countries", - "searchterms": [ - "Sweden", - "Germany", - "Liechtenstein" - ], - "type": "field" - } -} -``` - -Search a master object dimension using search terms from a file. - -```json -{ - "label": "Search dimension", - "action": "objectsearch", - "disabled": false, - "settings": { - "id": "Dim1M", - "type": "dimension", - "erroronempty": true, - "source": "fromfile", - "searchtermsfile": "./resources/objectsearchterms.txt" - } -} -``` - -
- -
-openapp - -## OpenApp action - -Open an app. - -**Note:** If the app name is used to specify which app to open, this action cannot be the first action in the scenario. It must be preceded by an action that can populate the artifact map, such as `openhub`. - -* `appmode`: App selection mode - * `current`: (default) Use the current app, selected by an app selection in a previous action - * `guid`: Use the app GUID specified by the `app` parameter. - * `name`: Use the app name specified by the `app` parameter. - * `random`: Select a random app from the artifact map, which is filled by e.g. `openhub` - * `randomnamefromlist`: Select a random app from a list of app names. The `list` parameter should contain a list of app names. - * `randomguidfromlist`: Select a random app from a list of app GUIDs. The `list` parameter should contain a list of app GUIDs. - * `randomnamefromfile`: Select a random app from a file with app names. The `filename` parameter should contain the path to a file in which each line represents an app name. - * `randomguidfromfile`: Select a random app from a file with app GUIDs. The `filename` parameter should contain the path to a file in which each line represents an app GUID. - * `round`: Select an app from the artifact map according to the round-robin principle. - * `roundnamefromlist`: Select an app from a list of app names according to the round-robin principle. The `list` parameter should contain a list of app names. - * `roundguidfromlist`: Select an app from a list of app GUIDs according to the round-robin principle. The `list` parameter should contain a list of app GUIDs. - * `roundnamefromfile`: Select an app from a file with app names according to the round-robin principle. The `filename` parameter should contain the path to a file in which each line represents an app name. - * `roundguidfromfile`: Select an app from a file with app GUIDs according to the round-robin principle. The `filename` parameter should contain the path to a file in which each line represents an app GUID. -* `app`: App name or app GUID (supports the use of [session variables](#session_variables)). Used with `appmode` set to `guid` or `name`. -* `list`: List of apps. Used with `appmode` set to `randomnamefromlist`, `randomguidfromlist`, `roundnamefromlist` or `roundguidfromlist`. -* `filename`: Path to a file in which each line represents an app. Used with `appmode` set to `randomnamefromfile`, `randomguidfromfile`, `roundnamefromfile` or `roundguidfromfile`. -* `externalhost`: (optional) Sets an external host to be used instead of `server` configured in connection settings. -* `unique`: Create unqiue engine session not re-using session from previous connection with same user. Defaults to false. - -### Examples - -```json -{ - "label": "OpenApp", - "action": "OpenApp", - "settings": { - "appmode": "guid", - "app": "7967af99-68b6-464a-86de-81de8937dd56" - } -} -``` -```json -{ - "label": "OpenApp", - "action": "OpenApp", - "settings": { - "appmode": "randomguidfromlist", - "list": ["7967af99-68b6-464a-86de-81de8937dd56", "ca1a9720-0f42-48e5-baa5-597dd11b6cad"] - } -} -``` - -
- -
-productversion - -## ProductVersion action - -Request the product version from the server and, optionally, save it to the log. This is a lightweight request that can be used as a keep-alive message in a loop. - -* `log`: Save the product version to the log (`true` / `false`). Defaults to `false`, if omitted. - -### Example - -```json -//Keep-alive loop -{ - "action": "iterated", - "settings" : { - "iterations" : 10, - "actions" : [ - { - "action" : "productversion" - }, - { - "action": "thinktime", - "settings": { - "type": "static", - "delay": 30 - } - } - ] - } -} -``` - -
- -
-publishbookmark - -## PublishBookmark action - -Publish a bookmark. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. - -### Example - -Publish the bookmark with `id` "bookmark1" that was created earlier on in the script. - -```json -{ - "label" : "Publish bookmark 1", - "action": "publishbookmark", - "disabled" : false, - "settings" : { - "id" : "bookmark1" - } -} -``` - -Publish the bookmark with the `title` "bookmark of testuser", where "testuser" is the username of the simulated user. - -```json -{ - "label" : "Publish bookmark 2", - "action": "publishbookmark", - "disabled" : false, - "settings" : { - "title" : "bookmark of {{.UserName}}" - } -} -``` - -
- -
-publishsheet - -## PublishSheet action - -Publish sheets in the current app. - -* `mode`: - * `allsheets`: Publish all sheets in the app. - * `sheetids`: Only publish the sheets specified by the `sheetIds` array. -* `sheetIds`: (optional) Array of sheet IDs for the `sheetids` mode. -* `includePublished`: Try to publish already published sheets. - -### Example -```json -{ - "label": "PublishSheets", - "action": "publishsheet", - "settings": { - "mode": "sheetids", - "sheetIds": ["qmGcYS", "bKbmgT"] - } -} -``` - -
- -
-randomaction - -## RandomAction action - -Randomly select other actions to perform. This meta-action can be used as a starting point for your testing efforts, to simplify script authoring or to add background load. - -`randomaction` accepts a list of action types between which to randomize. An execution of `randomaction` executes one or more of the listed actions (as determined by the `iterations` parameter), randomly chosen by a weighted probability. If nothing else is specified, each action has a default random mode that is used. An override is done by specifying one or more parameters of the original action. - -Each action executed by `randomaction` is followed by a customizable `thinktime`. - -**Note:** The recommended way to use this action is to prepend it with an `openapp` and a `changesheet` action as this ensures that a sheet is always in context. - -* `actions`: List of actions from which to randomly pick an action to execute. Each item has a number of possible parameters. - * `type`: Type of action - * `thinktime`: See the `thinktime` action. - * `sheetobjectselection`: Make random selections within objects visible on the current sheet. See the `select` action. - * `changesheet`: See the `changesheet` action. - * `clearall`: See the `clearall` action. - * `weight`: The probabilistic weight of the action, specified as an integer. This number is proportional to the likelihood of the specified action, and is used as a weight in a uniform random selection. - * `overrides`: (optional) Static overrides to the action. The overrides can include any or all of the settings from the original action, as determined by the `type` field. If nothing is specified, the default values are used. -* `thinktimesettings`: Settings for the `thinktime` action, which is automatically inserted after every randomized action. - * `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. - * `delay`: Delay (seconds), used with type `static`. - * `mean`: Mean (seconds), used with type `uniform`. - * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. -* `iterations`: Number of random actions to perform. - -### Random action defaults - -The following default values are used for the different actions: - -* `thinktime`: Mirrors the configuration of `thinktimesettings` -* `sheetobjectselection`: - -```json -{ - "settings": - { - "id": , - "type": "RandomFromAll", - "min": 1, - "max": 2, - "accept": true - } -} -``` - -* `changesheet`: - -```json -{ - "settings": - { - "id": - } -} -``` - -* `clearall`: - -```json -{ - "settings": - { - } -} -``` - -### Examples - -#### Generating a background load by executing 5 random actions - -```json -{ - "action": "RandomAction", - "settings": { - "iterations": 5, - "actions": [ - { - "type": "thinktime", - "weight": 1 - }, - { - "type": "sheetobjectselection", - "weight": 3 - }, - { - "type": "changesheet", - "weight": 5 - }, - { - "type": "clearall", - "weight": 1 - } - ], - "thinktimesettings": { - "type": "uniform", - "mean": 10, - "dev": 5 - } - } -} -``` - -#### Making random selections from excluded values - -```json -{ - "action": "RandomAction", - "settings": { - "iterations": 1, - "actions": [ - { - "type": "sheetobjectselection", - "weight": 1, - "overrides": { - "type": "RandomFromExcluded", - "min": 1, - "max": 5 - } - } - ], - "thinktimesettings": { - "type": "static", - "delay": 1 - } - } -} -``` - -
- -
-reload - -## Reload action - -Reload the current app by simulating selecting **Load data** in the Data load editor. To select an app, preceed this action with an `openapp` action. - -* `mode`: Error handling during the reload operation - * `default`: Use the default error handling. - * `abend`: Stop reloading the script, if an error occurs. - * `ignore`: Continue reloading the script even if an error is detected in the script. -* `partial`: Enable partial reload (`true` / `false`). This allows you to add data to an app without reloading all data. Defaults to `false`, if omitted. -* `log`: Save the reload log as a field in the output (`true` / `false`). Defaults to `false`, if omitted. **Note:** This should only be used when needed as the reload log can become very large. -* `nosave`: Do not send a save request for the app after the reload is done. Defaults to saving the app. - -### Example - -```json -{ - "action": "reload", - "settings": { - "mode" : "default", - "partial": false - } -} -``` - -
- -
-select - -## Select action - -Select random values in an object. - -See the [Limitations](README.md#limitations) section in the README.md file for limitations related to this action. - -* `id`: ID of the object in which to select values. -* `type`: Selection type - * `randomfromall`: Randomly select within all values of the symbol table. - * `randomfromenabled`: Randomly select within the white and light grey values on the first data page. - * `randomfromexcluded`: Randomly select within the dark grey values on the first data page. - * `randomdeselect`: Randomly deselect values on the first data page. - * `values`: Select specific element values, defined by `values` array. -* `accept`: Accept or abort selection after selection (only used with `wrap`) (`true` / `false`). -* `wrap`: Wrap selection with Begin / End selection requests (`true` / `false`). -* `min`: Minimum number of selections to make. -* `max`: Maximum number of selections to make. -* `dim`: Dimension / column in which to select. -* `values`: Array of element values to select when using selection type `values`. These are the element values for a selection, not the values seen by the user. - -### Example - -Randomly select among all the values in object `RZmvzbF`. - -```json -{ - "label": "ListBox Year", - "action": "Select", - "settings": { - "id": "RZmvzbF", - "type": "RandomFromAll", - "accept": true, - "wrap": false, - "min": 1, - "max": 3, - "dim": 0 - } -} -``` - -Randomly select among all the enabled values (a.k.a "white" values) in object `RZmvzbF`. - -```json -{ - "label": "ListBox Year", - "action": "Select", - "settings": { - "id": "RZmvzbF", - "type": "RandomFromEnabled", - "accept": true, - "wrap": false, - "min": 1, - "max": 3, - "dim": 0 - } -} -``` - -#### Statically selecting specific values - -This example selects specific element values in object `RZmvzbF`. These are the values which can be seen in a selection when e.g. inspecting traffic, it is not the data values presented to the user. E.g. when loading a table in the following order by a Sense loadscript: - -``` -Beta -Alpha -Gamma -``` - -which might be presented to the user sorted as - -``` -Alpha -Beta -Gamma -``` - -The element values will be Beta=0, Alpha=1 and Gamma=2. - -To statically select "Gamma" in this case: - -```json -{ - "label": "Select Gammma", - "action": "Select", - "settings": { - "id": "RZmvzbF", - "type": "values", - "accept": true, - "wrap": false, - "values" : [2], - "dim": 0 - } -} -``` - -
- -
-setscript - -## SetScript action - -Set the load script for the current app. To load the data from the script, use the `reload` action after the `setscript` action. - -* `script`: Load script for the app (written as a string). - -### Example - -```json -{ - "action": "setscript", - "settings": { - "script" : "Characters:\nLoad Chr(RecNo()+Ord('A')-1) as Alpha, RecNo() as Num autogenerate 26;" - } -} -``` - -
- -
-setscriptvar - -## SetScriptVar action - -Sets a variable which can be used within the same session. Cannot be accessed across different simulated users. - -* `name`: Name of variable to set. Will overwrite any existing variable with same name. -* `type`: Type of the variable. - * `string`: Variable of type string e.g. `my var value`. - * `int`: Variable of type integer e.g. `6`. - * `array`: Variable of type array e.g. `1,2,3`. -* `value`: Value to set to variable (supports the use of [session variables](#session_variables)). -* `sep`: Separator to use when separating string into array. Defaults to `,`. - -### Example - -Create a variable containing a string and use it in openapp. - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "mylocalvar", - "type": "string", - "value": "My app Name with number for session {{ .Session }}" - } -}, -{ - "action": "openapp", - "settings": { - "appmode": "name", - "app": "{{ .ScriptVars.mylocalvar }}" - } -} -``` - -Create a variable containing an integer and use it in a loop creating bookmarks numbered 1 to 5. Then in a different loop reset variable and delete the bookmarks. - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "settings": { - "iterations": 5, - "actions": [ - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ add .ScriptVars.BookmarkCounter 1 }}" - } - }, - { - "action": "createbookmark", - "settings": { - "title": "Bookmark {{ .ScriptVars.BookmarkCounter }}", - "description": "This bookmark contains some interesting selections" - } - } - - ] - } -}, -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - }, - { - "action": "deletebookmark", - "settings": { - "mode": "single", - "title": "Bookmark {{ $element:=range.ScriptVars.BookmarkCounter }} {{ $element }}{{ end }}" - } - } - ] - } -} -``` - -Combine two variables `MyArrayVar` and `BookmarkCounter` to create 3 bookmarks with the names `Bookmark one`, `Bookmark two` and `Bookmark three`. - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "MyArrayVar", - "type": "array", - "value": "one,two,three,four,five", - "sep": "," - } -}, -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "createbookmark", - "settings": { - "title": "Bookmark {{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}", - "description": "This bookmark contains some interesting selections" - } - }, - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - } - ] - } -} - ``` - -A more advanced example. - -Create a bookmark "BookmarkX" for each iteration in a loop, and add this to an array "MyArrayVar". After the first `iterated` action this will look like "Bookmark1,Bookmark2,Bookmark3". The second `iterated` action then deletes these bookmarks using the created array. - -Dissecting the first array construction action. The `join` command takes the elements `.ScriptVars.MyArrayVar` and joins them together into a string separated by the separtor `,`. So with an array of [ elem1 elem2 ] this becomes a string as `elem1,elem2`. The `if` statement checks if the value of `.ScriptVars.BookmarkCounter` is 0, if it is 0 (i.e. the first iteration) it sets the string to `Bookmark1`. If it is not 0, it executes the join command on .ScriptVars.MyArrayVar, on iteration 3, the result of this would be `Bookmark1,Bookmark2` then it appends the fixed string `,Bookmark`, so far the string is `Bookmark1,Bookmark2,Bookmark`. Lastly it takes the value of `.ScriptVars.BookmarkCounter`, which is now 2, and adds 1 too it and appends, making the entire string `Bookmark1,Bookmark2,Bookmark3`. - - ```json -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "setscriptvar", - "settings": { - "name": "MyArrayVar", - "type": "array", - "value": "{{ if eq 0 .ScriptVars.BookmarkCounter }}Bookmark1{{ else }}{{ join .ScriptVars.MyArrayVar \",\" }},Bookmark{{ .ScriptVars.BookmarkCounter | add 1 }}{{ end }}", - "sep": "," - } - }, - { - "action": "createbookmark", - "settings": { - "title": "{{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}", - "description": "This bookmark contains some interesting selections" - } - }, - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - } - ] - } -}, -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "0" - } -}, -{ - "action": "iterated", - "disabled": false, - "settings": { - "iterations": 3, - "actions": [ - { - "action": "deletebookmark", - "settings": { - "mode": "single", - "title": "{{ index .ScriptVars.MyArrayVar .ScriptVars.BookmarkCounter }}" - } - }, - { - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "{{ .ScriptVars.BookmarkCounter | add 1}}" - } - } - ] - } -} - ``` -
- -
-setsensevariable - -## SetSenseVariable action - -Sets a Qlik Sense variable on a sheet in the open app. - -* `name`: Name of the Qlik Sense variable to set. -* `value`: Value to set the Qlik Sense variable to. (supports the use of [session variables](#session_variables)) - -### Example - -Set a variable to 2000 - -```json -{ - "name": "vSampling", - "value": "2000" -} -``` -
- -
-sheetchanger - -## SheetChanger action - -Create and execute a `changesheet` action for each sheet in an app. This can be used to cache the inital state for all objects or, by chaining two subsequent `sheetchanger` actions, to measure how well the calculations in an app utilize the cache. - - -### Example - -```json -{ - "label" : "Sheetchanger uncached", - "action": "sheetchanger" -}, -{ - "label" : "Sheetchanger cached", - "action": "sheetchanger" -} -``` - -
- -
-smartsearch - -## SmartSearch action - -Perform a Smart Search in Sense app to find suggested selections. - -* `searchtextsource`: Source for list of strings used for searching. - * `searchtextlist` (default) - * `searchtextfile` -* `searchtextlist`: List of of strings used for searching. -* `searchtextfile`: File path to file with one search string per line. -* `pastesearchtext`: - * `true`: Simulate pasting search text. - * `false`: Simulate typing at normal speed (default). -* `makeselection`: Select a random search result. - * `true` - * `false` -* `selectionthinktime`: Think time before selection if `makeselection` is `true`, defaults to a 1 second delay. - * `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. - * `delay`: Delay (seconds), used with type `static`. - * `mean`: Mean (seconds), used with type `uniform`. - * `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. - - -### Examples - -#### Search with one search term -```json -{ - "action": "smartsearch", - "label": "one term search", - "settings": { - "searchtextlist": [ - "term1" - ] - } -} -``` - -#### Search with two search terms -```json -{ - "action": "smartsearch", - "label": "two term search", - "settings": { - "searchtextlist": [ - "term1 term2" - ] - } -} -``` - -#### Search with random selection of search text from list -```json -{ - "action": "smartsearch", - "settings": { - "searchtextlist": [ - "text1", - "text2", - "text3" - ] - } -} -``` - -#### Search with random selection of search text from file -```json -{ - "action": "smartsearch", - "settings": { - "searchtextsource": "searchtextfile", - "searchtextfile": "data/searchtexts.txt" - } -} -``` -##### `data/searchtexts.txt` -``` -search text -"quoted search text" -another search text -``` - -#### Simulate pasting search text - -The default behavior is to simulate typing at normal speed. -```json -{ - "action": "smartsearch", - "settings": { - "pastesearchtext": true, - "searchtextlist": [ - "text1" - ] - } -} -``` - -#### Make a random selection from search results -```json -{ - "action": "smartsearch", - "settings": { - "searchtextlist": [ - "term1" - ], - "makeselection": true, - "selectionthinktime": { - "type": "static", - "delay": 2 - } - } -} -``` - -#### Search with one search term including spaces -```json -{ - "action": "smartsearch", - "settings": { - "searchtextlist": [ - "\"word1 word2\"" - ] - } -} -``` - -#### Search with two search terms, one of them including spaces -```json -{ - "action": "smartsearch", - "label": "two term search, one including spaces", - "settings": { - "searchtextlist": [ - "\"word1 word2\" term2" - ] - } -} -``` - -#### Search with one search term including double quote -```json -{ - "action": "smartsearch", - "label": "one term search including spaces", - "settings": { - "searchtext": - "searchtextlist": [ - "\\\"hello" - ] - } -} -``` - -
- -
-subscribeobjects - -## Subscribeobjects action - -Subscribe to any object in the currently active app. - -* `clear`: Remove any previously subscribed objects from the subscription list. -* `ids`: List of object IDs to subscribe to. - -### Example - -Subscribe to two objects in the currently active app and remove any previous subscriptions. - -```json -{ - "action" : "subscribeobjects", - "label" : "clear subscriptions and subscribe to mBshXB and f2a50cb3-a7e1-40ac-a015-bc4378773312", - "disabled": false, - "settings" : { - "clear" : true, - "ids" : ["mBshXB", "f2a50cb3-a7e1-40ac-a015-bc4378773312"] - } -} -``` - -Subscribe to an additional single object (or a list of objects) in the currently active app, adding the new subscription to any previous subscriptions. - -```json -{ - "action" : "subscribeobjects", - "label" : "add c430d8e2-0f05-49f1-aa6f-7234e325dc35 to currently subscribed objects", - "disabled": false, - "settings" : { - "clear" : false, - "ids" : ["c430d8e2-0f05-49f1-aa6f-7234e325dc35"] - } -} -``` -
- -
-thinktime - -## ThinkTime action - -Simulate user think time. - -**Note:** This action does not require an app context (that is, it does not have to be prepended with an `openapp` action). - -* `type`: Type of think time - * `static`: Static think time, defined by `delay`. - * `uniform`: Random think time with uniform distribution, defined by `mean` and `dev`. -* `delay`: Delay (seconds), used with type `static`. -* `mean`: Mean (seconds), used with type `uniform`. -* `dev`: Deviation (seconds) from `mean` value, used with type `uniform`. - -### Examples - -#### ThinkTime uniform - -This simulates a think time of 10 to 15 seconds. - -```json -{ - "label": "TimerDelay", - "action": "thinktime", - "settings": { - "type": "uniform", - "mean": 12.5, - "dev": 2.5 - } -} -``` - -#### ThinkTime constant - -This simulates a think time of 5 seconds. - -```json -{ - "label": "TimerDelay", - "action": "thinktime", - "settings": { - "type": "static", - "delay": 5 - } -} -``` - -
- -
-unpublishbookmark - -## UnpublishBookmark action - -Unpublish a bookmark. - -**Note:** Specify *either* `title` *or* `id`, not both. - -* `title`: Name of the bookmark (supports the use of [variables](#session_variables)). -* `id`: ID of the bookmark. - -### Example - -Unpublish the bookmark with `id` "bookmark1" that was created earlier on in the script. - -```json -{ - "label" : "Unpublish bookmark 1", - "action": "unpublishbookmark", - "disabled" : false, - "settings" : { - "id" : "bookmark1" - } -} -``` - -Unpublish the bookmark with the `title` "bookmark of testuser", where "testuser" is the username of the simulated user. - -```json -{ - "label" : "Unpublish bookmark 2", - "action": "unpublishbookmark", - "disabled" : false, - "settings" : { - "title" : "bookmark of {{.UserName}}" - } -} -``` - -
- -
-unpublishsheet - -## UnpublishSheet action - -Unpublish sheets in the current app. - -* `mode`: - * `allsheets`: Unpublish all sheets in the app. - * `sheetids`: Only unpublish the sheets specified by the `sheetIds` array. -* `sheetIds`: (optional) Array of sheet IDs for the `sheetids` mode. - -### Example -```json -{ - "label": "UnpublishSheets", - "action": "unpublishsheet", - "settings": { - "mode": "allsheets" - } -} -``` - -
- -
-unsubscribeobjects - -## Unsubscribeobjects action - -Unsubscribe to any currently subscribed object. - -* `ids`: List of object IDs to unsubscribe from. -* `clear`: Remove any previously subscribed objects from the subscription list. - -### Example - -Unsubscribe from a single object (or a list of objects). - -```json -{ - "action" : "unsubscribeobjects", - "label" : "unsubscribe from object maVjt and its children", - "disabled": false, - "settings" : { - "ids" : ["maVjt"] - } -} -``` - -Unsubscribe from all currently subscribed objects. - -```json -{ - "action" : "unsubscribeobjects", - "label" : "unsubscribe from all objects", - "disabled": false, - "settings" : { - "clear": true - } -} -``` -
- -
-stepdimension - -## StepDimension action - -Cycle a step in a cyclic dimension - -* `id`: library ID of the cyclic dimension - -### Example - -Cycle one step in the dimension with library ID `aBc123`. - -```json -{ - "action": "stepdimension", - "settings":{ - "id": "aBc123" - } -} -``` - -
- -
- -
-Qlik Sense Enterprise on Windows (QSEoW) actions - -## Qlik Sense Enterprise on Windows (QSEoW) actions - -These actions are only applicable to Qlik Sense Enterprise on Windows (QSEoW) deployments. - - -
-deleteodag - -## DeleteOdag action - -Delete all user-generated on-demand apps for the current user and the specified On-Demand App Generation (ODAG) link. - -* `linkname`: Name of the ODAG link from which to delete generated apps. The name is displayed in the ODAG navigation bar at the bottom of the *selection app*. - -### Example - -```json -{ - "action": "DeleteOdag", - "settings": { - "linkname": "Drill to Template App" - } -} -``` - -
- -
-generateodag - -## GenerateOdag action - -Generate an on-demand app from an existing On-Demand App Generation (ODAG) link. - -* `linkname`: Name of the ODAG link from which to generate an app. The name is displayed in the ODAG navigation bar at the bottom of the *selection app*. - -### Example - -```json -{ - "action": "GenerateOdag", - "settings": { - "linkname": "Drill to Template App" - } -} -``` - -
- -
-openhub - -## OpenHub action - -Open the hub in a QSEoW environment. This also makes the apps included in the response for the users `myspace` available for use by subsequent actions. The action `changestream` can be used to only select from apps in a specific stream. - - -### Example - -```json -{ - "action": "OpenHub", - "label": "Open the hub" -} -``` - -
- -
-changestream - -## ChangeStream action - -Change to specified stream. This makes the apps in the specified stream selectable by actions such as `openapp`. -* `mode`: Decides what kind of value the `stream` field contains. Defaults to `name`. - * `name`: `stream` is the name of the stream. - * `id`: `stream` is the ID if the stream. -* `stream`: - -### Example - -Make apps in stream `Everyone` selectable by subsequent actions. - -```json -{ - "label": "ChangeStream Everyone", - "action": "changestream", - "settings": { - "mode": "name", - "stream" : "Everyone" - } -} -``` - -Make apps in stream with id `ABSCDFSDFSDFO1231234` selectable subsequent actions. - -```json -{ - "label": "ChangeStream Test1", - "action": "changestream", - "settings": { - "mode": "id", - "stream" : "ABSCDFSDFSDFO1231234" - } -} -``` - -
- -
- - -## Session variables - -This section describes the session variables that can be used with some of the actions. - -Some action parameters support session variables. A session variable is defined by putting the variable, prefixed by a dot, within double curly brackets, such as `{{.UserName}}`. - -The following session variables are supported in actions: - -* `UserName`: The simulated username. This is not the same as the authenticated user, but rather how the username was defined by [Login settings](#login_settings). -* `Session`: The enumeration of the currently simulated session. -* `Thread`: The enumeration of the currently simulated "thread" or "concurrent user". -* `ScriptVars`: A map containing script variables added by the action `setscriptvar`. -* `Artifacts`: - * `GetIDByTypeAndName`: A function that accepts the two string arguments, - `artifactType` and `artifactName`, and returns the resource id of the artifact. - * `GetNameByTypeAndID`: A function that accepts the two string arguments, - `artifactType` and `artifactID`, and returns the name of the artifact. - - -The following variable is supported in the filename of the log file: - -* `ConfigFile`: The filename of the config file, without file extension. - -The following functions are supported: - -* `now`: Evaluates Golang [time.Now()](https://golang.org/pkg/time/). -* `hostname`: Hostname of the local machine. -* `timestamp`: Timestamp in `yyyyMMddhhmmss` format. -* `uuid`: Generate an uuid. -* `env`: Retrieve a specific environment variable. Takes one argument - the name of the environment variable to expand. -* `add`: Adds two integer values together and outputs the sum. E.g. `{{ add 1 2 }}`. -* `join`: Joins array elements together to a string separated by defined separator. E.g. `{{ join .ScriptVars.MyArray \",\" }}`. -* `modulo`: Returns modulo of two integer values and output the result. E.g. `{{ modulo 10 4 }}` (will return 2) - -### Example - -```json -{ - "label" : "Create bookmark", - "action": "createbookmark", - "settings": { - "title": "my bookmark {{.Thread}}-{{.Session}} ({{.UserName}})", - "description": "This bookmark contains some interesting selections" - } -}, -{ - "label" : "Publish created bookmark", - "action": "publishbookmark", - "disabled" : false, - "settings" : { - "title": "my bookmark {{.Thread}}-{{.Session}} ({{.UserName}})", - } -} - -``` - -```json -{ - "action": "createbookmark", - "settings": { - "title": "{{env \"TITLE\"}}", - "description": "This bookmark contains some interesting selections" - } -} -``` - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "BookmarkCounter", - "type": "int", - "value": "1" - } -}, -{ - "action": "createbookmark", - "settings": { - "title": "Bookmark no {{ add .ScriptVars.BookmarkCounter 1 }}", - "description": "This bookmark will have the title Bookmark no 2" - } -} -``` - -```json -{ - "action": "setscriptvar", - "settings": { - "name": "MyAppId", - "type": "string", - "value": "{{.Artifacts.GetIDByTypeAndName \"app\" (print \"an-app-\" .Session)}}" - } -} -``` - -Let's assume the case there are 4 apps to be used in the test, all ending with number 0 to 3. The use of modulo in the example will cycle through the app suffix number in following order: 1, 2, 3, 0. - -```json -{ - "action": "elastictriggersubscription", - "label": "trigger reporting task", - "settings": { - "subscriptiontype": "template-sharing", - "limitperpage": 100, - "appname": "PS-18566_Test_Levels_Pages- {{ modulo .Session 4}}", - "subscriptionmode": "random", - } -} -``` - -Very similar case as above but apps have number suffix from 1 to 4. This can be handled combining `modulo` and `add` functions. The cycle through the suffix number will be done in following order: 2, 3, 4, 1. -```json -{ - "action": "elastictriggersubscription", - "label": "trigger reporting task", - "settings": { - "subscriptiontype": "template-sharing", - "limitperpage": 100, - "appname": "PS-18566_Test_Levels_Pages- {{ modulo .Session 4 | add 1 }}", - "subscriptionmode": "random", - } -} -``` - - -
- -
-scheduler - -## Scheduler section - -This section of the JSON file contains scheduler settings for the users in the load scenario. - - - - -
-simple - -## Simple scheduler - -Settings specific to the `simple` scheduler. - -* `type`: Type of scheduler - * `simple`: Standard scheduler -* `iterationtimebuffer`: - * `mode`: Time buffer mode. Defaults to `nowait`, if omitted. - * `nowait`: No time buffer in between the iterations. - * `constant`: Add a constant time buffer after each iteration. Defined by `duration`. - * `onerror`: Add a time buffer in case of an error. Defined by `duration`. - * `minduration`: Add a time buffer if the iteration duration is less than `duration`. - * `duration`: Duration of the time buffer (for example, `500ms`, `30s` or `1m10s`). Valid time units are `ns`, `us` (or `µs`), `ms`, `s`, `m`, and `h`. -* `instance`: Instance number for this instance. Use different instance numbers when running the same script in multiple instances to make sure the randomization is different in each instance. Defaults to 1. -* `reconnectsettings`: Settings for enabling re-connection attempts in case of unexpected disconnects. - * `reconnect`: Enable re-connection attempts if the WebSocket is disconnected. Defaults to `false`. - * `backoff`: Re-connection backoff scheme. Defaults to `[0.0, 2.0, 2.0, 2.0, 2.0, 2.0, 4.0, 4.0, 8.0, 12.0, 16.0]`, if left empty. An example backoff scheme could be `[0.0, 1.0, 10.0, 20.0]`: - * `0.0`: If the WebSocket is disconnected, wait 0.0s before attempting to re-connect - * `1.0`: If the previous attempt to re-connect failed, wait 1.0s before attempting again - * `10.0`: If the previous attempt to re-connect failed, wait 10.0s before attempting again - * `20.0`: If the previous attempt to re-connect failed, wait 20.0s before attempting again -* `settings`: - * `executionTime`: Test execution time (seconds). The sessions are disconnected when the specified time has elapsed. Allowed values are positive integers. `-1` means an infinite execution time. - * `iterations`: Number of iterations for each 'concurrent' user to repeat. Allowed values are positive integers. `-1` means an infinite number of iterations. - * `rampupDelay`: Time delay (seconds) scheduled in between each concurrent user during the startup period. - * `concurrentUsers`: Number of concurrent users to simulate. Allowed values are positive integers. - * `reuseUsers`: - * `true`: Every iteration for each concurrent user uses the same user and session. - * `false`: Every iteration for each concurrent user uses a new user and session. The total number of users is the product of `concurrentusers` and `iterations`. - * `onlyinstanceseed`: Disable session part of randomization seed. Defaults to `false`, if omitted. - * `true`: All users and sessions have the same randomization sequence, which only changes if the `instance` flag is changed. - * `false`: Normal randomization sequence, dependent on both the `instance` parameter and the current user session. - -### Using `reconnectsettings` - -If `reconnectsettings.reconnect` is enabled, the following is attempted: - -1. Re-connect the WebSocket. -2. Get the currently opened app in the re-attached engine session. -3. Re-subscribe to the same object as before the disconnection. -4. If successful, the action during which the re-connect happened is logged as a successful action with `action` and `label` changed to `Reconnect(action)` and `Reconnect(label)`. -5. Restart the action that was executed when the disconnection occurred (unless it is a `thinktime` action, which will not be restarted). -6. Log an info row with info type `WebsocketReconnect` and with a semicolon-separated `details` section as follows: "success=`X`;attempts=`Y`;TimeSpent=`Z`" - * `X`: True/false - * `Y`: An integer representing the number of re-connection attempts - * `Z`: The time spent re-connecting (ms) - -### Example - -Simple scheduler settings: - -```json -"scheduler": { - "type": "simple", - "settings": { - "executiontime": 120, - "iterations": -1, - "rampupdelay": 7.0, - "concurrentusers": 10 - }, - "iterationtimebuffer" : { - "mode": "onerror", - "duration" : "5s" - }, - "instance" : 2 -} -``` - -Simple scheduler set to attempt re-connection in case of an unexpected WebSocket disconnection: - -```json -"scheduler": { - "type": "simple", - "settings": { - "executiontime": 120, - "iterations": -1, - "rampupdelay": 7.0, - "concurrentusers": 10 - }, - "iterationtimebuffer" : { - "mode": "onerror", - "duration" : "5s" - }, - "reconnectsettings" : { - "reconnect" : true - } -} -``` - -
- -
- -
-settings - -## Settings section - -This section of the JSON file contains timeout and logging settings for the load scenario. - -* `timeout`: Timeout setting (seconds) for requests. -* `logs`: Log settings - * `traffic`: Log traffic information (`true` / `false`). Defaults to `false`, if omitted. **Note:** This should only be used for debugging purposes as traffic logging is resource-demanding. - * `debug`: Log debug information (`true` / `false`). Defaults to `false`, if omitted. - * `metrics`: Log traffic metrics (`true` / `false`). Defaults to `false`, if omitted. **Note:** This should only be used for debugging purposes as traffic logging is resource-demanding. - * `regression`: Log regression data (`true` / `false`). Defaults to `false`, if omitted. **Note:** Do not log regression data when testing performance. **Note** With regression logging enabled, the the scheduler is implicitly set to execute the scenario as one user for one iteration. - * `filename`: Name of the log file (supports the use of [variables](#session_variables)). - * `format`: Log format. Defaults to `tsvfile`, if omitted. - * `tsvfile`: Log to file in TSV format and output status to console. - * `tsvconsole`: Log to console in TSV format without any status output. - * `jsonfile`: Log to file in JSON format and output status to console. - * `jsonconsole`: Log to console in JSON format without any status output. - * `console`: Log to console in color format without any status output. - * `combined`: Log to file in TSV format and to console in JSON format. - * `no`: Default logs and status output turned off. - * `onlystatus`: Default logs turned off, but status output turned on. - * `summary`: Type of summary to display after the test run. Defaults to simple for minimal performance impact. - * `0` or `undefined`: Simple, single-row summary - * `1` or `none`: No summary - * `2` or `simple`: Simple, single-row summary - * `3` or `extended`: Extended summary that includes statistics on each unique combination of action, label and app GUID - * `4` or `full`: Same as extended, but with statistics on each unique combination of method and endpoint added - * `summaryFilename`: Name of summary file, only used when using summary type `file`. Defaults to `summary.json` -* `outputs`: Used by some actions to save results to a file. - * `dir`: Directory in which to save artifacts generated by the script (except log file). -* `maxerrors`: Break execution if max errors exceeded. 0 - Do not break. Defaults to 0. - -### Examples - -```json -"settings": { - "timeout": 300, - "logs": { - "traffic": false, - "debug": false, - "filename": "logs/{{.ConfigFile}}-{{timestamp}}.log" - } -} -``` - -```json -"settings": { - "timeout": 300, - "logs": { - "filename": "logs/scenario.log" - }, - "outputs" : { - "dir" : "./outputs" - } -} -``` - -
- +Documenation has moved to [wiki](https://github.com/qlik-oss/gopherciser/wiki/introduction) \ No newline at end of file diff --git a/main.go b/main.go index a158f5d7..3c18776d 100644 --- a/main.go +++ b/main.go @@ -9,9 +9,6 @@ import ( // Compile documentation data to be used by GUI and for markdown generation //go:generate go run ./generatedocs/cmd/compiledocs -// Generate markdown files -//go:generate go run ./generatedocs/cmd/generatemarkdown --output ./docs/settingup.md - func main() { cmd.Execute() } From d5151fdcdc817780758d79365899897af7d38661 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 16:50:07 +0100 Subject: [PATCH 18/46] add initial workflow --- .github/workflows/publishwiki.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/publishwiki.yaml diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml new file mode 100644 index 00000000..06159777 --- /dev/null +++ b/.github/workflows/publishwiki.yaml @@ -0,0 +1,25 @@ +name: Publish wiki + +on: + workflow_dispatch: + +jobs: + generatewiki: + name: Generate WIKI + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up GO + uses: actions/setup-go@v3 + with: + go-version: 1.23 + + - name: Check submodule + run: | + cd gopherciser.wiki + ls \ No newline at end of file From 4d5496267415d3f96d0b3cc11c94a1826bb891e2 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 16:55:32 +0100 Subject: [PATCH 19/46] trigger action on push to this branch --- .github/workflows/publishwiki.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 06159777..cf4dd96b 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -2,7 +2,9 @@ name: Publish wiki on: workflow_dispatch: - + push: + branches: + - "documentation/1584-actions-to-wiki" jobs: generatewiki: name: Generate WIKI From e2b9fb7f45292eed403e85fd134b99c18fd38915 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 16:57:04 +0100 Subject: [PATCH 20/46] try add initwiki --- .github/workflows/publishwiki.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index cf4dd96b..bb368866 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -23,5 +23,6 @@ jobs: - name: Check submodule run: | + make initwiki cd gopherciser.wiki ls \ No newline at end of file From bbc2458751519d3b0f04de7b1670d4cd45c4f227 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 17:04:00 +0100 Subject: [PATCH 21/46] try turn on submodules --- .github/workflows/publishwiki.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index bb368866..b9da7bbb 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -14,7 +14,7 @@ jobs: - name: Checkout uses: actions/checkout@v3 with: - fetch-depth: 0 + submodules: 'true' - name: Set up GO uses: actions/setup-go@v3 From f90fb51655695259c193f1d3e566f1ea2a7c3744 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 17:05:08 +0100 Subject: [PATCH 22/46] remove initwiki command --- .github/workflows/publishwiki.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index b9da7bbb..eab573c5 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -23,6 +23,5 @@ jobs: - name: Check submodule run: | - make initwiki cd gopherciser.wiki ls \ No newline at end of file From da314e475a35247d8982f99b2cfba5040e63cec8 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 17:09:18 +0100 Subject: [PATCH 23/46] add genwiki command --- .github/workflows/publishwiki.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index eab573c5..95327774 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -21,7 +21,6 @@ jobs: with: go-version: 1.23 - - name: Check submodule + - name: Generate wiki run: | - cd gopherciser.wiki - ls \ No newline at end of file + make genwiki \ No newline at end of file From d9d4680d48a2ab8a5cd4a51c437d4c86ce4f8cbd Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Wed, 19 Feb 2025 17:39:57 +0100 Subject: [PATCH 24/46] add change detection --- .github/workflows/publishwiki.yaml | 12 +++++++++++- Makefile | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 95327774..1d63e669 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -23,4 +23,14 @@ jobs: - name: Generate wiki run: | - make genwiki \ No newline at end of file + make genwiki PARAM=--verbose + + - name: Publish if any changes + run: | + cd gopherciser.wiki + git diff --cached --quiet + if [[ "$?" != "0" ]]; then + echo "should publish" + else + echo "no changes skipping publish" + fi diff --git a/Makefile b/Makefile index 9327f257..6596ea21 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ ifeq ($(UNAME_S),Darwin) OSFLAG += ./$(BIN)/$(BIN_NAME)_osx endif endif +PARAM ?= .PHONY: clean build unit-test-cover unit-test-cover-ext codeclimate lint test alltests @@ -72,4 +73,4 @@ initwiki: genwiki: initwiki set -e go generate - go run ./generatedocs/cmd/generatemarkdown --wiki ./gopherciser.wiki \ No newline at end of file + go run ./generatedocs/cmd/generatemarkdown $(PARAM) --wiki ./gopherciser.wiki From 41f05c09df242038b75c4f231570239c69327c1a Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 09:28:51 +0100 Subject: [PATCH 25/46] add publish to github action --- .github/workflows/publishwiki.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 1d63e669..ba97685b 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -30,7 +30,11 @@ jobs: cd gopherciser.wiki git diff --cached --quiet if [[ "$?" != "0" ]]; then - echo "should publish" + echo "publishing wiki" + set -e + git add -A + git commit -m "Github action automatically generated wiki" + git push else echo "no changes skipping publish" fi From 0bf8e9b43e6c8251eeec56bdb5c2c6475f029c71 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 09:32:48 +0100 Subject: [PATCH 26/46] small fix to test wiki publish --- generatedocs/data/actions/disconnectenvironment/description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generatedocs/data/actions/disconnectenvironment/description.md b/generatedocs/data/actions/disconnectenvironment/description.md index 56a25958..d4263900 100644 --- a/generatedocs/data/actions/disconnectenvironment/description.md +++ b/generatedocs/data/actions/disconnectenvironment/description.md @@ -1,5 +1,5 @@ ## DisconnectEnvironment action -Disconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environmentsor uses custom actions towards external environment, it should be used directly after the last action towards the environment. +Disconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environments or uses custom actions towards external environment, it should be used directly after the last action towards the environment. Since the action also disconnects any open websocket to Sense apps, it does not need to be preceeded with a `disconnectapp` action. From eb9ea9b53d368417cc0b0ba9c1e8b798fd94d4b8 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 09:48:48 +0100 Subject: [PATCH 27/46] update workflow --- .github/workflows/publishwiki.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index ba97685b..4b31ebcf 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -21,16 +21,31 @@ jobs: with: go-version: 1.23 + - name: Cache go modules + id: cache-go + uses: actions/cache@v3 + env: + cache-name: dependency-cache-1.23 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Generate wiki run: | make genwiki PARAM=--verbose - name: Publish if any changes run: | - cd gopherciser.wiki + git status git diff --cached --quiet if [[ "$?" != "0" ]]; then echo "publishing wiki" + cd gopherciser.wiki + git status set -e git add -A git commit -m "Github action automatically generated wiki" From 946df1780a7d85dea94e2c9baa4508021d234247 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 11:02:01 +0100 Subject: [PATCH 28/46] try new if for publish --- .github/workflows/publishwiki.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 4b31ebcf..cc0df562 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -41,11 +41,10 @@ jobs: - name: Publish if any changes run: | git status - git diff --cached --quiet - if [[ "$?" != "0" ]]; then - echo "publishing wiki" - cd gopherciser.wiki + cd gopherciser.wiki + if [[ "$(git diff --name-only)" != "" ]]; then git status + echo "publishing wiki" set -e git add -A git commit -m "Github action automatically generated wiki" From 4e99f752ac41945d862be2e8fa74ada58f04b673 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 11:15:44 +0100 Subject: [PATCH 29/46] try set user --- .github/workflows/publishwiki.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index cc0df562..16f5e42a 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -46,8 +46,9 @@ jobs: git status echo "publishing wiki" set -e - git add -A - git commit -m "Github action automatically generated wiki" + git config user.email "scalabilitylab@qlik.com" + git config user.name "Qlik Performance Robot" + git commit -a -m "Github action automatically generated wiki" git push else echo "no changes skipping publish" From cdd7db14ad2b94398f60195057998e719b5c902c Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 11:20:31 +0100 Subject: [PATCH 30/46] try checkout master --- .github/workflows/publishwiki.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 16f5e42a..582858ed 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -45,6 +45,7 @@ jobs: if [[ "$(git diff --name-only)" != "" ]]; then git status echo "publishing wiki" + git checkout master set -e git config user.email "scalabilitylab@qlik.com" git config user.name "Qlik Performance Robot" From 3a977b468ff3a8297a509660a11d05c9d2f4aeb0 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 12:16:49 +0100 Subject: [PATCH 31/46] better submodule handling --- .github/workflows/publishwiki.yaml | 28 +++++++++++++++++++++++++--- Makefile | 5 +++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 582858ed..489ab436 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -39,18 +39,40 @@ jobs: make genwiki PARAM=--verbose - name: Publish if any changes + env: + GIT_USER_EMAIL: ${{ secrets.PERBOT_EMAIL }} + GIT_USER_NAME: ${{ secrets.PERBOT_USERNAME }} run: | git status cd gopherciser.wiki if [[ "$(git diff --name-only)" != "" ]]; then git status echo "publishing wiki" - git checkout master set -e - git config user.email "scalabilitylab@qlik.com" - git config user.name "Qlik Performance Robot" + git config user.email "$GIT_USER_EMAIL" + git config user.name "$GIT_USER_NAME" git commit -a -m "Github action automatically generated wiki" git push else echo "no changes skipping publish" fi + + - name: Update submodule and generated go file in branch + env: + GIT_USER_EMAIL: ${{ secrets.PERBOT_EMAIL }} + GIT_USER_NAME: ${{ secrets.PERBOT_USERNAME }} + run: | + pwd + git status + if [[ "$(git diff --name-only)" != "" ]]; then + echo "add generated documentation and wiki sha to branch" + set -e + git config user.email "$GIT_USER_EMAIL" + git config user.name "$GIT_USER_NAME" + git add generatedocs/generated/documentation.go + git add gopherciser.wiki + git status + #TODO push + else + echo "nothing to add" + fi diff --git a/Makefile b/Makefile index 6596ea21..c2fb30e2 100644 --- a/Makefile +++ b/Makefile @@ -65,9 +65,10 @@ alltests: # Run quickbuild test and linting. Good to run e.g. before pushing to remote verify: quickbuild test lint-min -# init submodule and get latest +# init submodule and get latest version initwiki: - git submodule update --init --recursive + git submodule update --init --recursive --remote + git submodule foreach --recursive git checkout master # generate config and action documenation genwiki: initwiki From d01aca40cd4a0df5d16e677baa7511e36733f215 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 15:42:29 +0100 Subject: [PATCH 32/46] add auto update wiki entry to branch --- .github/workflows/publishwiki.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 489ab436..388210f6 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -72,7 +72,8 @@ jobs: git add generatedocs/generated/documentation.go git add gopherciser.wiki git status - #TODO push + git commit -m "Github action automatically added generated documentation" + git push else echo "nothing to add" fi From f3ce6ff81ffad122517781731b1cc3a81e8edfc3 Mon Sep 17 00:00:00 2001 From: Qlik Performance Robot <> Date: Thu, 20 Feb 2025 14:44:39 +0000 Subject: [PATCH 33/46] Github action automatically added generated documentation --- generatedocs/generated/documentation.go | 2 +- gopherciser.wiki | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index d446e43b..34757613 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -67,7 +67,7 @@ var ( Examples: "### Example\n\n```json\n{\n \"label\": \"Disconnect from server\",\n \"action\" : \"disconnectapp\"\n}\n```\n", }, "disconnectenvironment": { - Description: "## DisconnectEnvironment action\n\nDisconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environmentsor uses custom actions towards external environment, it should be used directly after the last action towards the environment.\n\nSince the action also disconnects any open websocket to Sense apps, it does not need to be preceeded with a `disconnectapp` action.\n", + Description: "## DisconnectEnvironment action\n\nDisconnect from an environment. This action will disconnect open websockets towards sense and events. The action is not needed for most scenarios, however if a scenario mixes different types of environments or uses custom actions towards external environment, it should be used directly after the last action towards the environment.\n\nSince the action also disconnects any open websocket to Sense apps, it does not need to be preceeded with a `disconnectapp` action.\n", Examples: "### Example\n\n```json\n{\n \"label\": \"Disconnect from environment\",\n \"action\" : \"disconnectenvironment\"\n}\n```\n", }, "dosave": { diff --git a/gopherciser.wiki b/gopherciser.wiki index fc7a0ef3..8e63aa3d 160000 --- a/gopherciser.wiki +++ b/gopherciser.wiki @@ -1 +1 @@ -Subproject commit fc7a0ef3a67b5a28da5e354f076e4d66aa29756b +Subproject commit 8e63aa3d51b85c956024fbc97d4f4dbfb99608df From bdd087b8e1c8070cabf45480150227234f3d4d8b Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:24:53 +0100 Subject: [PATCH 34/46] publish wiki on master push --- .github/workflows/publishwiki.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 388210f6..092ba564 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - "documentation/1584-actions-to-wiki" + - "master" jobs: generatewiki: name: Generate WIKI From dad152a285c726ca5acf98c6b5cb9151dacc78a4 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:25:49 +0100 Subject: [PATCH 35/46] add check doc gen on PR --- .github/workflows/check-generate-docs.yaml | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/check-generate-docs.yaml diff --git a/.github/workflows/check-generate-docs.yaml b/.github/workflows/check-generate-docs.yaml new file mode 100644 index 00000000..945f0a0e --- /dev/null +++ b/.github/workflows/check-generate-docs.yaml @@ -0,0 +1,43 @@ +name: Check generated documentation + +on: + workflow_dispatch: + pull_request: + branches: + - master + +jobs: + checkdocs: + name: Check if documentation genereation is needed + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + submodules: 'true' + + - name: Set up GO + uses: actions/setup-go@v3 + with: + go-version: 1.23 + + - name: Cache go modules + id: cache-go + uses: actions/cache@v3 + env: + cache-name: dependency-cache-1.23 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Generate docs + run: | + go generate + + - name: Verifty nothing to generate + run: if [[ "$(g diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then; echo "documentation not generated"; exit 1; fi \ No newline at end of file From c9f14243fd2f739b54280e1a8ee3772292613ef7 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:26:12 +0100 Subject: [PATCH 36/46] add action change to be generated on push to master + fail for doc gen check --- README.md | 8 ++++++- .../data/actions/changesheet/description.md | 22 ------------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 63f11193..ad21c032 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,18 @@ This repo contains the wiki as a submodule, to clone sub modules when cloning th git clone --recurse-submodules git@github.com:qlik-oss/gopherciser.wiki.git ``` -If repo was cloned manually, the wiki submodule can be checkd out using +If repo was cloned manually, the wiki submodule can be checked out using ```bash git submodule update --init --recursive ``` +Updating submodule to version defined by current branch commit: + +```bash +git submodule update +``` + **Note** the submodule will by default be in it's `master` branch. Any changes done and pushed in the submodule master branch will instantly update the wiki (i.e. don't make changes intended for a PR directly here). ## Building Gopherciser diff --git a/generatedocs/data/actions/changesheet/description.md b/generatedocs/data/actions/changesheet/description.md index 3a7480af..7c7cbc28 100644 --- a/generatedocs/data/actions/changesheet/description.md +++ b/generatedocs/data/actions/changesheet/description.md @@ -1,25 +1,3 @@ ## ChangeSheet action Change to a new sheet, unsubscribe to the currently subscribed objects, and subscribe to all objects on the new sheet. - -The action supports getting data from the following objects: - -* Listbox -* Filter pane -* Bar chart -* Scatter plot -* Map (only the first layer) -* Combo chart -* Table -* Pivot table -* Line chart -* Pie chart -* Tree map -* Text-Image -* KPI -* Gauge -* Box plot -* Distribution plot -* Histogram -* Auto chart (including any support generated visualization from this list) -* Waterfall chart From dffae2419124ee024ae784e1f005a1a48ff99b7c Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:30:10 +0100 Subject: [PATCH 37/46] yaml sucks --- .github/workflows/check-generate-docs.yaml | 55 +++++++++++----------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/.github/workflows/check-generate-docs.yaml b/.github/workflows/check-generate-docs.yaml index 945f0a0e..0e57c0a7 100644 --- a/.github/workflows/check-generate-docs.yaml +++ b/.github/workflows/check-generate-docs.yaml @@ -8,36 +8,35 @@ on: jobs: checkdocs: - name: Check if documentation genereation is needed + name: Check if documentation generation is needed runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + submodules: 'true' - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: 'true' + - name: Set up GO + uses: actions/setup-go@v3 + with: + go-version: 1.23 - - name: Set up GO - uses: actions/setup-go@v3 - with: - go-version: 1.23 + - name: Cache go modules + id: cache-go + uses: actions/cache@v3 + env: + cache-name: dependency-cache-1.23 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- - - name: Cache go modules - id: cache-go - uses: actions/cache@v3 - env: - cache-name: dependency-cache-1.23 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + - name: Generate docs + run: | + go generate - - name: Generate docs - run: | - go generate - - - name: Verifty nothing to generate - run: if [[ "$(g diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then; echo "documentation not generated"; exit 1; fi \ No newline at end of file + - name: Verifty nothing to generate + run: if [[ "$(g diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then; echo "documentation not generated"; exit 1; fi \ No newline at end of file From 0d06b46e28d8df60348b1fe877d268dcca7d39ea Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:41:57 +0100 Subject: [PATCH 38/46] fix check doc action --- .github/workflows/check-generate-docs.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-generate-docs.yaml b/.github/workflows/check-generate-docs.yaml index 0e57c0a7..fc7e0e43 100644 --- a/.github/workflows/check-generate-docs.yaml +++ b/.github/workflows/check-generate-docs.yaml @@ -39,4 +39,8 @@ jobs: go generate - name: Verifty nothing to generate - run: if [[ "$(g diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then; echo "documentation not generated"; exit 1; fi \ No newline at end of file + run: | + if [[ "$(g diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then + echo "documentation not generated" + exit 1 + fi \ No newline at end of file From 7175d1d11e9b3cf3275f880d6f061a1d9c193c0b Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:44:52 +0100 Subject: [PATCH 39/46] fix check doc action --- .github/workflows/check-generate-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-generate-docs.yaml b/.github/workflows/check-generate-docs.yaml index fc7e0e43..88e233d9 100644 --- a/.github/workflows/check-generate-docs.yaml +++ b/.github/workflows/check-generate-docs.yaml @@ -40,7 +40,7 @@ jobs: - name: Verifty nothing to generate run: | - if [[ "$(g diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then + if [[ "$(git diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then echo "documentation not generated" exit 1 fi \ No newline at end of file From 27f06b608aa4fdac66e389c39582250fae9391d2 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Thu, 20 Feb 2025 16:48:47 +0100 Subject: [PATCH 40/46] verify doccheck again positive --- .github/workflows/check-generate-docs.yaml | 6 +++--- generatedocs/generated/documentation.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-generate-docs.yaml b/.github/workflows/check-generate-docs.yaml index 88e233d9..c83948ba 100644 --- a/.github/workflows/check-generate-docs.yaml +++ b/.github/workflows/check-generate-docs.yaml @@ -1,4 +1,4 @@ -name: Check generated documentation +name: Documentation check on: workflow_dispatch: @@ -8,7 +8,7 @@ on: jobs: checkdocs: - name: Check if documentation generation is needed + name: Verify generated runs-on: ubuntu-latest steps: - name: Checkout @@ -38,7 +38,7 @@ jobs: run: | go generate - - name: Verifty nothing to generate + - name: Verify nothing to generate run: | if [[ "$(git diff --name-only -- generatedocs/generated/documentation.go)" != "" ]]; then echo "documentation not generated" diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index 34757613..095ff637 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -19,7 +19,7 @@ var ( Examples: "### Examples\n\n#### Pick queries from file\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"file\",\n \"file\": \"queries.txt\"\n }\n}\n```\n\nThe file `queries.txt` contains one query and an optional weight per line. The line format is `[WEIGHT;]QUERY`.\n```txt\nshow sales per country\n5; what is the lowest price of shoes\n```\n\n#### Pick queries from list\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\"show sales per country\", \"what is the lowest price of shoes\"]\n }\n}\n```\n\n#### Perform followup queries if possible (default: 0)\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\"show sales per country\", \"what is the lowest price of shoes\"],\n \"maxfollowup\": 3\n }\n}\n```\n\n#### Change lanuage (default: \"en\")\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\"show sales per country\", \"what is the lowest price of shoes\"],\n \"lang\": \"fr\"\n }\n}\n```\n\n#### Weights in querylist\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\n {\n \"query\": \"show sales per country\",\n \"weight\": 5,\n },\n \"what is the lowest price of shoes\"\n ]\n }\n}\n```\n\n#### Thinktime before followup queries\n\nSee detailed examples of settings in the documentation for thinktime action.\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\n \"what is the lowest price of shoes\"\n ],\n \"maxfollowup\": 5,\n \"thinktime\": {\n \"type\": \"static\",\n \"delay\": 5\n }\n }\n}\n```\n\n#### Ask followups only based on app selection\n\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\n \"what is the lowest price of shoes\"\n ],\n \"maxfollowup\": 5,\n \"followuptypes\": [\"app\"]\n }\n}\n```\n\n#### Save chart images to file\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\n \"show price per shoe type\"\n ],\n \"maxfollowup\": 5,\n \"saveimages\": true\n }\n}\n```\n\n#### Save chart images to file with custom name\n\nThe `saveimagefile` file name template setting supports\n[Session Variables](https://github.com/qlik-trial/gopherciser-oss/blob/master/docs/settingup.md#session-variables).\nYou can apart from session variables include the following action local variables in the `saveimagefile` file name template:\n- .Local.ImageCount - _the number of images written to file_\n- .Local.ServerFileName - _the server side name of image file_\n- .Local.Query - _the query sentence_\n- .Local.AppName - _the name of app, if any app, where query is asked_\n- .Local.AppID - _the id of app, if any app, where query is asked_\n\n```json\n{\n \"action\": \"AskHubAdvisor\",\n \"settings\": {\n \"querysource\": \"querylist\",\n \"querylist\": [\n \"show price per shoe type\"\n ],\n \"maxfollowup\": 5,\n \"saveimages\": true,\n \"saveimagefile\": \"{{.Local.Query}}--app-{{.Local.AppName}}--user-{{.UserName}}--thread-{{.Thread}}--session-{{.Session}}\"\n }\n}\n```\n", }, "changesheet": { - Description: "## ChangeSheet action\n\nChange to a new sheet, unsubscribe to the currently subscribed objects, and subscribe to all objects on the new sheet.\n\nThe action supports getting data from the following objects:\n\n* Listbox\n* Filter pane\n* Bar chart\n* Scatter plot\n* Map (only the first layer)\n* Combo chart\n* Table\n* Pivot table\n* Line chart\n* Pie chart\n* Tree map\n* Text-Image\n* KPI\n* Gauge\n* Box plot\n* Distribution plot\n* Histogram\n* Auto chart (including any support generated visualization from this list)\n* Waterfall chart\n", + Description: "## ChangeSheet action\n\nChange to a new sheet, unsubscribe to the currently subscribed objects, and subscribe to all objects on the new sheet.\n", Examples: "### Example\n\n```json\n{\n \"label\": \"Change Sheet Dashboard\",\n \"action\": \"ChangeSheet\",\n \"settings\": {\n \"id\": \"TFJhh\"\n }\n}\n```\n", }, "changestream": { From ffe17d1392a6cd073b3f6aba9c9aa7bf9a9890df Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Fri, 21 Feb 2025 11:59:37 +0100 Subject: [PATCH 41/46] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad21c032..50f71ed7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ For more information on how to perform load testing with Gopherciser see the [wi This repo contains the wiki as a submodule, to clone sub modules when cloning the project ```bash -git clone --recurse-submodules git@github.com:qlik-oss/gopherciser.wiki.git +git clone --recurse-submodules git@github.com:qlik-oss/gopherciser.git ``` If repo was cloned manually, the wiki submodule can be checked out using From 09aa6a92bc88186c21788198afe19e7e2cfa1c51 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Fri, 21 Feb 2025 16:04:59 +0100 Subject: [PATCH 42/46] use newer action versions --- .github/workflows/check-generate-docs.yaml | 20 ++++---------------- .github/workflows/publishwiki.yaml | 20 ++++---------------- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/.github/workflows/check-generate-docs.yaml b/.github/workflows/check-generate-docs.yaml index c83948ba..c7cc4ddb 100644 --- a/.github/workflows/check-generate-docs.yaml +++ b/.github/workflows/check-generate-docs.yaml @@ -12,27 +12,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: 'true' - name: Set up GO - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: - go-version: 1.23 - - - name: Cache go modules - id: cache-go - uses: actions/cache@v3 - env: - cache-name: dependency-cache-1.23 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version: '1.23' + check-latest: true - name: Generate docs run: | diff --git a/.github/workflows/publishwiki.yaml b/.github/workflows/publishwiki.yaml index 092ba564..46c74044 100644 --- a/.github/workflows/publishwiki.yaml +++ b/.github/workflows/publishwiki.yaml @@ -12,27 +12,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: 'true' - name: Set up GO - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: - go-version: 1.23 - - - name: Cache go modules - id: cache-go - uses: actions/cache@v3 - env: - cache-name: dependency-cache-1.23 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version: '1.23' + check-latest: true - name: Generate wiki run: | From 99da3399741e16d1a0fdad27e306775735d505a0 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Fri, 21 Feb 2025 17:03:10 +0100 Subject: [PATCH 43/46] remove extra var definition from Makefile --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index c2fb30e2..875cc56d 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,6 @@ ifeq ($(UNAME_S),Darwin) OSFLAG += ./$(BIN)/$(BIN_NAME)_osx endif endif -PARAM ?= .PHONY: clean build unit-test-cover unit-test-cover-ext codeclimate lint test alltests From 032633b585a6667a2554cb682f714cc180681891 Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 24 Feb 2025 11:25:58 +0100 Subject: [PATCH 44/46] fix group sorting + footer --- generatedocs/pkg/common/common.go | 8 +++++++ generatedocs/pkg/genmd/generate.go | 34 ++++++++++++------------------ gopherciser.wiki | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/generatedocs/pkg/common/common.go b/generatedocs/pkg/common/common.go index 68810fa3..943418a7 100644 --- a/generatedocs/pkg/common/common.go +++ b/generatedocs/pkg/common/common.go @@ -21,6 +21,9 @@ type ( // Examples Information subsequent to parameters Examples string } + + GroupsEntries []GroupsEntry + // GroupsEntry definition of group of actions GroupsEntry struct { // Name of the group @@ -33,6 +36,11 @@ type ( } ) +// Implements Sort interface +func (entries GroupsEntries) Len() int { return len(entries) } +func (entries GroupsEntries) Less(i, j int) bool { return entries[i].Name < entries[j].Name } +func (entries GroupsEntries) Swap(i, j int) { entries[i], entries[j] = entries[j], entries[i] } + // Shared global variables for compile and generate documentation var ( // IgnoreActions list of "helper" actions to be ignored for documentation diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 84e6ae1b..79479a2e 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -7,7 +7,6 @@ import ( "io" "os" "path/filepath" - "slices" "sort" "strings" @@ -52,7 +51,7 @@ type ( Schedulers map[string]common.DocEntry Params map[string][]string Config map[string]common.DocEntry - Groups []common.GroupsEntry + Groups common.GroupsEntries Extra map[string]common.DocEntry } @@ -269,15 +268,14 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { common.Exit(err, ExitCodeFailedWriteResult) } - for name, title := range groups { - groupslink := fmt.Sprintf("[%s](%s)\n\n", title, name) - if _, err := configSidebar.WriteString(fmt.Sprintf(" - %s", groupslink)); err != nil { + for _, grouplink := range groups { + if _, err := configSidebar.WriteString(fmt.Sprintf(" - %s", grouplink)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := grouplinks.WriteString(groupslink); err != nil { + if _, err := grouplinks.WriteString(grouplink); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := configfile.WriteString(fmt.Sprintf("- %s", groupslink)); err != nil { + if _, err := configfile.WriteString(fmt.Sprintf("- %s", grouplink)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } } @@ -326,29 +324,23 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { } } -func generateWikiGroups(compiledDocs *CompiledDocs) map[string]string { - groups := make(map[string]string) - +func generateWikiGroups(compiledDocs *CompiledDocs) []string { // make sure generated same order every time - groupNames := make([]string, len(compiledDocs.Groups)) - mapForSorting := make(map[string]common.GroupsEntry, len(compiledDocs.Groups)) - for i := 0; i < len(compiledDocs.Groups); i++ { - groupNames[i] = compiledDocs.Groups[i].Name - mapForSorting[compiledDocs.Groups[i].Name] = compiledDocs.Groups[i] - } - slices.Sort(groupNames) + sort.Sort(compiledDocs.Groups) + groups := make([]string, len(compiledDocs.Groups)) - for _, groupName := range groupNames { - group := mapForSorting[groupName] + for i := 0; i < len(compiledDocs.Groups); i++ { + group := compiledDocs.Groups[i] + groups[i] = fmt.Sprintf("[%s](%s)\n\n", group.Title, group.Name) if verbose { fmt.Printf("Generating wiki actions for GROUP %s...\n", group.Name) } if err := createFolder(filepath.Join(wiki, GeneratedFolder, group.Name), false); err != nil { common.Exit(err, ExitCodeFailedCreateFolder) } - groups[group.Name] = group.Title generateWikiGroup(compiledDocs, group) } + return groups } @@ -426,7 +418,7 @@ func createFolder(path string, footer bool) error { } } if footer { - if err := os.WriteFile(fmt.Sprintf("%s/_Footer.md", path), []byte("The file has been generated, do not edit manually\n"), os.ModePerm); err != nil { + if err := os.WriteFile(fmt.Sprintf("%s/_Footer.md", path), []byte("This file has been automatically generated, do not edit manually\n"), os.ModePerm); err != nil { return err } } diff --git a/gopherciser.wiki b/gopherciser.wiki index 8e63aa3d..fc307b7f 160000 --- a/gopherciser.wiki +++ b/gopherciser.wiki @@ -1 +1 @@ -Subproject commit 8e63aa3d51b85c956024fbc97d4f4dbfb99608df +Subproject commit fc307b7fcc824d42f9110179b5cf9bce80555381 From b285a0113bfb876aaef94f05f88ca227d2e1d0df Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 24 Feb 2025 15:37:00 +0100 Subject: [PATCH 45/46] home and load scenario link naming --- generatedocs/data/config/main/description.md | 2 -- generatedocs/generated/documentation.go | 2 +- generatedocs/pkg/genmd/generate.go | 10 ++++++---- gopherciser.wiki | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/generatedocs/data/config/main/description.md b/generatedocs/data/config/main/description.md index 3ffa4891..f767ebba 100644 --- a/generatedocs/data/config/main/description.md +++ b/generatedocs/data/config/main/description.md @@ -1,3 +1 @@ -# Setting up load scenarios - A load scenario is defined in a JSON file with a number of sections. diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index 095ff637..80fcb2d0 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -383,7 +383,7 @@ var ( Examples: "### Examples\n\n#### Prefix login request type\n\n```json\n\"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"directory\": \"anydir\",\n \"prefix\": \"Nunit\"\n }\n}\n```\n\n#### Userlist login request type\n\n```json\n\"loginSettings\": {\n \"type\": \"userlist\",\n \"settings\": {\n \"userList\": [\n {\n \"username\": \"sim1@myhost.example\",\n \"directory\": \"anydir1\",\n \"password\": \"MyPassword1\"\n },\n {\n \"username\": \"sim2@myhost.example\"\n }\n ],\n \"directory\": \"anydir2\",\n \"password\": \"MyPassword2\"\n }\n}\n```\n\n#### Fromfile login request type\n\nReads a user list from file. 1 User per row of the and with the format `username;directory;password`. `directory` and `password` are optional, if none are defined for a user it will use the default values on settings (i.e. `defaultdir` and `defaultpassword`). If the used authentication type doesn't use `directory` or `password` these can be omitted.\n\nDefinition with default values:\n\n```json\n\"loginSettings\": {\n \"type\": \"fromfile\",\n \"settings\": {\n \"filename\": \"./myusers.txt\",\n \"directory\": \"defaultdir\",\n \"password\": \"defaultpassword\"\n }\n}\n```\n\nDefinition without default values:\n\n```json\n\"loginSettings\": {\n \"type\": \"fromfile\",\n \"settings\": {\n \"filename\": \"./myusers.txt\"\n }\n}\n```\n\nThis is a valid format of a file.\n\n```text\ntestuser1\ntestuser2;myspecialdirectory\ntestuser3;;somepassword\ntestuser4;specialdir;anotherpassword\ntestuser5;;A;d;v;a;n;c;e;d;;P;a;s;s;w;o;r;d;\n```\n\n*testuser1* will get default `directory` and `password`, *testuser3* and *testuser5* will get default `directory`.\n", }, "main": { - Description: "# Setting up load scenarios\n\nA load scenario is defined in a JSON file with a number of sections.\n", + Description: "A load scenario is defined in a JSON file with a number of sections.\n", Examples: "\n
\nExample\n\n```json\n{\n \"settings\": {\n \"timeout\": 300,\n \"logs\": {\n \"filename\": \"scenarioresult.tsv\"\n },\n \"outputs\": {\n \"dir\": \"\"\n }\n },\n \"loginSettings\": {\n \"type\": \"prefix\",\n \"settings\": {\n \"prefix\": \"testuser\"\n }\n },\n \"connectionSettings\": {\n \"mode\": \"ws\",\n \"server\": \"localhost\",\n \"virtualproxy\": \"header\",\n \"security\": true,\n \"allowuntrusted\": true,\n \"headers\": {\n \"Qlik-User-Header\": \"{{.UserName}}\"\n }\n },\n \"scheduler\": {\n \"type\": \"simple\",\n \"iterationtimebuffer\": {\n \"mode\": \"onerror\",\n \"duration\": \"10s\"\n },\n \"instance\": 1,\n \"reconnectsettings\": {\n \"reconnect\": false,\n \"backoff\": null\n },\n \"settings\": {\n \"executionTime\": -1,\n \"iterations\": 10,\n \"rampupDelay\": 7,\n \"concurrentUsers\": 10,\n \"reuseUsers\": false,\n \"onlyinstanceseed\": false\n }\n },\n \"scenario\": [\n {\n \"action\": \"openhub\",\n \"label\": \"open hub\",\n \"disabled\": false,\n \"settings\": {}\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"openapp\",\n \"label\": \"open app\",\n \"disabled\": false,\n \"settings\": {\n \"appmode\": \"name\",\n \"app\": \"myapp\",\n \"filename\": \"\",\n \"unique\": false\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"changesheet\",\n \"label\": \"change sheet to analysis sheet\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"QWERTY\"\n }\n },\n {\n \"action\": \"thinktime\",\n \"label\": \"think for 10-15s\",\n \"disabled\": false,\n \"settings\": {\n \"type\": \"uniform\",\n \"mean\": 15,\n \"dev\": 5\n }\n },\n {\n \"action\": \"select\",\n \"label\": \"select 1-10 values in object uvxyz\",\n \"disabled\": false,\n \"settings\": {\n \"id\": \"uvxyz\",\n \"type\": \"randomfromenabled\",\n \"accept\": false,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 10,\n \"dim\": 0,\n \"values\": null\n }\n }\n ]\n}\n```\n\n
", }, "scenario": { diff --git a/generatedocs/pkg/genmd/generate.go b/generatedocs/pkg/genmd/generate.go index 79479a2e..622e5b5d 100644 --- a/generatedocs/pkg/genmd/generate.go +++ b/generatedocs/pkg/genmd/generate.go @@ -18,6 +18,8 @@ var unitTestMode = false const ( GeneratedFolder = "generated" SessionVariableName = "sessionvariables" + ConfigLinkName = "Load test scenario" + ConfigLinkFile = "Load-test-scenario" ) type ( @@ -177,9 +179,9 @@ func generateWikiFromCompiled(compiledDocs *CompiledDocs) { func generateWikiConfigSections(compiledDocs *CompiledDocs) { if verbose { - fmt.Println("creating config.md...") + fmt.Printf("creating %s.md...\n", ConfigLinkFile) } - configfile, err := os.Create(fmt.Sprintf("%s/config.md", filepath.Join(wiki, GeneratedFolder))) + configfile, err := os.Create(fmt.Sprintf("%s/%s.md", filepath.Join(wiki, GeneratedFolder), ConfigLinkFile)) defer func() { if err := configfile.Close(); err != nil { _, _ = os.Stderr.WriteString(err.Error()) @@ -206,7 +208,7 @@ func generateWikiConfigSections(compiledDocs *CompiledDocs) { if err != nil { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := configSidebar.WriteString("[Home](home)\n\n- [Config](config)\n\n"); err != nil { + if _, err := configSidebar.WriteString(fmt.Sprintf("[Home](Home)\n\n- [%s](%s)\n\n", ConfigLinkName, ConfigLinkFile)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } @@ -362,7 +364,7 @@ func generateWikiGroup(compiledDocs *CompiledDocs, group common.GroupsEntry) { common.Exit(err, ExitCodeFailedWriteResult) } - if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](home)\n\n- [Config](config)\n\n - [Action Groups](groups)\n\n - [%s](%s)\n\n", group.Title, group.Name)); err != nil { + if _, err := actionsSidebar.WriteString(fmt.Sprintf("[Home](Home)\n\n- [%s](%s)\n\n - [Action Groups](groups)\n\n - [%s](%s)\n\n", ConfigLinkName, ConfigLinkFile, group.Title, group.Name)); err != nil { common.Exit(err, ExitCodeFailedWriteResult) } diff --git a/gopherciser.wiki b/gopherciser.wiki index fc307b7f..1046c742 160000 --- a/gopherciser.wiki +++ b/gopherciser.wiki @@ -1 +1 @@ -Subproject commit fc307b7fcc824d42f9110179b5cf9bce80555381 +Subproject commit 1046c7429d636b55dae23a7a0bce2681ebd0143e From 8f2963b86481883bce22c8ed5e5da8d6f785308d Mon Sep 17 00:00:00 2001 From: Daniel Larsson Date: Mon, 24 Feb 2025 15:47:41 +0100 Subject: [PATCH 46/46] small change in action description to test wiki publish on merge to master --- generatedocs/data/actions/select/description.md | 2 -- generatedocs/generated/documentation.go | 2 +- gopherciser.wiki | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/generatedocs/data/actions/select/description.md b/generatedocs/data/actions/select/description.md index 6cbf9aaa..9fcf4849 100644 --- a/generatedocs/data/actions/select/description.md +++ b/generatedocs/data/actions/select/description.md @@ -1,6 +1,4 @@ ## Select action Select random values in an object. - -See the [Limitations](README.md#limitations) section in the README.md file for limitations related to this action. \ No newline at end of file diff --git a/generatedocs/generated/documentation.go b/generatedocs/generated/documentation.go index 80fcb2d0..9a33c32d 100644 --- a/generatedocs/generated/documentation.go +++ b/generatedocs/generated/documentation.go @@ -127,7 +127,7 @@ var ( Examples: "### Example\n\n```json\n{\n \"action\": \"reload\",\n \"settings\": {\n \"mode\" : \"default\",\n \"partial\": false\n }\n}\n```\n", }, "select": { - Description: "## Select action\n\nSelect random values in an object.\n\nSee the [Limitations](README.md#limitations) section in the README.md file for limitations related to this action.\n ", + Description: "## Select action\n\nSelect random values in an object.\n ", Examples: "### Example\n\nRandomly select among all the values in object `RZmvzbF`.\n\n```json\n{\n \"label\": \"ListBox Year\",\n \"action\": \"Select\",\n \"settings\": {\n \"id\": \"RZmvzbF\",\n \"type\": \"RandomFromAll\",\n \"accept\": true,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 3,\n \"dim\": 0\n }\n}\n```\n\nRandomly select among all the enabled values (a.k.a \"white\" values) in object `RZmvzbF`.\n\n```json\n{\n \"label\": \"ListBox Year\",\n \"action\": \"Select\",\n \"settings\": {\n \"id\": \"RZmvzbF\",\n \"type\": \"RandomFromEnabled\",\n \"accept\": true,\n \"wrap\": false,\n \"min\": 1,\n \"max\": 3,\n \"dim\": 0\n }\n}\n```\n\n#### Statically selecting specific values\n\nThis example selects specific element values in object `RZmvzbF`. These are the values which can be seen in a selection when e.g. inspecting traffic, it is not the data values presented to the user. E.g. when loading a table in the following order by a Sense loadscript:\n\n```\nBeta\nAlpha\nGamma\n```\n\nwhich might be presented to the user sorted as\n\n```\nAlpha\nBeta\nGamma\n```\n\nThe element values will be Beta=0, Alpha=1 and Gamma=2.\n\nTo statically select \"Gamma\" in this case:\n\n```json\n{\n \"label\": \"Select Gammma\",\n \"action\": \"Select\",\n \"settings\": {\n \"id\": \"RZmvzbF\",\n \"type\": \"values\",\n \"accept\": true,\n \"wrap\": false,\n \"values\" : [2],\n \"dim\": 0\n }\n}\n```\n", }, "setscript": { diff --git a/gopherciser.wiki b/gopherciser.wiki index 1046c742..faf7fc97 160000 --- a/gopherciser.wiki +++ b/gopherciser.wiki @@ -1 +1 @@ -Subproject commit 1046c7429d636b55dae23a7a0bce2681ebd0143e +Subproject commit faf7fc970309bda33d4b5e869c63bd837ef541dd