diff --git a/flake.nix b/flake.nix index 81d8b79ff..caad7858a 100644 --- a/flake.nix +++ b/flake.nix @@ -83,11 +83,14 @@ uv # go client # go + playwright-driver.browsers ]; shellHook = '' # If it exists from the host system, kill it unset DEVELOPER_DIR + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true ''; }; diff --git a/makefile b/makefile index 648787cfb..665d80079 100644 --- a/makefile +++ b/makefile @@ -246,6 +246,16 @@ test: setup frontend superposition 'http://localhost:8080/health' 2>&1 > /dev/null cd tests && bun test:clean $(MAKE) bindings-test + @echo "Installing cucumber test dependencies" + cd tests/cucumber && npm ci + @echo "Running API tests via cucumber" + cd tests/cucumber && npm run test:api + @echo "Running UI tests via cucumber" + @if [ -z "$$PLAYWRIGHT_BROWSERS_PATH" ]; then \ + echo "Installing Playwright browsers (not in nix shell)..."; \ + cd tests/cucumber && npx playwright install --with-deps chromium; \ + fi + cd tests/cucumber && npm run test:ui $(MAKE) kill ## npm run test diff --git a/tests/cucumber/.gitignore b/tests/cucumber/.gitignore new file mode 100644 index 000000000..5fe8fb1f1 --- /dev/null +++ b/tests/cucumber/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +reports/ +*.lock diff --git a/tests/cucumber/cucumber.js b/tests/cucumber/cucumber.js new file mode 100644 index 000000000..f1903eb16 --- /dev/null +++ b/tests/cucumber/cucumber.js @@ -0,0 +1,23 @@ +/** + * Cucumber profiles for API and UI testing. + * + * Both profiles run the SAME Gherkin feature files from features/. + * The only difference is which step definitions and support files are loaded: + * + * npm run test:api → step_definitions/ + support/ (drives the SDK) + * npm run test:ui → step_definitions_ui/ + support_ui/ (drives Playwright) + */ +export default function () { + return { + api: { + import: ["step_definitions/**/*.ts", "support/**/*.ts"], + format: ["progress", "html:reports/api-report.html"], + formatOptions: { snippetInterface: "async-await" }, + }, + ui: { + import: ["step_definitions_ui/**/*.ts", "support_ui/**/*.ts"], + format: ["progress", "html:reports/ui-report.html"], + formatOptions: { snippetInterface: "async-await" }, + }, + }; +} diff --git a/tests/cucumber/features/config.feature b/tests/cucumber/features/config.feature new file mode 100644 index 000000000..4536b5b43 --- /dev/null +++ b/tests/cucumber/features/config.feature @@ -0,0 +1,30 @@ +@api @config @config_retrieval +Feature: Configuration Retrieval and Versioning + As a developer + I want to retrieve configurations and manage versions + So that I can serve the right config values to my application + + Background: + Given an organisation and workspace exist + And a test default config exists for config retrieval + + # ── GetConfig ────────────────────────────────────────────────────── + + Scenario: Get configuration with context + When I get the config with the test config key prefix + Then the operation should succeed + And the response should have a version + + Scenario: Pin workspace to a config version and verify + Given I know the current config version + When I pin the workspace to that config version + And I get the config again + Then the config version should match the pinned version + When I unpin the workspace config version + Then the workspace config version should be unset + + # ── ListVersions ─────────────────────────────────────────────────── + + Scenario: List configuration versions + When I list config versions with count 10 and page 1 + Then the operation should succeed diff --git a/tests/cucumber/features/context.feature b/tests/cucumber/features/context.feature new file mode 100644 index 000000000..0eb9f05af --- /dev/null +++ b/tests/cucumber/features/context.feature @@ -0,0 +1,65 @@ +@api @context +Feature: Context Management + As a configuration administrator + I want to manage contexts with condition-based overrides + So that configurations vary based on runtime conditions + + Background: + Given an organisation and workspace exist + And dimensions and default configs are set up for context tests + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a context with overrides + When I create a context with condition "os" equals "android" and override "ctx-config-key" to "android-value" + Then the operation should succeed + And the response should have a context ID + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get a context by ID + Given a context exists with condition "os" equals "ios" and override "ctx-config-key" to "ios-value" + When I get the context by its ID + Then the response should include the override for "ctx-config-key" + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List all contexts + Given a context exists with condition "os" equals "web" and override "ctx-config-key" to "web-value" + When I list all contexts + Then the response should contain a list + And the list should contain the created context + + # ── Update Override ──────────────────────────────────────────────── + + Scenario: Update a context override + Given a context exists with condition "os" equals "android" and override "ctx-config-key" to "old-value" + When I update the context override for "ctx-config-key" to "new-value" + Then the operation should succeed + + # ── Move Context ─────────────────────────────────────────────────── + + Scenario: Move a context to a different condition + Given a context exists with condition "os" equals "android" and override "ctx-config-key" to "move-value" + When I move the context to condition "os" equals "ios" + Then the operation should succeed + + # ── Delete ───────────────────────────────────────────────────────── + + Scenario: Delete a context + Given a context exists with condition "os" equals "android" and override "ctx-config-key" to "delete-value" + When I delete the context + Then the operation should succeed + + # ── Bulk Operations ──────────────────────────────────────────────── + + Scenario: Perform bulk context operations + When I perform a bulk operation to create contexts for "os" values "android,ios" + Then the operation should succeed + + # ── Weight Recompute ─────────────────────────────────────────────── + + Scenario: Recompute context weights + Given contexts exist for weight recompute + When I trigger weight recomputation + Then the operation should succeed diff --git a/tests/cucumber/features/default_config.feature b/tests/cucumber/features/default_config.feature new file mode 100644 index 000000000..eafb577c1 --- /dev/null +++ b/tests/cucumber/features/default_config.feature @@ -0,0 +1,74 @@ +@api @config @default_config +Feature: Default Configuration Management + As a developer + I want to manage default configurations with schema validation + So that my application has well-defined configuration defaults + + Background: + Given an organisation and workspace exist + And validation functions are set up + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a valid default config + When I create a default config with key "test-key" and value: + | name | Test User | + | age | 30 | + And the schema requires "name" as string and "age" as number with minimum 0 + Then the operation should succeed + + Scenario: Fail to create config with invalid schema type + When I create a default config with key "test-key-2" and an invalid schema type "invalid-type" + Then the operation should fail with error matching "Invalid JSON schema" + + Scenario: Fail to create config with empty schema + When I create a default config with key "test-key-2" and an empty schema + Then the operation should fail with error matching "Schema cannot be empty" + + Scenario: Fail to create config when value violates schema constraints + When I create a default config with key "test-key-2" where age is -5 but minimum is 0 + Then the operation should fail with error matching "value is too small, minimum is 0" + + Scenario: Fail to create config when function validation fails + When I create a default config with key "test-key-2" using validation function "false_validation" + Then the operation should fail with error matching "validation failed" + + Scenario: Create config with value_compute function + When I create a default config with key "test-key-3" using compute function "auto_fn" + Then the operation should succeed + And the response should have value_compute_function_name "auto_fn" + + Scenario: Fail to create config with non-existent function + When I create a default config with key "test-key-2" using validation function "non_existent_function" + Then the operation should fail with error matching "published code does not exist" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update an existing default config value + Given a default config exists with key "test-key-upd" and value "Test User" age 30 + When I update the default config "test-key-upd" with value "Updated User" age 35 + Then the response value should have name "Updated User" and age 35 + + Scenario: Update schema and value together + Given a default config exists with key "test-key-upd2" and value "Test User" age 30 + When I update default config "test-key-upd2" schema to add email field and set value with email "updated@example.com" + Then the response value should include email "updated@example.com" + + Scenario: Fail to update a non-existent key + When I update default config "non_existent_key" with a new value + Then the operation should fail with error matching "No record found" + + Scenario: Fail to update with invalid schema + Given a default config exists with key "test-key-upd3" and value "Test User" age 30 + When I update default config "test-key-upd3" schema to an invalid type + Then the operation should fail with error matching "Invalid JSON schema" + + Scenario: Fail to update when value misses required field + Given a default config exists with key "test-key-upd4" and requires name and email + When I update default config "test-key-upd4" value without the required email field + Then the operation should fail with error matching "required property" + + Scenario: Update config with a validation function + Given a default config exists with key "test-key-upd5" and value "Test User" age 30 + When I update default config "test-key-upd5" validation function to "true_function" + Then the response should have value_validation_function_name "true_function" diff --git a/tests/cucumber/features/dimension.feature b/tests/cucumber/features/dimension.feature new file mode 100644 index 000000000..e7b469064 --- /dev/null +++ b/tests/cucumber/features/dimension.feature @@ -0,0 +1,58 @@ +@api @dimension +Feature: Dimension Management + As a configuration administrator + I want to manage dimensions (context keys) + So that I can define the axes along which configuration varies + + Background: + Given an organisation and workspace exist + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a string dimension + When I create a dimension with name "test-dim-str" and schema type "string" + Then the operation should succeed + And the response should have dimension name "test-dim-str" + + Scenario: Create an enum dimension + When I create a dimension with name "test-dim-enum" and enum values "small,big,otherwise" + Then the operation should succeed + + Scenario: Create a boolean dimension + When I create a dimension with name "test-dim-bool" and schema type "boolean" + Then the operation should succeed + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get a dimension by name + Given a dimension "test-dim-get" exists with schema type "string" + When I get dimension "test-dim-get" + Then the response should have dimension name "test-dim-get" + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List all dimensions + Given a dimension "test-dim-list" exists with schema type "string" + When I list all dimensions + Then the response should contain a list + And the list should contain dimension "test-dim-list" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update a dimension's description + Given a dimension "test-dim-upd" exists with schema type "string" + When I update dimension "test-dim-upd" description to "Updated description" + Then the operation should succeed + + # ── Delete ───────────────────────────────────────────────────────── + + Scenario: Delete a dimension + Given a dimension "test-dim-del" exists with schema type "string" + When I delete dimension "test-dim-del" + Then the operation should succeed + + # ── Error Cases ──────────────────────────────────────────────────── + + Scenario: Fail to create a dimension with invalid schema + When I create a dimension with name "test-dim-invalid" and schema type "invalid-type" + Then the operation should fail diff --git a/tests/cucumber/features/experiment.feature b/tests/cucumber/features/experiment.feature new file mode 100644 index 000000000..64edceb98 --- /dev/null +++ b/tests/cucumber/features/experiment.feature @@ -0,0 +1,69 @@ +@api @experiment +Feature: Experiment Management + As a product manager + I want to manage A/B test experiments + So that I can test configuration variants with controlled traffic + + Background: + Given an organisation and workspace exist + And dimensions and default configs are set up for experiment tests + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create an experiment with control and experimental variants + When I create an experiment with name "exp-test" and context "os" equals "android" + And the experiment has a control variant with override "exp-config-key" = "control-val" + And the experiment has an experimental variant with override "exp-config-key" = "experimental-val" + Then the operation should succeed + And the response should have experiment status "CREATED" + And the response should have 2 variants + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get an experiment by ID + Given an experiment "exp-get" exists with context "os" equals "ios" + When I get the experiment by its ID + Then the response should have the experiment name + And the response should have experiment status "CREATED" + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List experiments + Given an experiment "exp-list" exists with context "os" equals "android" + When I list experiments + Then the response should contain a list + And the list should contain the created experiment + + # ── Ramp ─────────────────────────────────────────────────────────── + + Scenario: Ramp an experiment to 50% traffic + Given an experiment "exp-ramp" exists with context "os" equals "android" + When I ramp the experiment to 50 percent traffic + Then the operation should succeed + And the experiment status should be "INPROGRESS" + + # ── Update Overrides ─────────────────────────────────────────────── + + Scenario: Update experiment variant overrides + Given an experiment "exp-override" exists with context "os" equals "android" + When I update the experimental variant override for "exp-config-key" to "updated-val" + Then the operation should succeed + + # ── Conclude ─────────────────────────────────────────────────────── + + Scenario: Conclude an experiment + Given an experiment "exp-conclude" exists and is ramped to 50 percent + When I conclude the experiment with the experimental variant + Then the experiment status should be "CONCLUDED" + + # ── Discard ──────────────────────────────────────────────────────── + + Scenario: Discard a created experiment + Given an experiment "exp-discard" exists with context "os" equals "ios" + When I discard the experiment + Then the experiment status should be "DISCARDED" + + Scenario: Discard an in-progress experiment + Given an experiment "exp-discard-prog" exists and is ramped to 50 percent + When I discard the experiment + Then the experiment status should be "DISCARDED" diff --git a/tests/cucumber/features/experiment_group.feature b/tests/cucumber/features/experiment_group.feature new file mode 100644 index 000000000..30c3e0537 --- /dev/null +++ b/tests/cucumber/features/experiment_group.feature @@ -0,0 +1,103 @@ +@api @experiment @experiment_group +Feature: Experiment Group Management + As a product manager + I want to group related experiments together + So that I can manage traffic and lifecycle for experiment sets + + Background: + Given an organisation and workspace exist + And dimensions and default configs are set up for experiment group tests + And experiments are set up for group tests + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create an experiment group with valid members + When I create an experiment group with name "test-group" and member experiments + Then the operation should succeed + And the response should contain the member experiment IDs + And the response traffic percentage should be 100 + + Scenario: Create an experiment group with no members + When I create an experiment group with name "empty-group" and no members + Then the operation should succeed + And the response member list should be empty + + Scenario: Fail to create group with in-progress experiment + When I create an experiment group including an in-progress experiment + Then the operation should fail with error matching "not in the created stage" + + Scenario: Fail to create group with conflicting context experiment + When I create an experiment group including an experiment with conflicting context + Then the operation should fail with error matching "contexts do not match" + + Scenario: Fail to create group with traffic percentage over 100 + When I create an experiment group with traffic percentage 101 + Then the operation should fail + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get an experiment group by ID + Given an experiment group exists + When I get the experiment group by its ID + Then the operation should succeed + And the response should have a group name + + Scenario: Fail to get a non-existent experiment group + When I get an experiment group with ID "123" + Then the operation should fail with error matching "No records found" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update experiment group traffic percentage + Given an experiment group exists + When I update the experiment group traffic percentage to 75 + Then the response traffic percentage should be 75 + + Scenario: Update experiment group description + Given an experiment group exists + When I update the experiment group description to "Updated description" + Then the response description should be "Updated description" + + Scenario: Fail to update a non-existent group + When I update experiment group "123" traffic percentage to 50 + Then the operation should fail with error matching "No records found" + + # ── Add/Remove Members ───────────────────────────────────────────── + + Scenario: Add members to an experiment group + Given an experiment group exists with no members + When I add a valid experiment to the group + Then the response should contain the added experiment ID + + Scenario: Remove members from an experiment group + Given an experiment group exists with members + When I remove a member from the group + Then the response should not contain the removed experiment ID + + Scenario: Fail to add an in-progress experiment to a group + Given an experiment group exists + When I add an in-progress experiment to the group + Then the operation should fail with error matching "not in the created stage" + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List experiment groups + Given an experiment group exists + When I list experiment groups + Then the response should contain a list + And the list should contain the created group + + Scenario: List experiment groups sorted by created_at descending + When I list experiment groups sorted by "created_at" in "DESC" order + Then the response should be sorted by created_at descending + + # ── Delete ───────────────────────────────────────────────────────── + + Scenario: Fail to delete a group with active members + Given an experiment group exists with members + When I delete the experiment group + Then the operation should fail with error matching "has members" + + Scenario: Fail to delete a non-existent group + When I delete experiment group "22" + Then the operation should fail with error matching "No records found" diff --git a/tests/cucumber/features/function.feature b/tests/cucumber/features/function.feature new file mode 100644 index 000000000..96939eabf --- /dev/null +++ b/tests/cucumber/features/function.feature @@ -0,0 +1,66 @@ +@api @function +Feature: Function Management + As a developer + I want to manage validation and compute functions + So that I can add custom logic to configuration handling + + Background: + Given an organisation and workspace exist + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a value_validation function + When I create a value_validation function named "test-val-func" with code that validates key "test-dimension" + Then the operation should succeed + And the response should have function type "VALUE_VALIDATION" + + Scenario: Create a value_compute function + When I create a value_compute function named "test-comp-func" with code that returns computed values + Then the operation should succeed + And the response should have function type "VALUE_COMPUTE" + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get a function by name + Given a value_validation function "test-get-func" exists + When I get function "test-get-func" + Then the response should have function name "test-get-func" + And the response should have function type "VALUE_VALIDATION" + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List functions + Given a value_validation function "test-list-func" exists + When I list functions with count 10 and page 1 + Then the response should contain a list with at least 1 item + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update a function's code + Given a value_validation function "test-upd-func" exists + When I update function "test-upd-func" with new validation code + Then the operation should succeed + And the response should have description "Updated value_validation function" + + # ── Publish ──────────────────────────────────────────────────────── + + Scenario: Publish a function + Given a value_validation function "test-pub-func" exists + When I publish function "test-pub-func" + Then the operation should succeed + And the response should have a "published_at" property + And the response should have a "published_code" property + + Scenario: Fail to publish a non-existent function + When I publish function "non-existent-function" + Then the operation should fail with error matching "No records found" + + # ── Error Cases ──────────────────────────────────────────────────── + + Scenario: Fail to create a function with invalid code + When I create a value_validation function named "invalid-func" with code "invalid code" + Then the operation should fail + + Scenario: Create a value_compute function with string return type (validated at runtime) + When I create a value_compute function named "invalid-return" with code that returns a string + Then the operation should succeed diff --git a/tests/cucumber/features/organisation.feature b/tests/cucumber/features/organisation.feature new file mode 100644 index 000000000..f42052ddd --- /dev/null +++ b/tests/cucumber/features/organisation.feature @@ -0,0 +1,64 @@ +@api @organisation +Feature: Organisation Management + As a platform administrator + I want to manage organisations + So that I can onboard and maintain tenant organisations + + # ── Create ──────────────────────────────────────────────────────── + + Scenario: Create a new organisation + When I create an organisation with name "tmporg" and admin email "test@gmail.com" + Then the operation should succeed + And the response should have an "id" property + + Scenario: Fail to create organisation with invalid input + When I create an organisation with name "" and admin email "invalid-email" + Then the operation should fail with error matching "Json" + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get an organisation by ID + Given an organisation exists with name "tmporg" and admin email "test@gmail.com" + When I get the organisation by its ID + Then the response should have name "tmporg" + And the response should have admin email "test@gmail.com" + + Scenario: Fail to get a non-existent organisation + When I get an organisation with ID "non-existent-id" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to get an organisation with empty ID + When I get an organisation with ID "" + Then the operation should fail with error matching "Empty value provided" + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List all organisations + Given an organisation exists with name "tmporg" and admin email "test@gmail.com" + When I list all organisations + Then the response should contain a list + And the list should contain the created organisation + + Scenario: List organisations with pagination + When I list organisations with count 1 and page 1 + Then the response should contain a list with at most 1 item + + Scenario: Fail to list organisations with negative page + When I list organisations with count 1 and page -1 + Then the operation should fail with error matching "Page should be greater than 0" + + Scenario: Fail to list organisations with negative count + When I list organisations with count -1 and page 0 + Then the operation should fail with error matching "Count should be greater than 0" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update an organisation's admin email + Given an organisation exists with name "tmporg" and admin email "test@gmail.com" + When I update the organisation's admin email to "updated-test@gmail.com" + Then the response should have admin email "updated-test@gmail.com" + And getting the organisation by ID should show admin email "updated-test@gmail.com" + + Scenario: Fail to update a non-existent organisation + When I update organisation "non-existent-id" admin email to "test@gmail.com" + Then the operation should fail with error matching "No records found" diff --git a/tests/cucumber/features/resolve_config.feature b/tests/cucumber/features/resolve_config.feature new file mode 100644 index 000000000..d8f14588a --- /dev/null +++ b/tests/cucumber/features/resolve_config.feature @@ -0,0 +1,15 @@ +@api @config @resolve_config +Feature: Config Resolution with Identifier Bucketing + As a developer + I want to resolve configurations using identifiers + So that users are consistently bucketed into experiment variants + + Background: + Given an organisation and workspace exist + And a dimension, default config, and experiment are set up for bucketing tests + + Scenario: Resolve config with identifier returns bucketed value + When I resolve the config with the test identifier and matching context + Then the operation should succeed + And the response should have a version + And the config value should be either the default or experimental value diff --git a/tests/cucumber/features/secret.feature b/tests/cucumber/features/secret.feature new file mode 100644 index 000000000..c95e0b2aa --- /dev/null +++ b/tests/cucumber/features/secret.feature @@ -0,0 +1,79 @@ +@api @secret +Feature: Secret Management + As a developer + I want to manage encrypted secrets accessible in functions + So that sensitive values are securely stored and used + + Background: + Given an organisation and workspace exist + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a secret + When I create a secret named "TEST_API_KEY_SECRET" with value "test-key-12345" + Then the operation should succeed + And the response should have secret name "TEST_API_KEY_SECRET" + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get a secret by name (value should not be returned) + Given a secret "GET_TEST_SECRET" exists with value "test-value" + When I get secret "GET_TEST_SECRET" + Then the response should have secret name "GET_TEST_SECRET" + And the secret value should not be returned + + # ── Update and Verify ────────────────────────────────────────────── + + Scenario: Verify secret value update via function + Given a secret "UPDATE_VERIFY_SECRET" exists with value "original-secret-value" + And a compute function exists that reads the secret "UPDATE_VERIFY_SECRET" + When I test the compute function + Then the function output should contain "original-secret-value" + When I update secret "UPDATE_VERIFY_SECRET" value to "updated-secret-value" + And I test the compute function again + Then the function output should contain "updated-secret-value" + + # ── Delete ───────────────────────────────────────────────────────── + + Scenario: Delete a secret + Given a secret "DELETE_TEST_SECRET" exists with value "test-value" + When I delete secret "DELETE_TEST_SECRET" + Then the operation should succeed + And getting secret "DELETE_TEST_SECRET" should fail with "No records found" + + # ── Error Cases ──────────────────────────────────────────────────── + + Scenario: Fail to create a duplicate secret + Given a secret "DUP_TEST_SECRET" exists with value "test-value" + When I create a secret named "DUP_TEST_SECRET" with value "different-value" + Then the operation should fail with error matching "duplicate key" + + Scenario: Fail to get a non-existent secret + When I get secret "NON_EXISTENT_SECRET" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to update a non-existent secret + When I update secret "NON_EXISTENT_SECRET" value to "new-value" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to delete a non-existent secret + When I delete secret "NON_EXISTENT_SECRET" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to create secret with empty name + When I create a secret named "" with value "test-value" + Then the operation should fail with error matching "Json deserialize error" + + Scenario Outline: Fail to create secret with invalid name pattern + When I create a secret named "" with value "test-value" + Then the operation should fail with error matching "Json deserialize error" + + Examples: + | invalid_name | + | invalid-name-with-dashes | + | invalid name with spaces | + | invalid.name.with.dots | + | 123_STARTS_WITH_NUMBER | + | special@chars#not$allowed | + | lowercase_not_allowed | + | Mixed_Case_Name | diff --git a/tests/cucumber/features/type_template.feature b/tests/cucumber/features/type_template.feature new file mode 100644 index 000000000..91b8d9f79 --- /dev/null +++ b/tests/cucumber/features/type_template.feature @@ -0,0 +1,49 @@ +@api @type_template +Feature: Type Template Management + As a configuration administrator + I want to manage reusable type templates + So that dimensions and configs can reference shared schema types + + Background: + Given an organisation and workspace exist + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List type templates + When I list type templates + Then the operation should succeed + And the response should contain a type template list + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a Boolean type template + When I create a type template named "Boolean" with schema type "boolean" + Then the operation should succeed + And the response should have type name "Boolean" + And the response schema type should be "boolean" + + Scenario: Create a Pattern type template + When I create a type template named "Pattern" with pattern ".*" + Then the operation should succeed + And the response schema should have pattern ".*" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update a type template with range constraints + Given a type template "Decimal" exists with schema type "number" + When I update type template "Decimal" with minimum 0 and maximum 100 + Then the response schema should have minimum 0 and maximum 100 + + # ── Delete ───────────────────────────────────────────────────────── + + Scenario: Delete a type template + Given a type template "ToDelete" exists with schema type "boolean" + When I delete type template "ToDelete" + Then the operation should succeed + And listing type templates should not include "ToDelete" + + # ── Error Cases ──────────────────────────────────────────────────── + + Scenario: Fail to create a type template with invalid schema + When I create a type template named "Invalid" with schema type "invalid" + Then the operation should fail diff --git a/tests/cucumber/features/variable.feature b/tests/cucumber/features/variable.feature new file mode 100644 index 000000000..6f7a0cf45 --- /dev/null +++ b/tests/cucumber/features/variable.feature @@ -0,0 +1,90 @@ +@api @variable +Feature: Variable Management + As a developer + I want to manage variables accessible in functions + So that functions can use dynamic runtime values + + Background: + Given an organisation and workspace exist + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a variable + When I create a variable named "TEST_API_KEY" with value "test-key-12345" + Then the operation should succeed + And the response should have variable name "TEST_API_KEY" + And the response should have variable value "test-key-12345" + + # ── Get ──────────────────────────────────────────────────────────── + + Scenario: Get a variable by name + Given a variable "GET_TEST_VAR" exists with value "test-value" + When I get variable "GET_TEST_VAR" + Then the response should have variable name "GET_TEST_VAR" + And the response should have variable value "test-value" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update a variable's value + Given a variable "UPDATE_TEST_VAR" exists with value "original-value" + When I update variable "UPDATE_TEST_VAR" value to "updated-value" + Then the response should have variable value "updated-value" + + Scenario: Update a variable's description + Given a variable "DESC_TEST_VAR" exists with value "test-value" and description "Original description" + When I update variable "DESC_TEST_VAR" description to "Updated description" + Then the response description should be "Updated description" + + # ── Delete ───────────────────────────────────────────────────────── + + Scenario: Delete a variable + Given a variable "DELETE_TEST_VAR" exists with value "test-value" + When I delete variable "DELETE_TEST_VAR" + Then the operation should succeed + And getting variable "DELETE_TEST_VAR" should fail with "No records found" + + # ── Error Cases ──────────────────────────────────────────────────── + + Scenario: Fail to create a duplicate variable + Given a variable "DUP_TEST_VAR" exists with value "test-value" + When I create a variable named "DUP_TEST_VAR" with value "different-value" + Then the operation should fail with error matching "duplicate key" + + Scenario: Fail to get a non-existent variable + When I get variable "NON_EXISTENT_VARIABLE" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to update a non-existent variable + When I update variable "NON_EXISTENT_VARIABLE" value to "new-value" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to delete a non-existent variable + When I delete variable "NON_EXISTENT_VARIABLE" + Then the operation should fail with error matching "No records found" + + Scenario: Fail to create variable with empty name + When I create a variable named "" with value "test-value" + Then the operation should fail with error matching "Json deserialize error" + + Scenario Outline: Fail to create variable with invalid name pattern + When I create a variable named "" with value "test-value" + Then the operation should fail with error matching "Json deserialize error" + + Examples: + | invalid_name | + | invalid-name-with-dashes | + | invalid name with spaces | + | invalid.name.with.dots | + | 123_STARTS_WITH_NUMBER | + | special@chars#not$allowed | + | lowercase_not_allowed | + | Mixed_Case_Name | + + # ── Function Integration ─────────────────────────────────────────── + + Scenario: Use a variable in a function and verify access + Given a variable "API_KEY_FUNC" exists with value "secret-api-key-12345" + And a function "test_var_func" exists that reads VARS.API_KEY_FUNC + When I test the function "test_var_func" + Then the function output should be true + And the function stdout should contain "secret-api-key-12345" diff --git a/tests/cucumber/features/workspace.feature b/tests/cucumber/features/workspace.feature new file mode 100644 index 000000000..d831ef91c --- /dev/null +++ b/tests/cucumber/features/workspace.feature @@ -0,0 +1,58 @@ +@api @workspace +Feature: Workspace Management + As a platform administrator + I want to manage workspaces within an organisation + So that I can isolate configuration environments + + Background: + Given an organisation exists for workspace tests + + # ── List ─────────────────────────────────────────────────────────── + + Scenario: List workspaces + When I list workspaces with count 10 and page 1 + Then the response should contain a workspace list + And the response should have a "total_items" count + + # ── Create ───────────────────────────────────────────────────────── + + Scenario: Create a new workspace + When I create a workspace with name "cucumbertestws" and admin email "admin@example.com" + Then the operation should succeed + And the response should have workspace name "cucumbertestws" + And the response should have workspace status "ENABLED" + And the response should have workspace admin email "admin@example.com" + + # ── Get via List ──────────────────────────────────────────────────── + + Scenario: Find a created workspace in the list + Given a workspace exists with name "cucumbertestws" + When I list workspaces with count 100 and page 1 + Then the list should contain workspace "cucumbertestws" + + # ── Update ───────────────────────────────────────────────────────── + + Scenario: Update workspace admin email + Given a workspace exists with name "cucumbertestws" + When I update workspace "cucumbertestws" admin email to "updated-admin@example.com" + Then the response should have workspace admin email "updated-admin@example.com" + + # ── Filters ──────────────────────────────────────────────────────── + + Scenario: List workspaces filtered by ENABLED status + When I list workspaces filtered by status "ENABLED" + Then all returned workspaces should have status "ENABLED" + + # ── Error Cases ──────────────────────────────────────────────────── + + Scenario: List workspaces with invalid organisation ID returns empty + When I list workspaces for organisation "non-existent-org" + Then the operation should succeed + + Scenario: Fail to create workspace with invalid data + When I create a workspace with name "" and admin email "invalid-email" + Then the operation should fail + + Scenario: Fail to create workspace with special characters + When I create a workspace with name "test-special-chars@!#" and admin email "admin@example.com" + Then the operation should fail diff --git a/tests/cucumber/package-lock.json b/tests/cucumber/package-lock.json new file mode 100644 index 000000000..f2b6754a8 --- /dev/null +++ b/tests/cucumber/package-lock.json @@ -0,0 +1,2280 @@ +{ + "name": "superposition-cucumber-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "superposition-cucumber-tests", + "version": "1.0.0", + "dependencies": { + "@cucumber/cucumber": "^11.0.0", + "@juspay/superposition-sdk": "file:../../clients/javascript/sdk", + "@playwright/test": "1.52.0", + "playwright": "1.52.0", + "tsx": "^4.19.0", + "typescript": "^5.8.3" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } + }, + "../../clients/javascript/sdk": { + "name": "superposition-sdk", + "version": "0.0.1", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.731.0", + "@aws-sdk/middleware-host-header": "3.731.0", + "@aws-sdk/middleware-logger": "3.731.0", + "@aws-sdk/middleware-recursion-detection": "3.731.0", + "@aws-sdk/middleware-user-agent": "3.731.0", + "@aws-sdk/types": "3.731.0", + "@aws-sdk/util-user-agent-browser": "3.731.0", + "@aws-sdk/util-user-agent-node": "3.731.0", + "@smithy/config-resolver": "^4.0.1", + "@smithy/core": "^3.1.1", + "@smithy/fetch-http-handler": "^5.0.1", + "@smithy/hash-node": "^4.0.1", + "@smithy/invalid-dependency": "^4.0.1", + "@smithy/middleware-content-length": "^4.0.1", + "@smithy/middleware-retry": "^4.0.3", + "@smithy/middleware-serde": "^4.0.1", + "@smithy/middleware-stack": "^4.0.1", + "@smithy/node-config-provider": "^4.0.1", + "@smithy/node-http-handler": "^4.0.2", + "@smithy/protocol-http": "^5.0.1", + "@smithy/smithy-client": "^4.1.2", + "@smithy/types": "^4.1.0", + "@smithy/url-parser": "^4.0.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.3", + "@smithy/util-defaults-mode-node": "^4.0.3", + "@smithy/util-middleware": "^4.0.1", + "@smithy/util-retry": "^4.0.1", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@tsconfig/node18": "18.2.4", + "@types/node": "^18.19.69", + "concurrently": "7.0.0", + "downlevel-dts": "0.10.1", + "rimraf": "^3.0.0", + "typescript": "~5.2.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cucumber/ci-environment": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-10.0.1.tgz", + "integrity": "sha512-/+ooDMPtKSmvcPMDYnMZt4LuoipfFfHaYspStI4shqw8FyKcfQAmekz6G+QKWjQQrvM+7Hkljwx58MEwPCwwzg==", + "license": "MIT" + }, + "node_modules/@cucumber/cucumber": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-11.3.0.tgz", + "integrity": "sha512-1YGsoAzRfDyVOnRMTSZP/EcFsOBElOKa2r+5nin0DJAeK+Mp0mzjcmSllMgApGtck7Ji87wwy3kFONfHUHMn4g==", + "license": "MIT", + "dependencies": { + "@cucumber/ci-environment": "10.0.1", + "@cucumber/cucumber-expressions": "18.0.1", + "@cucumber/gherkin": "30.0.4", + "@cucumber/gherkin-streams": "5.0.1", + "@cucumber/gherkin-utils": "9.2.0", + "@cucumber/html-formatter": "21.10.1", + "@cucumber/junit-xml-formatter": "0.7.1", + "@cucumber/message-streams": "4.0.1", + "@cucumber/messages": "27.2.0", + "@cucumber/tag-expressions": "6.1.2", + "assertion-error-formatter": "^3.0.0", + "capital-case": "^1.0.4", + "chalk": "^4.1.2", + "cli-table3": "0.6.5", + "commander": "^10.0.0", + "debug": "^4.3.4", + "error-stack-parser": "^2.1.4", + "figures": "^3.2.0", + "glob": "^10.3.10", + "has-ansi": "^4.0.1", + "indent-string": "^4.0.0", + "is-installed-globally": "^0.4.0", + "is-stream": "^2.0.0", + "knuth-shuffle-seeded": "^1.0.6", + "lodash.merge": "^4.6.2", + "lodash.mergewith": "^4.6.2", + "luxon": "3.6.1", + "mime": "^3.0.0", + "mkdirp": "^2.1.5", + "mz": "^2.7.0", + "progress": "^2.0.3", + "read-package-up": "^11.0.0", + "semver": "7.7.1", + "string-argv": "0.3.1", + "supports-color": "^8.1.1", + "type-fest": "^4.41.0", + "util-arity": "^1.1.0", + "yaml": "^2.2.2", + "yup": "1.6.1" + }, + "bin": { + "cucumber-js": "bin/cucumber.js" + }, + "engines": { + "node": "18 || 20 || 22 || >=23" + }, + "funding": { + "url": "https://opencollective.com/cucumber" + } + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-18.0.1.tgz", + "integrity": "sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==", + "license": "MIT", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-30.0.4.tgz", + "integrity": "sha512-pb7lmAJqweZRADTTsgnC3F5zbTh3nwOB1M83Q9ZPbUKMb3P76PzK6cTcPTJBHWy3l7isbigIv+BkDjaca6C8/g==", + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=26" + } + }, + "node_modules/@cucumber/gherkin-streams": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-5.0.1.tgz", + "integrity": "sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==", + "license": "MIT", + "dependencies": { + "commander": "9.1.0", + "source-map-support": "0.5.21" + }, + "bin": { + "gherkin-javascript": "bin/gherkin" + }, + "peerDependencies": { + "@cucumber/gherkin": ">=22.0.0", + "@cucumber/message-streams": ">=4.0.0", + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/gherkin-streams/node_modules/commander": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.1.0.tgz", + "integrity": "sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", + "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^31.0.0", + "@cucumber/messages": "^27.0.0", + "@teppeis/multimaps": "3.0.0", + "commander": "13.1.0", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", + "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=26" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", + "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "10.0.0" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", + "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "10.0.0" + } + }, + "node_modules/@cucumber/gherkin/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "21.10.1", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.10.1.tgz", + "integrity": "sha512-isaaNMNnBYThsvaHy7i+9kkk9V3+rhgdkt0pd6TCY6zY1CSRZQ7tG6ST9pYyRaECyfbCeF7UGH0KpNEnh6UNvQ==", + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/junit-xml-formatter": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.7.1.tgz", + "integrity": "sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==", + "license": "MIT", + "dependencies": { + "@cucumber/query": "^13.0.2", + "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", + "xmlbuilder": "^15.1.1" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/message-streams": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", + "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/messages": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", + "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "11.0.5" + } + }, + "node_modules/@cucumber/query": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", + "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "license": "MIT", + "dependencies": { + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.1.2.tgz", + "integrity": "sha512-xa3pER+ntZhGCxRXSguDTKEHTZpUUsp+RzTRNnit+vi5cqnk6abLdSLg5i3HZXU3c74nQ8afQC6IT507EN74oQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@juspay/superposition-sdk": { + "resolved": "../../clients/javascript/sdk", + "link": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@teppeis/multimaps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", + "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/assertion-error-formatter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/assertion-error-formatter/-/assertion-error-formatter-3.0.0.tgz", + "integrity": "sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==", + "license": "MIT", + "dependencies": { + "diff": "^4.0.1", + "pad-right": "^0.2.2", + "repeat-string": "^1.6.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-ansi": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz", + "integrity": "sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/knuth-shuffle-seeded": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz", + "integrity": "sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==", + "license": "Apache-2.0", + "dependencies": { + "seed-random": "~2.2.0" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", + "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pad-right": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/pad-right/-/pad-right-0.2.2.tgz", + "integrity": "sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "license": "Apache-2.0", + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/seed-random": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", + "integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/util-arity": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz", + "integrity": "sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/cucumber/package.json b/tests/cucumber/package.json new file mode 100644 index 000000000..594c01df8 --- /dev/null +++ b/tests/cucumber/package.json @@ -0,0 +1,41 @@ +{ + "name": "superposition-cucumber-tests", + "version": "1.0.0", + "description": "Cucumber/Gherkin BDD tests for Superposition - shared feature files for API and UI testing", + "type": "module", + "scripts": { + "test": "tsx node_modules/.bin/cucumber-js --profile api", + "test:api": "tsx node_modules/.bin/cucumber-js --profile api", + "test:ui": "tsx node_modules/.bin/cucumber-js --profile ui", + "test:ui:headed": "HEADLESS=false tsx node_modules/.bin/cucumber-js --profile ui", + "test:org": "tsx node_modules/.bin/cucumber-js --profile api --tags '@organisation'", + "test:workspace": "tsx node_modules/.bin/cucumber-js --profile api --tags '@workspace'", + "test:config": "tsx node_modules/.bin/cucumber-js --profile api --tags '@config'", + "test:dimension": "tsx node_modules/.bin/cucumber-js --profile api --tags '@dimension'", + "test:context": "tsx node_modules/.bin/cucumber-js --profile api --tags '@context'", + "test:experiment": "tsx node_modules/.bin/cucumber-js --profile api --tags '@experiment'", + "test:function": "tsx node_modules/.bin/cucumber-js --profile api --tags '@function'", + "test:variable": "tsx node_modules/.bin/cucumber-js --profile api --tags '@variable'", + "test:secret": "tsx node_modules/.bin/cucumber-js --profile api --tags '@secret'", + "test:type-template": "tsx node_modules/.bin/cucumber-js --profile api --tags '@type_template'", + "test:ui:org": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@organisation'", + "test:ui:workspace": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@workspace'", + "test:ui:dimension": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@dimension'", + "test:ui:experiment": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@experiment'", + "test:ui:variable": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@variable'", + "test:ui:secret": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@secret'", + "test:ui:function": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@function'", + "test:ui:config": "tsx node_modules/.bin/cucumber-js --profile ui --tags '@config'" + }, + "dependencies": { + "@cucumber/cucumber": "^11.0.0", + "@juspay/superposition-sdk": "file:../../clients/javascript/sdk", + "@playwright/test": "1.52.0", + "playwright": "1.52.0", + "tsx": "^4.19.0", + "typescript": "^5.8.3" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } +} diff --git a/tests/cucumber/step_definitions/common_steps.ts b/tests/cucumber/step_definitions/common_steps.ts new file mode 100644 index 000000000..665c83802 --- /dev/null +++ b/tests/cucumber/step_definitions/common_steps.ts @@ -0,0 +1,94 @@ +import { Then } from "@cucumber/cucumber"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Generic outcome assertions ────────────────────────────────────── + +Then("the operation should succeed", function (this: SuperpositionWorld) { + assert.strictEqual(this.lastError, undefined, `Operation failed unexpectedly: ${this.lastError?.message}`); + assert.ok(this.lastResponse !== undefined, "Expected a successful response but got none"); +}); + +Then("the operation should fail", function (this: SuperpositionWorld) { + assert.ok(this.lastError !== undefined, "Expected an error but operation succeeded"); +}); + +Then( + "the operation should fail with error matching {string}", + function (this: SuperpositionWorld, errorPattern: string) { + assert.ok(this.lastError !== undefined, "Expected an error but operation succeeded"); + // The SDK may throw a SyntaxError when the server returns non-JSON error responses. + // In that case, the raw server response is available on $response.body. + const rawBody = typeof this.lastError?.$response?.body === "string" + ? this.lastError.$response.body + : ""; + const message = rawBody || this.lastError?.message || String(this.lastError); + assert.ok( + message.includes(errorPattern), + `Error "${message}" does not contain "${errorPattern}"` + ); + } +); + +// ── Generic response property assertions ──────────────────────────── + +Then( + "the response should have an {string} property", + function (this: SuperpositionWorld, prop: string) { + assert.ok(this.lastResponse, "No response available"); + assert.ok( + this.lastResponse[prop] !== undefined, + `Response missing property "${prop}"` + ); + } +); + +Then( + "the response should have a {string} property", + function (this: SuperpositionWorld, prop: string) { + assert.ok(this.lastResponse, "No response available"); + assert.ok( + this.lastResponse[prop] !== undefined, + `Response missing property "${prop}"` + ); + } +); + +Then("the response should contain a list", function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response available"); + const data = this.lastResponse.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); +}); + +Then( + "the response should contain a list with at least {int} item(s)", + function (this: SuperpositionWorld, count: number) { + assert.ok(this.lastResponse, "No response available"); + const data = this.lastResponse.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); + assert.ok(data.length >= count, `Expected at least ${count} items, got ${data.length}`); + } +); + +Then( + "the response should contain a list with at most {int} item(s)", + function (this: SuperpositionWorld, count: number) { + assert.ok(this.lastResponse, "No response available"); + const data = this.lastResponse.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); + assert.ok(data.length <= count, `Expected at most ${count} items, got ${data.length}`); + } +); + +Then("the response should have a version", function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response available"); + assert.ok(this.lastResponse.version !== undefined, "Response missing version"); +}); + +Then( + "the response description should be {string}", + function (this: SuperpositionWorld, expected: string) { + assert.ok(this.lastResponse, "No response available"); + assert.strictEqual(this.lastResponse.description, expected); + } +); diff --git a/tests/cucumber/step_definitions/config_steps.ts b/tests/cucumber/step_definitions/config_steps.ts new file mode 100644 index 000000000..156422c4b --- /dev/null +++ b/tests/cucumber/step_definitions/config_steps.ts @@ -0,0 +1,168 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + GetConfigCommand, + ListVersionsCommand, + UpdateWorkspaceCommand, + CreateDefaultConfigCommand, + DeleteDefaultConfigCommand, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a test default config exists for config retrieval", + async function (this: SuperpositionWorld) { + this.configKey = this.uniqueName("cfg-retrieval"); + try { + await this.client.send( + new CreateDefaultConfigCommand({ + key: this.configKey, + value: { enabled: true, message: "test config" }, + schema: { type: "object" }, + description: "Test config for retrieval tests", + change_reason: "Cucumber setup", + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.createdConfigs.push(this.configKey); + } catch { + // Already exists + } + } +); + +Given( + "I know the current config version", + async function (this: SuperpositionWorld) { + const cmd = new GetConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + }); + const out = await this.client.send(cmd); + this.configVersionId = out.version ?? undefined; + assert.ok(this.configVersionId, "Could not determine config version"); + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I get the config with the test config key prefix", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + }) + ); + this.configVersionId = this.lastResponse.version ?? undefined; + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I pin the workspace to that config version", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new UpdateWorkspaceCommand({ + org_id: this.orgId, + workspace_name: this.workspaceId, + config_version: this.configVersionId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I get the config again", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + context: {}, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I unpin the workspace config version", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new UpdateWorkspaceCommand({ + org_id: this.orgId, + workspace_name: this.workspaceId, + config_version: "null", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I list config versions with count {int} and page {int}", + async function (this: SuperpositionWorld, count: number, page: number) { + try { + this.lastResponse = await this.client.send( + new ListVersionsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + count, + page, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the config version should match the pinned version", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.version, "No version in response"); + assert.strictEqual( + this.lastResponse.version?.toString(), + this.configVersionId?.toString() + ); + } +); + +Then( + "the workspace config version should be unset", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.config_version, undefined); + } +); diff --git a/tests/cucumber/step_definitions/context_steps.ts b/tests/cucumber/step_definitions/context_steps.ts new file mode 100644 index 000000000..2b321e560 --- /dev/null +++ b/tests/cucumber/step_definitions/context_steps.ts @@ -0,0 +1,326 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateContextCommand, + GetContextCommand, + ListContextsCommand, + UpdateOverrideCommand, + MoveContextCommand, + DeleteContextCommand, + BulkOperationCommand, + WeightRecomputeCommand, + CreateDimensionCommand, + CreateDefaultConfigCommand, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "dimensions and default configs are set up for context tests", + async function (this: SuperpositionWorld) { + // Create "os" dimension if not exists + try { + await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: "os", + position: 1, + schema: { type: "string", enum: ["android", "ios", "web"] }, + description: "OS dimension", + change_reason: "Cucumber context test setup", + }) + ); + this.createdDimensions.push("os"); + } catch { + // Already exists + } + + // Create config key + const configKey = "ctx-config-key"; + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: configKey, + value: "default", + schema: { type: "string" }, + description: "Config for context tests", + change_reason: "Cucumber setup", + }) + ); + this.createdConfigs.push(configKey); + } catch { + // Already exists + } + } +); + +Given( + "a context exists with condition {string} equals {string} and override {string} to {string}", + async function ( + this: SuperpositionWorld, + dimName: string, + dimValue: string, + configKey: string, + configValue: string + ) { + try { + const response = await this.client.send( + new CreateContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { [dimName]: dimValue }, + override: { [configKey]: configValue }, + description: "Cucumber test context", + change_reason: "Cucumber setup", + }, + }) + ); + this.contextId = response.id ?? ""; + this.createdContextIds.push(this.contextId); + } catch { + // May already exist + } + } +); + +Given( + "contexts exist for weight recompute", + async function (this: SuperpositionWorld) { + // Create a couple of contexts + for (const val of ["android", "ios"]) { + try { + const response = await this.client.send( + new CreateContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { os: val }, + override: { "ctx-config-key": `${val}-weight` }, + description: "Weight recompute test", + change_reason: "Cucumber setup", + }, + }) + ); + this.createdContextIds.push(response.id ?? ""); + } catch { + // May already exist + } + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create a context with condition {string} equals {string} and override {string} to {string}", + async function ( + this: SuperpositionWorld, + dimName: string, + dimValue: string, + configKey: string, + configValue: string + ) { + try { + this.lastResponse = await this.client.send( + new CreateContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { [dimName]: dimValue }, + override: { [configKey]: configValue }, + description: "Cucumber test context", + change_reason: "Cucumber test", + }, + }) + ); + this.contextId = this.lastResponse.id ?? ""; + this.createdContextIds.push(this.contextId); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get the context by its ID", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.contextId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I list all contexts", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new ListContextsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I update the context override for {string} to {string}", + async function (this: SuperpositionWorld, configKey: string, newValue: string) { + try { + this.lastResponse = await this.client.send( + new UpdateOverrideCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { id: this.contextId }, + override: { [configKey]: newValue }, + description: "Updated override", + change_reason: "Cucumber update test", + }, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I move the context to condition {string} equals {string}", + async function (this: SuperpositionWorld, dimName: string, dimValue: string) { + try { + this.lastResponse = await this.client.send( + new MoveContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.contextId, + request: { + context: { [dimName]: dimValue }, + description: "Moved context", + change_reason: "Cucumber move test", + }, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I delete the context", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new DeleteContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.contextId, + }) + ); + this.createdContextIds = this.createdContextIds.filter( + (id) => id !== this.contextId + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I perform a bulk operation to create contexts for {string} values {string}", + async function (this: SuperpositionWorld, dimName: string, values: string) { + const valueList = values.split(",").map((v) => v.trim()); + try { + this.lastResponse = await this.client.send( + new BulkOperationCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + operations: valueList.map((val) => ({ + PUT: { + context: { [dimName]: val }, + override: { "ctx-config-key": `${val}-bulk` }, + description: `Bulk context for ${val}`, + change_reason: "Cucumber bulk test", + }, + })), + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I trigger weight recomputation", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new WeightRecomputeCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have a context ID", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok( + this.lastResponse.context_id || this.contextId, + "No context ID in response" + ); + } +); + +Then( + "the response should include the override for {string}", + function (this: SuperpositionWorld, configKey: string) { + assert.ok(this.lastResponse, "No response"); + const override = this.lastResponse.override ?? this.lastResponse.r_override; + assert.ok(override, "No override in response"); + } +); + +Then( + "the list should contain the created context", + function (this: SuperpositionWorld) { + const data = this.lastResponse?.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); + assert.ok(data.length > 0, "List is empty"); + } +); diff --git a/tests/cucumber/step_definitions/default_config_steps.ts b/tests/cucumber/step_definitions/default_config_steps.ts new file mode 100644 index 000000000..c371a0625 --- /dev/null +++ b/tests/cucumber/step_definitions/default_config_steps.ts @@ -0,0 +1,489 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateDefaultConfigCommand, + UpdateDefaultConfigCommand, + DeleteDefaultConfigCommand, + CreateFunctionCommand, + PublishCommand, + FunctionTypes, + FunctionRuntimeVersion, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "an organisation and workspace exist", + function (this: SuperpositionWorld) { + assert.ok(this.orgId, "Organisation ID not available"); + assert.ok(this.workspaceId, "Workspace ID not available"); + } +); + +Given( + "validation functions are set up", + async function (this: SuperpositionWorld) { + const functions = [ + { + name: "false_validation", + code: `async function execute(payload) { return false; }`, + type: FunctionTypes.VALUE_VALIDATION, + }, + { + name: "true_function", + code: `async function execute(payload) { return true; }`, + type: FunctionTypes.VALUE_VALIDATION, + }, + { + name: "auto_fn", + code: `async function execute(payload) { return []; }`, + type: FunctionTypes.VALUE_COMPUTE, + }, + ]; + + for (const fn of functions) { + try { + await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: fn.name, + function: fn.code, + description: `Test ${fn.type} function`, + change_reason: "Cucumber test setup", + runtime_version: FunctionRuntimeVersion.V1, + function_type: fn.type, + }) + ); + this.createdFunctions.push(fn.name); + + await this.client.send( + new PublishCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: fn.name, + change_reason: "Publishing for cucumber tests", + }) + ); + } catch { + // Already exists + } + } + } +); + +Given( + "a default config exists with key {string} and value {string} age {int}", + async function (this: SuperpositionWorld, key: string, name: string, age: number) { + const uniqueKey = this.uniqueName(key); + this.configKey = uniqueKey; + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: uniqueKey, + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "number", minimum: 0 } }, + required: ["name"], + }, + value: { name, age }, + description: "Test configuration", + change_reason: "Cucumber test setup", + }) + ); + this.createdConfigs.push(uniqueKey); + } catch { + // Already exists + } + } +); + +Given( + "a default config exists with key {string} and requires name and email", + async function (this: SuperpositionWorld, key: string) { + const uniqueKey = this.uniqueName(key); + this.configKey = uniqueKey; + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: uniqueKey, + schema: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string", format: "email" }, + }, + required: ["name", "email"], + }, + value: { name: "Test", email: "test@test.com" }, + description: "Test config requiring email", + change_reason: "Cucumber test setup", + }) + ); + this.createdConfigs.push(uniqueKey); + } catch { + // Already exists + } + } +); + +// ── When: Create ──────────────────────────────────────────────────── + +When( + "I create a default config with key {string} and value:", + async function (this: SuperpositionWorld, key: string, table: any) { + const rows = table.rowsHash(); + const value: any = {}; + for (const [k, v] of Object.entries(rows)) { + value[k] = isNaN(Number(v)) ? v : Number(v); + } + this.configKey = this.uniqueName(key); + // Schema and value stored for later + this.configValue = value; + } +); + +When( + "the schema requires {string} as string and {string} as number with minimum {int}", + async function (this: SuperpositionWorld, strField: string, numField: string, min: number) { + try { + this.lastResponse = await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: this.configKey, + schema: { + type: "object", + properties: { + [strField]: { type: "string" }, + [numField]: { type: "number", minimum: min }, + }, + required: [strField], + }, + value: this.configValue, + description: "Test configuration", + change_reason: "Cucumber test creation", + }) + ); + this.createdConfigs.push(this.configKey); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a default config with key {string} and an invalid schema type {string}", + async function (this: SuperpositionWorld, key: string, schemaType: string) { + try { + this.lastResponse = await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key, + schema: { type: schemaType }, + value: { name: "Test" }, + description: "Test", + change_reason: "Testing invalid schema", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a default config with key {string} and an empty schema", + async function (this: SuperpositionWorld, key: string) { + try { + this.lastResponse = await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key, + schema: {}, + value: { name: "Test" }, + description: "Test", + change_reason: "Testing empty schema", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a default config with key {string} where age is {int} but minimum is {int}", + async function (this: SuperpositionWorld, key: string, age: number, min: number) { + try { + this.lastResponse = await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key, + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number", minimum: min }, + }, + required: ["name"], + }, + value: { name: "Test User", age }, + description: "Test", + change_reason: "Testing schema violation", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a default config with key {string} using validation function {string}", + async function (this: SuperpositionWorld, key: string, funcName: string) { + try { + this.lastResponse = await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key, + schema: { type: "object", properties: { name: { type: "string" } } }, + value: { name: "Invalid Value" }, + description: "Test", + value_validation_function_name: funcName, + change_reason: "Testing function validation", + }) + ); + this.createdConfigs.push(key); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a default config with key {string} using compute function {string}", + async function (this: SuperpositionWorld, key: string, funcName: string) { + const uniqueKey = this.uniqueName(key); + try { + this.lastResponse = await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: uniqueKey, + schema: { type: "object", properties: { name: { type: "string" } } }, + value: { name: "valid Value" }, + description: "Test", + value_compute_function_name: funcName, + change_reason: "Testing compute function", + }) + ); + this.createdConfigs.push(uniqueKey); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── When: Update ──────────────────────────────────────────────────── + +When( + "I update the default config {string} with value {string} age {int}", + async function (this: SuperpositionWorld, key: string, name: string, age: number) { + try { + this.lastResponse = await this.client.send( + new UpdateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: this.configKey, + value: { name, age }, + description: "Updated configuration", + change_reason: "Cucumber update test", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update default config {string} with a new value", + async function (this: SuperpositionWorld, key: string) { + try { + this.lastResponse = await this.client.send( + new UpdateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key, + value: { name: "Updated" }, + description: "Updated", + change_reason: "Update test", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update default config {string} schema to add email field and set value with email {string}", + async function (this: SuperpositionWorld, key: string, email: string) { + try { + this.lastResponse = await this.client.send( + new UpdateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: this.configKey, + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string", format: "email" }, + }, + required: ["name", "email"], + }, + value: { name: "Updated Name", age: 35, email }, + change_reason: "Updating schema and value", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update default config {string} schema to an invalid type", + async function (this: SuperpositionWorld, key: string) { + try { + this.lastResponse = await this.client.send( + new UpdateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: this.configKey, + schema: { type: "invalid-type" }, + change_reason: "Testing invalid schema update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update default config {string} value without the required email field", + async function (this: SuperpositionWorld, key: string) { + try { + this.lastResponse = await this.client.send( + new UpdateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: this.configKey, + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number", minimum: 18 }, + email: { type: "string", format: "email" }, + }, + required: ["name", "email"], + }, + value: { name: "Updated Name", age: 20 }, + change_reason: "Testing missing required field", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update default config {string} validation function to {string}", + async function (this: SuperpositionWorld, key: string, funcName: string) { + try { + this.lastResponse = await this.client.send( + new UpdateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: this.configKey, + value_validation_function_name: funcName, + change_reason: "Update validation function", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have value_compute_function_name {string}", + function (this: SuperpositionWorld, expected: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.value_compute_function_name, expected); + } +); + +Then( + "the response should have value_validation_function_name {string}", + function (this: SuperpositionWorld, expected: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.value_validation_function_name, expected); + } +); + +Then( + "the response value should have name {string} and age {int}", + function (this: SuperpositionWorld, name: string, age: number) { + assert.ok(this.lastResponse, "No response"); + assert.deepStrictEqual(this.lastResponse.value, { name, age }); + } +); + +Then( + "the response value should include email {string}", + function (this: SuperpositionWorld, email: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.value?.email, email); + } +); diff --git a/tests/cucumber/step_definitions/dimension_steps.ts b/tests/cucumber/step_definitions/dimension_steps.ts new file mode 100644 index 000000000..d64adf15f --- /dev/null +++ b/tests/cucumber/step_definitions/dimension_steps.ts @@ -0,0 +1,194 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateDimensionCommand, + GetDimensionCommand, + ListDimensionsCommand, + UpdateDimensionCommand, + DeleteDimensionCommand, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +function nextPosition(): number { + return 1; // Always insert at position 1 (front) to avoid exceeding dimension count +} + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a dimension {string} exists with schema type {string}", + async function (this: SuperpositionWorld, name: string, schemaType: string) { + const uniqueName = this.uniqueName(name); + this.dimensionName = uniqueName; + try { + await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: uniqueName, + position: nextPosition(), + schema: { type: schemaType }, + description: `Test dimension ${uniqueName}`, + change_reason: "Cucumber test setup", + }) + ); + this.createdDimensions.push(uniqueName); + } catch { + // Already exists + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create a dimension with name {string} and schema type {string}", + async function (this: SuperpositionWorld, name: string, schemaType: string) { + const uniqueName = this.uniqueName(name); + this.dimensionName = uniqueName; + try { + this.lastResponse = await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: uniqueName, + position: nextPosition(), + schema: { type: schemaType }, + description: `Test dimension ${uniqueName}`, + change_reason: "Cucumber test", + }) + ); + this.createdDimensions.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a dimension with name {string} and enum values {string}", + async function (this: SuperpositionWorld, name: string, values: string) { + const uniqueName = this.uniqueName(name); + this.dimensionName = uniqueName; + const enumValues = values.split(",").map((v) => v.trim()); + try { + this.lastResponse = await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: uniqueName, + position: nextPosition(), + schema: { type: "string", enum: enumValues }, + description: `Enum dimension ${uniqueName}`, + change_reason: "Cucumber test", + }) + ); + this.createdDimensions.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get dimension {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new GetDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: this.dimensionName, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I list all dimensions", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new ListDimensionsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + count: 200, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I update dimension {string} description to {string}", + async function (this: SuperpositionWorld, name: string, description: string) { + try { + this.lastResponse = await this.client.send( + new UpdateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: this.dimensionName, + description, + change_reason: "Cucumber update test", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I delete dimension {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new DeleteDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: this.dimensionName, + }) + ); + // Remove from cleanup list since we deleted it + this.createdDimensions = this.createdDimensions.filter( + (d) => d !== this.dimensionName + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have dimension name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.dimension, this.dimensionName); + } +); + +Then( + "the list should contain dimension {string}", + function (this: SuperpositionWorld, name: string) { + const data = this.lastResponse?.data; + assert.ok(Array.isArray(data), "No list data"); + const found = data.find((d: any) => d.dimension === this.dimensionName); + assert.ok(found, `Dimension "${this.dimensionName}" not found in list`); + } +); diff --git a/tests/cucumber/step_definitions/experiment_group_steps.ts b/tests/cucumber/step_definitions/experiment_group_steps.ts new file mode 100644 index 000000000..f76c4f22e --- /dev/null +++ b/tests/cucumber/step_definitions/experiment_group_steps.ts @@ -0,0 +1,671 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateExperimentCommand, + CreateExperimentGroupCommand, + GetExperimentGroupCommand, + UpdateExperimentGroupCommand, + AddMembersToGroupCommand, + RemoveMembersFromGroupCommand, + ListExperimentGroupsCommand, + DeleteExperimentGroupCommand, + CreateDimensionCommand, + CreateDefaultConfigCommand, + RampExperimentCommand, + VariantType, + ExperimentGroupSortOn, + SortBy, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// Track group-specific experiment IDs +let validExpId: string; +let validExp2Id: string; +let inProgressExpId: string; +let conflictingContextExpId: string; + +const groupContext = { os: "ios", clientId: "groupClient" }; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "dimensions and default configs are set up for experiment group tests", + async function (this: SuperpositionWorld) { + // Ensure dimensions exist + for (const dim of [ + { name: "os", schema: { type: "string", enum: ["ios", "android", "web"] } as Record }, + { name: "clientId", schema: { type: "string" } as Record }, + { name: "app_version", schema: { type: "string" } as Record }, + { name: "device_specific_id", schema: { type: "string" } as Record }, + ]) { + try { + await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: dim.name, + position: 1, + schema: dim.schema, + description: `Dim ${dim.name}`, + change_reason: "Cucumber group setup", + }) + ); + this.createdDimensions.push(dim.name); + } catch { + // Already exists + } + } + + // Ensure default configs exist + for (const cfg of ["pmTestKey1", "pmTestKey2"]) { + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: cfg, + value: `default_${cfg}`, + schema: { type: "string" }, + description: `Config ${cfg}`, + change_reason: "Cucumber group setup", + }) + ); + this.createdConfigs.push(cfg); + } catch { + // Already exists + } + } + } +); + +Given( + "experiments are set up for group tests", + async function (this: SuperpositionWorld) { + const variants = [ + { + variant_type: VariantType.CONTROL, + id: "grp_control", + overrides: { pmTestKey1: "ctrl_val1", pmTestKey2: "ctrl_val2" }, + }, + { + variant_type: VariantType.EXPERIMENTAL, + id: "grp_experimental", + overrides: { pmTestKey1: "exp_val1", pmTestKey2: "exp_val2" }, + }, + ]; + + // Valid experiment 1 (exact context match) + const exp1 = await this.client.send( + new CreateExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-exp1"), + context: { ...groupContext }, + variants, + description: "Valid exp 1", + change_reason: "Cucumber setup", + }) + ); + validExpId = exp1.id!; + this.createdExperimentIds.push(validExpId); + + // Valid experiment 2 (superset context) + const exp2 = await this.client.send( + new CreateExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-exp2"), + context: { ...groupContext, app_version: "2.0.0" }, + variants, + description: "Valid exp 2 (superset)", + change_reason: "Cucumber setup", + }) + ); + validExp2Id = exp2.id!; + this.createdExperimentIds.push(validExp2Id); + + // In-progress experiment + const exp3 = await this.client.send( + new CreateExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-exp-prog"), + context: { os: "android" }, + variants, + description: "In-progress exp", + change_reason: "Cucumber setup", + }) + ); + inProgressExpId = exp3.id!; + this.createdExperimentIds.push(inProgressExpId); + await this.client.send( + new RampExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: inProgressExpId, + traffic_percentage: 50, + change_reason: "Ramp for test", + }) + ); + + // Conflicting context experiment + const exp4 = await this.client.send( + new CreateExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-exp-conflict"), + context: { device_specific_id: "devXYZ" }, + variants, + description: "Conflicting context exp", + change_reason: "Cucumber setup", + }) + ); + conflictingContextExpId = exp4.id!; + this.createdExperimentIds.push(conflictingContextExpId); + } +); + +Given( + "an experiment group exists", + async function (this: SuperpositionWorld) { + if (!this.experimentGroupId) { + const response = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-test"), + description: "Test group", + change_reason: "Cucumber setup", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [], + }) + ); + this.experimentGroupId = response.id!; + } + } +); + +Given( + "an experiment group exists with no members", + async function (this: SuperpositionWorld) { + const response = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-empty"), + description: "Empty group", + change_reason: "Cucumber setup", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [], + }) + ); + this.experimentGroupId = response.id!; + } +); + +Given( + "an experiment group exists with members", + async function (this: SuperpositionWorld) { + const response = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("grp-members"), + description: "Group with members", + change_reason: "Cucumber setup", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [validExpId], + }) + ); + this.experimentGroupId = response.id!; + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create an experiment group with name {string} and member experiments", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName(name), + description: "Test group", + change_reason: "Cucumber test", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [validExp2Id], + }) + ); + this.experimentGroupId = this.lastResponse.id!; + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create an experiment group with name {string} and no members", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName(name), + description: "Empty group", + change_reason: "Cucumber test", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [], + }) + ); + this.experimentGroupId = this.lastResponse.id!; + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create an experiment group including an in-progress experiment", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("fail-prog"), + description: "Should fail", + change_reason: "Test", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [validExpId, inProgressExpId], + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create an experiment group including an experiment with conflicting context", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("fail-ctx"), + description: "Should fail", + change_reason: "Test", + context: groupContext, + traffic_percentage: 100, + member_experiment_ids: [validExpId, conflictingContextExpId], + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create an experiment group with traffic percentage {int}", + async function (this: SuperpositionWorld, traffic: number) { + try { + this.lastResponse = await this.client.send( + new CreateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("fail-traffic"), + description: "Should fail", + change_reason: "Test", + context: groupContext, + traffic_percentage: traffic, + member_experiment_ids: [], + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get the experiment group by its ID", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get an experiment group with ID {string}", + async function (this: SuperpositionWorld, id: string) { + try { + this.lastResponse = await this.client.send( + new GetExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update the experiment group traffic percentage to {int}", + async function (this: SuperpositionWorld, traffic: number) { + try { + this.lastResponse = await this.client.send( + new UpdateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + traffic_percentage: traffic, + change_reason: "Cucumber update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update the experiment group description to {string}", + async function (this: SuperpositionWorld, desc: string) { + try { + this.lastResponse = await this.client.send( + new UpdateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + description: desc, + change_reason: "Cucumber update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update experiment group {string} traffic percentage to {int}", + async function (this: SuperpositionWorld, id: string, traffic: number) { + try { + this.lastResponse = await this.client.send( + new UpdateExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id, + traffic_percentage: traffic, + change_reason: "Cucumber update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I add a valid experiment to the group", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new AddMembersToGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + member_experiment_ids: [validExp2Id], + change_reason: "Cucumber add member", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I remove a member from the group", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new RemoveMembersFromGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + member_experiment_ids: [validExpId], + change_reason: "Cucumber remove member", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I add an in-progress experiment to the group", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new AddMembersToGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + member_experiment_ids: [inProgressExpId], + change_reason: "Cucumber add invalid", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I list experiment groups", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new ListExperimentGroupsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I list experiment groups sorted by {string} in {string} order", + async function (this: SuperpositionWorld, sortOn: string, sortBy: string) { + try { + this.lastResponse = await this.client.send( + new ListExperimentGroupsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + sort_on: + sortOn === "created_at" + ? ExperimentGroupSortOn.CREATED_AT + : ExperimentGroupSortOn.CREATED_AT, + sort_by: sortBy === "DESC" ? SortBy.DESC : SortBy.ASC, + count: 5, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I delete the experiment group", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new DeleteExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentGroupId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I delete experiment group {string}", + async function (this: SuperpositionWorld, id: string) { + try { + this.lastResponse = await this.client.send( + new DeleteExperimentGroupCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should contain the member experiment IDs", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok( + this.lastResponse.member_experiment_ids?.length > 0, + "No member IDs in response" + ); + } +); + +Then( + "the response traffic percentage should be {int}", + function (this: SuperpositionWorld, expected: number) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.traffic_percentage, expected); + } +); + +Then( + "the response member list should be empty", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.deepStrictEqual(this.lastResponse.member_experiment_ids, []); + } +); + +Then( + "the response should have a group name", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.name, "No group name in response"); + } +); + +Then( + "the response should contain the added experiment ID", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok( + this.lastResponse.member_experiment_ids?.includes(validExp2Id), + "Added experiment not found in member list" + ); + } +); + +Then( + "the response should not contain the removed experiment ID", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok( + !this.lastResponse.member_experiment_ids?.includes(validExpId), + "Removed experiment still in member list" + ); + } +); + +Then( + "the list should contain the created group", + function (this: SuperpositionWorld) { + const data = this.lastResponse?.data; + assert.ok(Array.isArray(data), "Response is not a list"); + assert.ok(data.length > 0, "List is empty"); + } +); + +Then( + "the response should be sorted by created_at descending", + function (this: SuperpositionWorld) { + const data = this.lastResponse?.data; + assert.ok(Array.isArray(data), "Response is not a list"); + if (data.length > 1) { + const d1 = new Date(data[0].created_at).getTime(); + const d2 = new Date(data[1].created_at).getTime(); + assert.ok(d1 >= d2, "Results not sorted by created_at DESC"); + } + } +); diff --git a/tests/cucumber/step_definitions/experiment_steps.ts b/tests/cucumber/step_definitions/experiment_steps.ts new file mode 100644 index 000000000..af9e0c9c8 --- /dev/null +++ b/tests/cucumber/step_definitions/experiment_steps.ts @@ -0,0 +1,347 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateExperimentCommand, + GetExperimentCommand, + ListExperimentCommand, + RampExperimentCommand, + ConcludeExperimentCommand, + DiscardExperimentCommand, + UpdateOverridesExperimentCommand, + CreateDimensionCommand, + CreateDefaultConfigCommand, + VariantType, + ExperimentStatusType, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "dimensions and default configs are set up for experiment tests", + async function (this: SuperpositionWorld) { + // Create "os" dimension + try { + await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: "os", + position: 1, + schema: { type: "string", enum: ["android", "ios", "web"] }, + description: "OS dimension", + change_reason: "Cucumber experiment setup", + }) + ); + this.createdDimensions.push("os"); + } catch { + // Already exists + } + + // Create config key + const configKey = "exp-config-key"; + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: configKey, + value: "default-value", + schema: { type: "string" }, + description: "Config for experiment tests", + change_reason: "Cucumber setup", + }) + ); + this.createdConfigs.push(configKey); + } catch { + // Already exists + } + } +); + +async function createExperiment( + world: SuperpositionWorld, + name: string, + dimName: string, + dimValue: string, + configKey: string = "exp-config-key" +) { + const uniqueName = world.uniqueName(name); + const response = await world.client.send( + new CreateExperimentCommand({ + workspace_id: world.workspaceId, + org_id: world.orgId, + name: uniqueName, + context: { [dimName]: dimValue }, + variants: [ + { + variant_type: VariantType.CONTROL, + id: "control", + overrides: { [configKey]: "control-val" }, + }, + { + variant_type: VariantType.EXPERIMENTAL, + id: "experimental", + overrides: { [configKey]: "experimental-val" }, + }, + ], + description: `Test experiment ${uniqueName}`, + change_reason: "Cucumber test", + }) + ); + world.experimentId = response.id ?? ""; + world.experimentVariants = response.variants ?? []; + world.createdExperimentIds.push(world.experimentId); + return response; +} + +Given( + "an experiment {string} exists with context {string} equals {string}", + async function (this: SuperpositionWorld, name: string, dim: string, val: string) { + await createExperiment(this, name, dim, val); + } +); + +Given( + "an experiment {string} exists and is ramped to {int} percent", + async function (this: SuperpositionWorld, name: string, traffic: number) { + await createExperiment(this, name, "os", "android"); + await this.client.send( + new RampExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + traffic_percentage: traffic, + change_reason: "Cucumber ramp", + }) + ); + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create an experiment with name {string} and context {string} equals {string}", + async function (this: SuperpositionWorld, name: string, dim: string, val: string) { + // Store for the multi-step creation + this.experimentId = ""; // Will be set in the final step + (this as any)._pendingExpName = this.uniqueName(name); + (this as any)._pendingExpContext = { [dim]: val }; + (this as any)._pendingExpVariants = []; + } +); + +When( + "the experiment has a control variant with override {string} = {string}", + function (this: SuperpositionWorld, key: string, value: string) { + (this as any)._pendingExpVariants.push({ + variant_type: VariantType.CONTROL, + id: "control", + overrides: { [key]: value }, + }); + } +); + +When( + "the experiment has an experimental variant with override {string} = {string}", + async function (this: SuperpositionWorld, key: string, value: string) { + (this as any)._pendingExpVariants.push({ + variant_type: VariantType.EXPERIMENTAL, + id: "experimental", + overrides: { [key]: value }, + }); + + // Now create the experiment + try { + this.lastResponse = await this.client.send( + new CreateExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: (this as any)._pendingExpName, + context: (this as any)._pendingExpContext, + variants: (this as any)._pendingExpVariants, + description: "Cucumber test experiment", + change_reason: "Cucumber test", + }) + ); + this.experimentId = this.lastResponse.id ?? ""; + this.experimentVariants = this.lastResponse.variants ?? []; + this.createdExperimentIds.push(this.experimentId); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get the experiment by its ID", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I list experiments", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new ListExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I ramp the experiment to {int} percent traffic", + async function (this: SuperpositionWorld, traffic: number) { + try { + this.lastResponse = await this.client.send( + new RampExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + traffic_percentage: traffic, + change_reason: "Cucumber ramp test", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update the experimental variant override for {string} to {string}", + async function (this: SuperpositionWorld, key: string, value: string) { + const experimentalVariant = this.experimentVariants.find( + (v: any) => v.variant_type === VariantType.EXPERIMENTAL + ); + // Server requires all variants in the update request + const variantList = this.experimentVariants.map((v: any) => ({ + id: v.id ?? v.variant_type, + overrides: v.id === experimentalVariant?.id + ? { ...v.overrides, [key]: value } + : v.overrides ?? {}, + })); + try { + this.lastResponse = await this.client.send( + new UpdateOverridesExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + variant_list: variantList, + change_reason: "Cucumber override update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I conclude the experiment with the experimental variant", + async function (this: SuperpositionWorld) { + const experimentalVariant = this.experimentVariants.find( + (v: any) => v.variant_type === VariantType.EXPERIMENTAL + ); + try { + this.lastResponse = await this.client.send( + new ConcludeExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + chosen_variant: experimentalVariant?.id ?? "experimental", + change_reason: "Cucumber conclude test", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I discard the experiment", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new DiscardExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + change_reason: "Cucumber discard test", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have experiment status {string}", + function (this: SuperpositionWorld, status: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.status, status); + } +); + +Then( + "the experiment status should be {string}", + function (this: SuperpositionWorld, status: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.status, status); + } +); + +Then( + "the response should have {int} variants", + function (this: SuperpositionWorld, count: number) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.variants?.length, count); + } +); + +Then( + "the response should have the experiment name", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.name, "No experiment name in response"); + } +); + +Then( + "the list should contain the created experiment", + function (this: SuperpositionWorld) { + const data = this.lastResponse?.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); + const found = data.find((e: any) => e.id === this.experimentId); + assert.ok(found, `Experiment ${this.experimentId} not found in list`); + } +); diff --git a/tests/cucumber/step_definitions/function_steps.ts b/tests/cucumber/step_definitions/function_steps.ts new file mode 100644 index 000000000..82ff30186 --- /dev/null +++ b/tests/cucumber/step_definitions/function_steps.ts @@ -0,0 +1,290 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateFunctionCommand, + GetFunctionCommand, + ListFunctionCommand, + UpdateFunctionCommand, + PublishCommand, + FunctionTypes, + FunctionRuntimeVersion, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a value_validation function {string} exists", + async function (this: SuperpositionWorld, name: string) { + const uniqueName = this.uniqueName(name); + this.functionName = uniqueName; + const code = ` + async function execute(payload) { + let value = payload.value_validate.value; + let key = payload.value_validate.key; + if (key === "test-dimension" && value === "valid") return true; + return false; + } + `; + try { + await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: uniqueName, + function: code, + description: "Test value_validation function", + change_reason: "Cucumber setup", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_VALIDATION, + }) + ); + this.createdFunctions.push(uniqueName); + } catch { + // Already exists + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create a value_validation function named {string} with code that validates key {string}", + async function (this: SuperpositionWorld, name: string, key: string) { + const uniqueName = this.uniqueName(name); + this.functionName = uniqueName; + const code = ` + async function execute(payload) { + let value = payload.value_validate.value; + let key = payload.value_validate.key; + if (key === "${key}" && value === "valid") return true; + return false; + } + `; + try { + this.lastResponse = await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: uniqueName, + function: code, + description: "Test value_validation function", + change_reason: "Cucumber test", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_VALIDATION, + }) + ); + this.createdFunctions.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a value_compute function named {string} with code that returns computed values", + async function (this: SuperpositionWorld, name: string) { + const uniqueName = this.uniqueName(name); + this.functionName = uniqueName; + const code = ` + async function execute(payload) { + return ["test1", "test2", "test3"]; + } + `; + try { + this.lastResponse = await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: uniqueName, + function: code, + description: "Test value_compute function", + change_reason: "Cucumber test", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_COMPUTE, + }) + ); + this.createdFunctions.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a value_validation function named {string} with code {string}", + async function (this: SuperpositionWorld, name: string, code: string) { + const uniqueName = this.uniqueName(name); + try { + this.lastResponse = await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: uniqueName, + function: code, + description: "Test function", + change_reason: "Cucumber test", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_VALIDATION, + }) + ); + this.createdFunctions.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a value_compute function named {string} with code that returns a string", + async function (this: SuperpositionWorld, name: string) { + const uniqueName = this.uniqueName(name); + const code = ` + async function execute(payload) { + return "invalid return type"; + } + `; + try { + this.lastResponse = await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: uniqueName, + function: code, + description: "Test function", + change_reason: "Cucumber test", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_COMPUTE, + }) + ); + this.createdFunctions.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get function {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new GetFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: this.functionName, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I list functions with count {int} and page {int}", + async function (this: SuperpositionWorld, count: number, page: number) { + try { + this.lastResponse = await this.client.send( + new ListFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + count, + page, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update function {string} with new validation code", + async function (this: SuperpositionWorld, name: string) { + const code = ` + async function execute(payload) { + let value = payload.value_validate.value; + if (value === "updated-valid") return true; + return false; + } + `; + try { + this.lastResponse = await this.client.send( + new UpdateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: this.functionName, + function: code, + description: "Updated value_validation function", + change_reason: "Cucumber update", + runtime_version: FunctionRuntimeVersion.V1, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I publish function {string}", + async function (this: SuperpositionWorld, name: string) { + // Use the tracked function name if it matches, otherwise use as-is + const funcName = this.functionName || this.uniqueName(name); + try { + this.lastResponse = await this.client.send( + new PublishCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: name === "non-existent-function" ? name : funcName, + change_reason: "Cucumber publish", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have function type {string}", + function (this: SuperpositionWorld, type: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.function_type, type); + } +); + +Then( + "the response should have function name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.function_name, this.functionName); + } +); + +Then( + "the response should have description {string}", + function (this: SuperpositionWorld, desc: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.description, desc); + } +); diff --git a/tests/cucumber/step_definitions/organisation_steps.ts b/tests/cucumber/step_definitions/organisation_steps.ts new file mode 100644 index 000000000..5e5730d60 --- /dev/null +++ b/tests/cucumber/step_definitions/organisation_steps.ts @@ -0,0 +1,184 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateOrganisationCommand, + ListOrganisationCommand, + GetOrganisationCommand, + UpdateOrganisationCommand, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "an organisation exists with name {string} and admin email {string}", + async function (this: SuperpositionWorld, name: string, email: string) { + try { + const response = await this.client.send( + new CreateOrganisationCommand({ admin_email: email, name }) + ); + this.createdOrgId = response.id ?? ""; + this.orgName = name; + } catch (e: any) { + // May already exist, try to find it + const list = await this.client.send(new ListOrganisationCommand({})); + const existing = list.data?.find((o) => o.name === name); + if (existing) { + this.createdOrgId = existing.id ?? ""; + this.orgName = name; + } else { + throw e; + } + } + } +); + +// ── When: Create ──────────────────────────────────────────────────── + +When( + "I create an organisation with name {string} and admin email {string}", + async function (this: SuperpositionWorld, name: string, email: string) { + try { + this.lastResponse = await this.client.send( + new CreateOrganisationCommand({ admin_email: email, name }) + ); + this.createdOrgId = this.lastResponse.id ?? ""; + this.orgName = name; + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── When: Get ─────────────────────────────────────────────────────── + +When( + "I get the organisation by its ID", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetOrganisationCommand({ id: this.createdOrgId }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get an organisation with ID {string}", + async function (this: SuperpositionWorld, id: string) { + try { + this.lastResponse = await this.client.send( + new GetOrganisationCommand({ id }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── When: List ────────────────────────────────────────────────────── + +When("I list all organisations", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new ListOrganisationCommand({}) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I list organisations with count {int} and page {int}", + async function (this: SuperpositionWorld, count: number, page: number) { + try { + this.lastResponse = await this.client.send( + new ListOrganisationCommand({ count, page }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── When: Update ──────────────────────────────────────────────────── + +When( + "I update the organisation's admin email to {string}", + async function (this: SuperpositionWorld, email: string) { + try { + this.lastResponse = await this.client.send( + new UpdateOrganisationCommand({ id: this.createdOrgId, admin_email: email }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update organisation {string} admin email to {string}", + async function (this: SuperpositionWorld, id: string, email: string) { + try { + this.lastResponse = await this.client.send( + new UpdateOrganisationCommand({ id, admin_email: email }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.name, name); + } +); + +Then( + "the response should have admin email {string}", + function (this: SuperpositionWorld, email: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.admin_email, email); + } +); + +Then( + "the list should contain the created organisation", + function (this: SuperpositionWorld) { + const data = this.lastResponse?.data; + assert.ok(Array.isArray(data), "Response is not a list"); + const found = data.find((o: any) => o.id === this.createdOrgId); + assert.ok(found, `Organisation ${this.createdOrgId} not found in list`); + } +); + +Then( + "getting the organisation by ID should show admin email {string}", + async function (this: SuperpositionWorld, email: string) { + const response = await this.client.send( + new GetOrganisationCommand({ id: this.createdOrgId }) + ); + assert.strictEqual(response.admin_email, email); + } +); diff --git a/tests/cucumber/step_definitions/resolve_config_steps.ts b/tests/cucumber/step_definitions/resolve_config_steps.ts new file mode 100644 index 000000000..b6f577c70 --- /dev/null +++ b/tests/cucumber/step_definitions/resolve_config_steps.ts @@ -0,0 +1,141 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + GetResolvedConfigWithIdentifierCommand, + CreateDimensionCommand, + CreateDefaultConfigCommand, + CreateExperimentCommand, + RampExperimentCommand, + DiscardExperimentCommand, + DeleteDefaultConfigCommand, + DeleteDimensionCommand, + VariantType, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +const BUCKETING_DIM = "clientId"; +const BUCKETING_CONFIG_KEY = "testKey_resolve"; +const BUCKETING_CLIENT_ID = "test-client-bucketing-123"; +const BUCKETING_IDENTIFIER = "test-identifier-bucketing-456"; +const DEFAULT_VALUE = "default-bucketing-value"; +const EXPERIMENTAL_VALUE = "experimental-bucketing-value"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a dimension, default config, and experiment are set up for bucketing tests", + async function (this: SuperpositionWorld) { + // Create dimension + try { + await this.client.send( + new CreateDimensionCommand({ + dimension: BUCKETING_DIM, + workspace_id: this.workspaceId, + org_id: this.orgId, + schema: { type: "string" }, + position: 1, + change_reason: "Cucumber bucketing setup", + description: "Client ID dimension", + }) + ); + this.createdDimensions.push(BUCKETING_DIM); + } catch { + // Already exists + } + + // Create default config + const configKey = this.uniqueName(BUCKETING_CONFIG_KEY); + this.configKey = configKey; + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: configKey, + value: DEFAULT_VALUE, + schema: { type: "string" }, + description: "Bucketing test config", + change_reason: "Cucumber setup", + }) + ); + this.createdConfigs.push(configKey); + } catch { + // Already exists + } + + // Create experiment + const expResp = await this.client.send( + new CreateExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name: this.uniqueName("bucketing-exp"), + context: { [BUCKETING_DIM]: BUCKETING_CLIENT_ID }, + variants: [ + { + variant_type: VariantType.CONTROL, + id: "control", + overrides: { [configKey]: DEFAULT_VALUE }, + }, + { + variant_type: VariantType.EXPERIMENTAL, + id: "test1", + overrides: { [configKey]: EXPERIMENTAL_VALUE }, + }, + ], + description: "Bucketing test experiment", + change_reason: "Cucumber setup", + }) + ); + this.experimentId = expResp.id ?? ""; + this.createdExperimentIds.push(this.experimentId); + + // Ramp to 50% + await this.client.send( + new RampExperimentCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.experimentId, + traffic_percentage: 50, + change_reason: "Cucumber ramp", + }) + ); + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I resolve the config with the test identifier and matching context", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetResolvedConfigWithIdentifierCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + identifier: BUCKETING_IDENTIFIER, + context: { [BUCKETING_DIM]: BUCKETING_CLIENT_ID }, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the config value should be either the default or experimental value", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.config, "No config in response"); + const value = (this.lastResponse.config as any)[this.configKey]; + assert.ok( + value === DEFAULT_VALUE || value === EXPERIMENTAL_VALUE, + `Expected "${DEFAULT_VALUE}" or "${EXPERIMENTAL_VALUE}", got "${value}"` + ); + } +); diff --git a/tests/cucumber/step_definitions/secret_steps.ts b/tests/cucumber/step_definitions/secret_steps.ts new file mode 100644 index 000000000..1dd20e21c --- /dev/null +++ b/tests/cucumber/step_definitions/secret_steps.ts @@ -0,0 +1,261 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateSecretCommand, + GetSecretCommand, + UpdateSecretCommand, + DeleteSecretCommand, + CreateFunctionCommand, + TestCommand, + FunctionTypes, + FunctionRuntimeVersion, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a secret {string} exists with value {string}", + async function (this: SuperpositionWorld, name: string, value: string) { + this.secretName = name; + try { + await this.client.send( + new CreateSecretCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + description: `Test secret ${name}`, + change_reason: "Cucumber setup", + }) + ); + this.createdSecrets.push(name); + } catch { + // Already exists + } + } +); + +Given( + "a compute function exists that reads the secret {string}", + async function (this: SuperpositionWorld, secretName: string) { + this.functionName = this.uniqueName("verify_secret"); + const code = ` + async function execute(payload) { + return [SECRETS.${secretName}]; + } + `; + try { + await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: this.functionName, + function: code, + description: "Secret verification function", + change_reason: "Cucumber setup", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_COMPUTE, + }) + ); + this.createdFunctions.push(this.functionName); + } catch { + // Already exists + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create a secret named {string} with value {string}", + async function (this: SuperpositionWorld, name: string, value: string) { + this.secretName = name; + try { + this.lastResponse = await this.client.send( + new CreateSecretCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + description: `Test secret ${name}`, + change_reason: "Cucumber test", + }) + ); + if (this.lastResponse.name) { + this.createdSecrets.push(this.lastResponse.name); + } + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get secret {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new GetSecretCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update secret {string} value to {string}", + async function (this: SuperpositionWorld, name: string, value: string) { + try { + this.lastResponse = await this.client.send( + new UpdateSecretCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + change_reason: "Cucumber update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I delete secret {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new DeleteSecretCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + }) + ); + this.createdSecrets = this.createdSecrets.filter((s) => s !== name); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I test the compute function", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new TestCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: this.functionName, + stage: "draft", + request: { + value_compute: { + name: "", + prefix: "", + type: "ConfigKey", + environment: { context: {}, overrides: {} }, + }, + }, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I test the compute function again", + async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new TestCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: this.functionName, + stage: "draft", + request: { + value_compute: { + name: "", + prefix: "", + type: "ConfigKey", + environment: { context: {}, overrides: {} }, + }, + }, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have secret name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.name, name); + } +); + +Then( + "the secret value should not be returned", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.value, undefined); + } +); + +Then( + "the function output should contain {string}", + function (this: SuperpositionWorld, expected: string) { + assert.ok(this.lastResponse, "No response"); + const output = JSON.stringify(this.lastResponse.fn_output); + assert.ok( + output.includes(expected), + `Function output "${output}" does not contain "${expected}"` + ); + } +); + +Then( + "getting secret {string} should fail with {string}", + async function (this: SuperpositionWorld, name: string, errorPattern: string) { + try { + await this.client.send( + new GetSecretCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + }) + ); + assert.fail("Expected an error but got success"); + } catch (e: any) { + assert.ok( + e.message?.includes(errorPattern), + `Expected error containing "${errorPattern}", got "${e.message}"` + ); + } + } +); diff --git a/tests/cucumber/step_definitions/type_template_steps.ts b/tests/cucumber/step_definitions/type_template_steps.ts new file mode 100644 index 000000000..8bb1ed58c --- /dev/null +++ b/tests/cucumber/step_definitions/type_template_steps.ts @@ -0,0 +1,217 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateTypeTemplatesCommand, + UpdateTypeTemplatesCommand, + DeleteTypeTemplatesCommand, + GetTypeTemplatesListCommand, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a type template {string} exists with schema type {string}", + async function (this: SuperpositionWorld, name: string, schemaType: string) { + const uniqueName = this.uniqueName(name); + this.typeTemplateName = uniqueName; + try { + await this.client.send( + new CreateTypeTemplatesCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + type_name: uniqueName, + type_schema: { type: schemaType }, + description: `Test type template ${uniqueName}`, + change_reason: "Cucumber setup", + }) + ); + this.createdTypeTemplates.push(uniqueName); + } catch { + // Already exists + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When("I list type templates", async function (this: SuperpositionWorld) { + try { + this.lastResponse = await this.client.send( + new GetTypeTemplatesListCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I create a type template named {string} with schema type {string}", + async function (this: SuperpositionWorld, name: string, schemaType: string) { + const uniqueName = this.uniqueName(name); + this.typeTemplateName = uniqueName; + try { + this.lastResponse = await this.client.send( + new CreateTypeTemplatesCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + type_name: uniqueName, + type_schema: { type: schemaType }, + description: `${name} type template`, + change_reason: "Cucumber test", + }) + ); + this.createdTypeTemplates.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a type template named {string} with pattern {string}", + async function (this: SuperpositionWorld, name: string, pattern: string) { + const uniqueName = this.uniqueName(name); + this.typeTemplateName = uniqueName; + try { + this.lastResponse = await this.client.send( + new CreateTypeTemplatesCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + type_name: uniqueName, + type_schema: { type: "string", pattern }, + description: `${name} pattern type`, + change_reason: "Cucumber test", + }) + ); + this.createdTypeTemplates.push(uniqueName); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update type template {string} with minimum {int} and maximum {int}", + async function (this: SuperpositionWorld, name: string, min: number, max: number) { + try { + this.lastResponse = await this.client.send( + new UpdateTypeTemplatesCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + type_name: this.typeTemplateName, + type_schema: { type: "number", minimum: min, maximum: max }, + description: "Updated type", + change_reason: "Cucumber update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I delete type template {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new DeleteTypeTemplatesCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + type_name: this.typeTemplateName, + }) + ); + this.createdTypeTemplates = this.createdTypeTemplates.filter( + (t) => t !== this.typeTemplateName + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should contain a type template list", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.data !== undefined, "No data in response"); + assert.ok(Array.isArray(this.lastResponse.data), "data is not an array"); + } +); + +Then( + "the response should have type name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.type_name, this.typeTemplateName); + } +); + +Then( + "the response schema type should be {string}", + function (this: SuperpositionWorld, type: string) { + assert.ok(this.lastResponse, "No response"); + const schema = + typeof this.lastResponse.type_schema === "string" + ? JSON.parse(this.lastResponse.type_schema) + : this.lastResponse.type_schema; + assert.strictEqual(schema.type, type); + } +); + +Then( + "the response schema should have pattern {string}", + function (this: SuperpositionWorld, pattern: string) { + assert.ok(this.lastResponse, "No response"); + const schema = + typeof this.lastResponse.type_schema === "string" + ? JSON.parse(this.lastResponse.type_schema) + : this.lastResponse.type_schema; + assert.strictEqual(schema.pattern, pattern); + } +); + +Then( + "the response schema should have minimum {int} and maximum {int}", + function (this: SuperpositionWorld, min: number, max: number) { + assert.ok(this.lastResponse, "No response"); + const schema = + typeof this.lastResponse.type_schema === "string" + ? JSON.parse(this.lastResponse.type_schema) + : this.lastResponse.type_schema; + assert.strictEqual(schema.minimum, min); + assert.strictEqual(schema.maximum, max); + } +); + +Then( + "listing type templates should not include {string}", + async function (this: SuperpositionWorld, name: string) { + const list = await this.client.send( + new GetTypeTemplatesListCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + const found = (list.data ?? []).find( + (t: any) => t.type_name === this.typeTemplateName + ); + assert.strictEqual(found, undefined, `Type template "${this.typeTemplateName}" still exists`); + } +); diff --git a/tests/cucumber/step_definitions/variable_steps.ts b/tests/cucumber/step_definitions/variable_steps.ts new file mode 100644 index 000000000..cda325710 --- /dev/null +++ b/tests/cucumber/step_definitions/variable_steps.ts @@ -0,0 +1,284 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateVariableCommand, + GetVariableCommand, + UpdateVariableCommand, + DeleteVariableCommand, + CreateFunctionCommand, + TestCommand, + FunctionTypes, + FunctionRuntimeVersion, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a variable {string} exists with value {string}", + async function (this: SuperpositionWorld, name: string, value: string) { + this.variableName = name; + try { + await this.client.send( + new CreateVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + description: `Test variable ${name}`, + change_reason: "Cucumber setup", + }) + ); + this.createdVariables.push(name); + } catch { + // Already exists + } + } +); + +Given( + "a variable {string} exists with value {string} and description {string}", + async function (this: SuperpositionWorld, name: string, value: string, desc: string) { + this.variableName = name; + try { + await this.client.send( + new CreateVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + description: desc, + change_reason: "Cucumber setup", + }) + ); + this.createdVariables.push(name); + } catch { + // Already exists + } + } +); + +Given( + "a function {string} exists that reads VARS.{word}", + async function (this: SuperpositionWorld, funcName: string, varName: string) { + this.functionName = funcName; + const code = ` + async function execute(payload) { + console.log("API Key:", VARS.${varName}); + return VARS.${varName} === 'secret-api-key-12345'; + } + `; + try { + await this.client.send( + new CreateFunctionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: funcName, + function: code, + description: "Function accessing variable", + change_reason: "Cucumber setup", + runtime_version: FunctionRuntimeVersion.V1, + function_type: FunctionTypes.VALUE_VALIDATION, + }) + ); + this.createdFunctions.push(funcName); + } catch { + // Already exists + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create a variable named {string} with value {string}", + async function (this: SuperpositionWorld, name: string, value: string) { + this.variableName = name; + try { + this.lastResponse = await this.client.send( + new CreateVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + description: `Test variable ${name}`, + change_reason: "Cucumber test", + }) + ); + if (this.lastResponse.name) { + this.createdVariables.push(this.lastResponse.name); + } + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get variable {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new GetVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update variable {string} value to {string}", + async function (this: SuperpositionWorld, name: string, value: string) { + try { + this.lastResponse = await this.client.send( + new UpdateVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + value, + change_reason: "Cucumber update", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update variable {string} description to {string}", + async function (this: SuperpositionWorld, name: string, desc: string) { + try { + this.lastResponse = await this.client.send( + new UpdateVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + description: desc, + change_reason: "Cucumber update description", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I delete variable {string}", + async function (this: SuperpositionWorld, name: string) { + try { + this.lastResponse = await this.client.send( + new DeleteVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + }) + ); + this.createdVariables = this.createdVariables.filter((v) => v !== name); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I test the function {string}", + async function (this: SuperpositionWorld, funcName: string) { + try { + this.lastResponse = await this.client.send( + new TestCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + function_name: funcName, + stage: "draft", + request: { + value_validate: { + key: "", + value: "", + type: "ConfigKey", + environment: { context: {}, overrides: {} }, + }, + }, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should have variable name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.name, name); + } +); + +Then( + "the response should have variable value {string}", + function (this: SuperpositionWorld, value: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.value, value); + } +); + +Then( + "getting variable {string} should fail with {string}", + async function (this: SuperpositionWorld, name: string, errorPattern: string) { + try { + await this.client.send( + new GetVariableCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + name, + }) + ); + assert.fail("Expected an error but got success"); + } catch (e: any) { + assert.ok( + e.message?.includes(errorPattern), + `Expected error containing "${errorPattern}", got "${e.message}"` + ); + } + } +); + +Then( + "the function output should be true", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.fn_output, true); + } +); + +Then( + "the function stdout should contain {string}", + function (this: SuperpositionWorld, expected: string) { + assert.ok(this.lastResponse, "No response"); + assert.ok( + this.lastResponse.stdout?.includes(expected), + `stdout "${this.lastResponse.stdout}" does not contain "${expected}"` + ); + } +); diff --git a/tests/cucumber/step_definitions/workspace_steps.ts b/tests/cucumber/step_definitions/workspace_steps.ts new file mode 100644 index 000000000..332e66137 --- /dev/null +++ b/tests/cucumber/step_definitions/workspace_steps.ts @@ -0,0 +1,211 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateWorkspaceCommand, + ListWorkspaceCommand, + UpdateWorkspaceCommand, + WorkspaceStatus, +} from "@juspay/superposition-sdk"; +import { SuperpositionWorld } from "../support/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "an organisation exists for workspace tests", + function (this: SuperpositionWorld) { + // orgId is set in the Before hook + assert.ok(this.orgId, "Organisation ID not available"); + } +); + +Given( + "a workspace exists with name {string}", + async function (this: SuperpositionWorld, name: string) { + const uniqueName = this.uniqueName(name); + try { + const response = await this.client.send( + new CreateWorkspaceCommand({ + org_id: this.orgId, + workspace_admin_email: "admin@example.com", + workspace_name: uniqueName, + workspace_status: WorkspaceStatus.ENABLED, + allow_experiment_self_approval: true, + auto_populate_control: false, + enable_context_validation: true, + enable_change_reason_validation: true, + }) + ); + this.workspaceName = uniqueName; + } catch { + // May already exist + this.workspaceName = uniqueName; + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I list workspaces with count {int} and page {int}", + async function (this: SuperpositionWorld, count: number, page: number) { + try { + this.lastResponse = await this.client.send( + new ListWorkspaceCommand({ count, page, org_id: this.orgId }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I create a workspace with name {string} and admin email {string}", + async function (this: SuperpositionWorld, name: string, email: string) { + const uniqueName = name ? this.uniqueName(name) : name; + try { + this.lastResponse = await this.client.send( + new CreateWorkspaceCommand({ + org_id: this.orgId, + workspace_admin_email: email, + workspace_name: uniqueName, + workspace_status: WorkspaceStatus.ENABLED, + allow_experiment_self_approval: true, + auto_populate_control: false, + enable_context_validation: true, + enable_change_reason_validation: true, + }) + ); + this.workspaceName = uniqueName; + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I update workspace {string} admin email to {string}", + async function (this: SuperpositionWorld, name: string, email: string) { + const uniqueName = this.uniqueName(name); + try { + this.lastResponse = await this.client.send( + new UpdateWorkspaceCommand({ + org_id: this.orgId, + workspace_name: uniqueName, + workspace_admin_email: email, + workspace_status: WorkspaceStatus.ENABLED, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I list workspaces filtered by status {string}", + async function (this: SuperpositionWorld, status: string) { + try { + this.lastResponse = await this.client.send( + new ListWorkspaceCommand({ + count: 5, + page: 1, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I list workspaces for organisation {string}", + async function (this: SuperpositionWorld, orgId: string) { + try { + this.lastResponse = await this.client.send( + new ListWorkspaceCommand({ count: 10, page: 1, org_id: orgId }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the response should contain a workspace list", + function (this: SuperpositionWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.data, "No data in response"); + assert.ok(Array.isArray(this.lastResponse.data), "data is not an array"); + } +); + +Then( + "the response should have a {string} count", + function (this: SuperpositionWorld, field: string) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse[field] !== undefined, `Missing ${field}`); + assert.strictEqual(typeof this.lastResponse[field], "number"); + } +); + +Then( + "the response should have workspace name {string}", + function (this: SuperpositionWorld, name: string) { + assert.ok(this.lastResponse, "No response"); + // The name was made unique, so check the original created name + assert.ok( + this.lastResponse.workspace_name?.includes(name) || this.lastResponse.workspace_name === this.workspaceName, + `Expected workspace name containing "${name}", got "${this.lastResponse.workspace_name}"` + ); + } +); + +Then( + "the response should have workspace status {string}", + function (this: SuperpositionWorld, status: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.workspace_status, status); + } +); + +Then( + "the response should have workspace admin email {string}", + function (this: SuperpositionWorld, email: string) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.workspace_admin_email, email); + } +); + +Then( + "the list should contain workspace {string}", + function (this: SuperpositionWorld, name: string) { + const data = this.lastResponse?.data; + assert.ok(Array.isArray(data), "No list data"); + const found = data.find((w: any) => w.workspace_name === this.workspaceName); + assert.ok(found, `Workspace "${this.workspaceName}" not found in list`); + } +); + +Then( + "all returned workspaces should have status {string}", + function (this: SuperpositionWorld, status: string) { + const data = this.lastResponse?.data; + assert.ok(Array.isArray(data), "No list data"); + for (const ws of data) { + assert.strictEqual(ws.workspace_status, status); + } + } +); diff --git a/tests/cucumber/step_definitions_ui/common_steps.ts b/tests/cucumber/step_definitions_ui/common_steps.ts new file mode 100644 index 000000000..9c5dfd4df --- /dev/null +++ b/tests/cucumber/step_definitions_ui/common_steps.ts @@ -0,0 +1,94 @@ +import { Then } from "@cucumber/cucumber"; +import { PlaywrightWorld } from "../support_ui/world.ts"; +import * as assert from "node:assert"; + +// ── Generic outcome assertions ────────────────────────────────────── + +Then("the operation should succeed", function (this: PlaywrightWorld) { + assert.strictEqual(this.lastError, undefined, `Operation failed unexpectedly: ${this.lastError?.message}`); + assert.ok(this.lastResponse !== undefined, "Expected a successful response but got none"); +}); + +Then("the operation should fail", function (this: PlaywrightWorld) { + assert.ok(this.lastError !== undefined, "Expected an error but operation succeeded"); +}); + +Then( + "the operation should fail with error matching {string}", + function (this: PlaywrightWorld, errorPattern: string) { + assert.ok(this.lastError !== undefined, "Expected an error but operation succeeded"); + // The SDK may throw a SyntaxError when the server returns non-JSON error responses. + // In that case, the raw server response is available on $response.body. + const rawBody = typeof this.lastError?.$response?.body === "string" + ? this.lastError.$response.body + : ""; + const message = rawBody || this.lastError?.message || String(this.lastError); + assert.ok( + message.includes(errorPattern), + `Error "${message}" does not contain "${errorPattern}"` + ); + } +); + +// ── Generic response property assertions ──────────────────────────── + +Then( + "the response should have an {string} property", + function (this: PlaywrightWorld, prop: string) { + assert.ok(this.lastResponse, "No response available"); + assert.ok( + this.lastResponse[prop] !== undefined, + `Response missing property "${prop}"` + ); + } +); + +Then( + "the response should have a {string} property", + function (this: PlaywrightWorld, prop: string) { + assert.ok(this.lastResponse, "No response available"); + assert.ok( + this.lastResponse[prop] !== undefined, + `Response missing property "${prop}"` + ); + } +); + +Then("the response should contain a list", function (this: PlaywrightWorld) { + assert.ok(this.lastResponse, "No response available"); + const data = this.lastResponse.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); +}); + +Then( + "the response should contain a list with at least {int} item(s)", + function (this: PlaywrightWorld, count: number) { + assert.ok(this.lastResponse, "No response available"); + const data = this.lastResponse.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); + assert.ok(data.length >= count, `Expected at least ${count} items, got ${data.length}`); + } +); + +Then( + "the response should contain a list with at most {int} item(s)", + function (this: PlaywrightWorld, count: number) { + assert.ok(this.lastResponse, "No response available"); + const data = this.lastResponse.data ?? this.lastResponse; + assert.ok(Array.isArray(data), "Response is not a list"); + assert.ok(data.length <= count, `Expected at most ${count} items, got ${data.length}`); + } +); + +Then("the response should have a version", function (this: PlaywrightWorld) { + assert.ok(this.lastResponse, "No response available"); + assert.ok(this.lastResponse.version !== undefined, "Response missing version"); +}); + +Then( + "the response description should be {string}", + function (this: PlaywrightWorld, expected: string) { + assert.ok(this.lastResponse, "No response available"); + assert.strictEqual(this.lastResponse.description, expected); + } +); diff --git a/tests/cucumber/step_definitions_ui/config_steps.ts b/tests/cucumber/step_definitions_ui/config_steps.ts new file mode 100644 index 000000000..a3f446b0a --- /dev/null +++ b/tests/cucumber/step_definitions_ui/config_steps.ts @@ -0,0 +1,183 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + GetConfigCommand, + ListVersionsCommand, + UpdateWorkspaceCommand, + CreateDefaultConfigCommand, + DeleteDefaultConfigCommand, +} from "@juspay/superposition-sdk"; +import { PlaywrightWorld } from "../support_ui/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "a test default config exists for config retrieval", + async function (this: PlaywrightWorld) { + this.configKey = this.uniqueName("cfg-retrieval"); + try { + await this.client.send( + new CreateDefaultConfigCommand({ + key: this.configKey, + value: { enabled: true, message: "test config" }, + schema: { type: "object" }, + description: "Test config for retrieval tests", + change_reason: "Cucumber setup", + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.createdConfigs.push(this.configKey); + } catch { + // Already exists + } + } +); + +Given( + "I know the current config version", + async function (this: PlaywrightWorld) { + const cmd = new GetConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + }); + const out = await this.client.send(cmd); + this.configVersionId = out.version ?? undefined; + assert.ok(this.configVersionId, "Could not determine config version"); + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I get the config with the test config key prefix", + async function (this: PlaywrightWorld) { + try { + // Navigate to default-config page for UI context + await this.goToWorkspacePage("default-config"); + await this.page.waitForTimeout(300); + + // Fetch via SDK for response assertions + this.lastResponse = await this.client.send( + new GetConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + }) + ); + this.configVersionId = this.lastResponse.version ?? undefined; + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I pin the workspace to that config version", + async function (this: PlaywrightWorld) { + try { + this.lastResponse = await this.client.send( + new UpdateWorkspaceCommand({ + org_id: this.orgId, + workspace_name: this.workspaceId, + config_version: this.configVersionId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I get the config again", async function (this: PlaywrightWorld) { + try { + // Navigate to default-config page for UI context + await this.goToWorkspacePage("default-config"); + await this.page.waitForTimeout(300); + + // Fetch via SDK for response assertions + this.lastResponse = await this.client.send( + new GetConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + prefix: [this.configKey], + context: {}, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I unpin the workspace config version", + async function (this: PlaywrightWorld) { + try { + this.lastResponse = await this.client.send( + new UpdateWorkspaceCommand({ + org_id: this.orgId, + workspace_name: this.workspaceId, + description: "Unset config version", + config_version: "null", + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I list config versions with count {int} and page {int}", + async function (this: PlaywrightWorld, count: number, page: number) { + try { + // Navigate to the config versions page (actual UI test) + await this.goToWorkspacePage("config/versions"); + await this.page.waitForTimeout(500); + // Also get data via SDK for assertions + this.lastResponse = await this.client.send( + new ListVersionsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + count, + page, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +// ── Then ──────────────────────────────────────────────────────────── + +Then( + "the config version should match the pinned version", + function (this: PlaywrightWorld) { + assert.ok(this.lastResponse, "No response"); + assert.ok(this.lastResponse.version, "No version in response"); + assert.strictEqual( + this.lastResponse.version?.toString(), + this.configVersionId?.toString() + ); + } +); + +Then( + "the workspace config version should be unset", + function (this: PlaywrightWorld) { + assert.ok(this.lastResponse, "No response"); + assert.strictEqual(this.lastResponse.config_version, undefined); + } +); diff --git a/tests/cucumber/step_definitions_ui/context_steps.ts b/tests/cucumber/step_definitions_ui/context_steps.ts new file mode 100644 index 000000000..a4a77bf4e --- /dev/null +++ b/tests/cucumber/step_definitions_ui/context_steps.ts @@ -0,0 +1,418 @@ +import { Given, When, Then } from "@cucumber/cucumber"; +import { + CreateContextCommand, + GetContextCommand, + ListContextsCommand, + UpdateOverrideCommand, + MoveContextCommand, + BulkOperationCommand, + WeightRecomputeCommand, + CreateDimensionCommand, + CreateDefaultConfigCommand, +} from "@juspay/superposition-sdk"; +import { PlaywrightWorld } from "../support_ui/world.ts"; +import * as assert from "node:assert"; + +// ── Given ─────────────────────────────────────────────────────────── + +Given( + "dimensions and default configs are set up for context tests", + async function (this: PlaywrightWorld) { + // Create "os" dimension if not exists + try { + await this.client.send( + new CreateDimensionCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + dimension: "os", + position: 1, + schema: { type: "string", enum: ["android", "ios", "web"] }, + description: "OS dimension", + change_reason: "Cucumber context test setup", + }) + ); + this.createdDimensions.push("os"); + } catch { + // Already exists + } + + // Create config key + const configKey = "ctx-config-key"; + try { + await this.client.send( + new CreateDefaultConfigCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + key: configKey, + value: "default", + schema: { type: "string" }, + description: "Config for context tests", + change_reason: "Cucumber setup", + }) + ); + this.createdConfigs.push(configKey); + } catch { + // Already exists + } + } +); + +Given( + "a context exists with condition {string} equals {string} and override {string} to {string}", + async function ( + this: PlaywrightWorld, + dimName: string, + dimValue: string, + configKey: string, + configValue: string + ) { + try { + const response = await this.client.send( + new CreateContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { [dimName]: dimValue }, + override: { [configKey]: configValue }, + description: "Cucumber test context", + change_reason: "Cucumber setup", + }, + }) + ); + this.contextId = response.id ?? ""; + this.createdContextIds.push(this.contextId); + } catch { + // May already exist + } + } +); + +Given( + "contexts exist for weight recompute", + async function (this: PlaywrightWorld) { + // Create a couple of contexts + for (const val of ["android", "ios"]) { + try { + const response = await this.client.send( + new CreateContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { os: val }, + override: { "ctx-config-key": `${val}-weight` }, + description: "Weight recompute test", + change_reason: "Cucumber setup", + }, + }) + ); + this.createdContextIds.push(response.id ?? ""); + } catch { + // May already exist + } + } + } +); + +// ── When ──────────────────────────────────────────────────────────── + +When( + "I create a context with condition {string} equals {string} and override {string} to {string}", + async function ( + this: PlaywrightWorld, + dimName: string, + dimValue: string, + configKey: string, + configValue: string + ) { + try { + // Navigate to overrides page (Playwright) + await this.goToWorkspacePage("overrides"); + await this.page.waitForTimeout(500); + + // Create via SDK (drawer form is too complex for full Playwright interaction) + this.lastResponse = await this.client.send( + new CreateContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { [dimName]: dimValue }, + override: { [configKey]: configValue }, + description: "Cucumber test context", + change_reason: "Cucumber test", + }, + }) + ); + this.contextId = this.lastResponse.id ?? ""; + this.createdContextIds.push(this.contextId); + + // Reload the page and verify the new context card appears + await this.page.reload(); + await this.page.waitForTimeout(500); + const conditionEl = this.page.locator(`[id="${this.contextId}"]`); + await conditionEl.waitFor({ state: "visible", timeout: 10000 }); + + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I get the context by its ID", + async function (this: PlaywrightWorld) { + try { + // Navigate to overrides page and verify the context card is visible (Playwright) + await this.goToWorkspacePage("overrides"); + await this.page.waitForTimeout(500); + const conditionEl = this.page.locator(`[id="${this.contextId}"]`); + await conditionEl.waitFor({ state: "visible", timeout: 10000 }); + + // Use SDK to retrieve full response data for assertions + this.lastResponse = await this.client.send( + new GetContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.contextId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I list all contexts", async function (this: PlaywrightWorld) { + try { + // Navigate to the overrides page (actual UI test) + await this.goToWorkspacePage("overrides"); + await this.page.waitForTimeout(500); + // Also get data via SDK for assertions + this.lastResponse = await this.client.send( + new ListContextsCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + }) + ); + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } +}); + +When( + "I update the context override for {string} to {string}", + async function (this: PlaywrightWorld, configKey: string, newValue: string) { + try { + // Navigate to the overrides page and verify the context card exists (Playwright) + await this.goToWorkspacePage("overrides"); + await this.page.waitForTimeout(500); + const conditionEl = this.page.locator(`[id="${this.contextId}"]`); + await conditionEl.waitFor({ state: "visible", timeout: 10000 }); + + // Use SDK for actual update (edit drawer form is too complex for full Playwright interaction) + this.lastResponse = await this.client.send( + new UpdateOverrideCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + request: { + context: { id: this.contextId }, + override: { [configKey]: newValue }, + description: "Updated override", + change_reason: "Cucumber update test", + }, + }) + ); + + // Reload the page and verify the context card is still present after update + await this.page.reload(); + await this.page.waitForTimeout(500); + await conditionEl.waitFor({ state: "visible", timeout: 10000 }); + + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When( + "I move the context to condition {string} equals {string}", + async function (this: PlaywrightWorld, dimName: string, dimValue: string) { + try { + // Navigate to overrides page (Playwright) — move has no direct UI equivalent + await this.goToWorkspacePage("overrides"); + await this.page.waitForTimeout(500); + + // Use SDK for the move operation + this.lastResponse = await this.client.send( + new MoveContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.contextId, + request: { + context: { [dimName]: dimValue }, + description: "Moved context", + change_reason: "Cucumber move test", + }, + }) + ); + + // Update tracked contextId if the move returned a new ID + if (this.lastResponse.id && this.lastResponse.id !== this.contextId) { + this.createdContextIds = this.createdContextIds.filter( + (id) => id !== this.contextId + ); + this.contextId = this.lastResponse.id; + this.createdContextIds.push(this.contextId); + } + + this.lastError = undefined; + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + } + } +); + +When("I delete the context", async function (this: PlaywrightWorld) { + // Pre-check existence via SDK to avoid long Playwright timeout on missing entity + try { + await this.client.send( + new GetContextCommand({ + workspace_id: this.workspaceId, + org_id: this.orgId, + id: this.contextId, + }) + ); + } catch (e: any) { + this.lastError = e; + this.lastResponse = undefined; + return; + } + try { + // Navigate to the overrides page + await this.goToWorkspacePage("overrides"); + await this.page.waitForTimeout(500); + + // The ConditionComponent for each context card renders with id=contextId on the
    element. + // Use attribute selector to handle IDs starting with digits (which are invalid CSS #id selectors) + const conditionEl = this.page.locator(`[id="${this.contextId}"]`); + await conditionEl.waitFor({ state: "visible", timeout: 10000 }); + + // The card is the closest ancestor div with rounded-lg shadow bg-base-100 classes + const card = conditionEl.locator( + "xpath=ancestor::div[contains(@class,'rounded-lg') and contains(@class,'shadow')][1]" + ); + + // The dropdown toggle is a