From 498644005a52303059d97f912ac6ec7f86c5f3e7 Mon Sep 17 00:00:00 2001 From: Artur Tagisow Date: Wed, 29 Jun 2022 12:03:48 +0200 Subject: [PATCH] test(load): add K6 load test workflow with example test This commit adds a GitHub Actions workflow (you have to *manually* trigger it) that uses the K6 load testing utillity to run tests on a given environment. I had to add the `K6_API_TOKEN` secret to GitHub to allow running the tests in K6's cloud (VSF has a paid plan). You can also run the test on GitHub's agent though. M2-899 fix: use js, not ts file chore: partially revert #1107 This reverts commit 9b8de85de8018133df266a7ccd2dc8295dbcfb64. This change was made because K6 recorded tests are gigantic when customQuery sends the GraphQL AST in the request body. After this change, this the request is sent in string form, but is converted into GraphQL AST on the middleware side (because Apollo GraphQL client expects AST (DocumentNode TS type), not string) test(load): improve gql requests refactor: fix newlines --- .eslintrc.js | 3 + .github/workflows/run-k6-load-test.yml | 52 +++ .../src/api/customMutation/index.ts | 6 +- .../api-client/src/api/customQuery/index.ts | 6 +- packages/api-client/src/types/API.ts | 6 +- .../TopBar/checkStoresAndCurrency.gql.ts | 4 +- packages/theme/composables/useApi/index.ts | 11 +- .../components/cms/categoryContent.gql.ts | 4 +- .../command/getProductFilterByCategory.gql.ts | 4 +- .../composables/useFacet/getFacetData.gql.ts | 4 +- .../stores/graphql/categoryList.gql.ts | 6 +- .../catalog/pricing/getPricesQuery.gql.ts | 4 +- .../queries/getProductPriceBySku.gql.ts | 125 ++++++ packages/theme/package.json | 2 +- .../theme/plugins/query/StoreConfig.gql.ts | 4 +- packages/theme/tests/load/searchProduct.js | 355 ++++++++++++++++++ yarn.lock | 5 + 17 files changed, 561 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/run-k6-load-test.yml create mode 100644 packages/theme/modules/catalog/product/queries/getProductPriceBySku.gql.ts create mode 100644 packages/theme/tests/load/searchProduct.js diff --git a/.eslintrc.js b/.eslintrc.js index 976fd049c..9b420a463 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,9 @@ module.exports = { '@vue-storefront/eslint-config-vue', '@vue-storefront/eslint-config-jest', ], + globals: { + "__ENV": "readonly", + }, rules: { "@typescript-eslint/no-floating-promises": "off", "jest/expect-expect": [ diff --git a/.github/workflows/run-k6-load-test.yml b/.github/workflows/run-k6-load-test.yml new file mode 100644 index 000000000..f29bb4ebd --- /dev/null +++ b/.github/workflows/run-k6-load-test.yml @@ -0,0 +1,52 @@ +name: Run K6 test file +on: + workflow_dispatch: + inputs: + cloud: + description: True = run in K6 cloud; False = run on the test on GitHub Actions agent + required: true + default: false + type: boolean + + filename: + description: The K6 test file to run relative to repository root + required: true + default: packages/theme/tests/load/searchProduct.js + type: string + + environment: + description: The full URL of the environment on which load tests will be ran + required: true + default: https://demo-magento2-canary.europe-west1.gcp.storefrontcloud.io + type: choice + options: + - https://demo-magento2-canary.europe-west1.gcp.storefrontcloud.io + - https://demo-magento2-dev.europe-west1.gcp.storefrontcloud.io + - https://demo-magento2.europe-west1.gcp.storefrontcloud.cloud + - https://demo-magento2-enterprise.europe-west1.gcp.storefrontcloud.io + + flags: + description: Additional argument and flags to provide to the k6 CLI. See https://k6.io/docs/using-k6/options for details. + required: false + default: '' + type: string + +jobs: + build: + name: Run k6 cloud test + runs-on: ubuntu-latest + environment: test + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Run k6 cloud test + uses: grafana/k6-action@v0.2.0 + with: + cloud: ${{ inputs.cloud }} + token: ${{ secrets.K6_CLOUD_API_TOKEN }} + filename: ${{ inputs.filename }} + flags: ${{ inputs.flags }} + env: + BASE_URL: ${{ inputs.environment }} diff --git a/packages/api-client/src/api/customMutation/index.ts b/packages/api-client/src/api/customMutation/index.ts index dbe9d36cc..bb6d2c25f 100644 --- a/packages/api-client/src/api/customMutation/index.ts +++ b/packages/api-client/src/api/customMutation/index.ts @@ -1,5 +1,5 @@ +import gql from 'graphql-tag'; import { FetchPolicy, FetchResult } from '@apollo/client/core'; -import { DocumentNode } from 'graphql'; import { Context } from '../../types/context'; import getHeaders from '../getHeaders'; @@ -10,12 +10,12 @@ export default async ( mutationVariables, fetchPolicy, }: { - mutation: DocumentNode, + mutation: string, mutationVariables: MUTATION_VARIABLES, fetchPolicy?: Extract, }, ): Promise> => context.client.mutate({ - mutation, + mutation: gql`${mutation}`, variables: { ...mutationVariables }, fetchPolicy: fetchPolicy || 'no-cache', context: { diff --git a/packages/api-client/src/api/customQuery/index.ts b/packages/api-client/src/api/customQuery/index.ts index 970f3c1e7..903c2cadb 100644 --- a/packages/api-client/src/api/customQuery/index.ts +++ b/packages/api-client/src/api/customQuery/index.ts @@ -1,5 +1,5 @@ +import gql from 'graphql-tag'; import { ApolloQueryResult, FetchPolicy } from '@apollo/client/core'; -import { DocumentNode } from 'graphql'; import { Context } from '../../types/context'; import getHeaders from '../getHeaders'; @@ -10,12 +10,12 @@ export default async ( queryVariables, fetchPolicy, }: { - query: DocumentNode, + query: string, queryVariables?: QUERY_VARIABLES, fetchPolicy?: FetchPolicy, }, ): Promise> => context.client.query({ - query, + query: gql`${query}`, variables: { ...queryVariables }, fetchPolicy: fetchPolicy || 'no-cache', context: { diff --git a/packages/api-client/src/types/API.ts b/packages/api-client/src/types/API.ts index a01a1f4dd..debc3e52d 100644 --- a/packages/api-client/src/types/API.ts +++ b/packages/api-client/src/types/API.ts @@ -1,5 +1,5 @@ import { ApolloQueryResult, FetchPolicy, FetchResult } from '@apollo/client/core'; -import { DocumentNode, ExecutionResult } from 'graphql'; +import { ExecutionResult } from 'graphql'; import { CustomQuery } from '@vue-storefront/core'; import { AddConfigurableProductsToCartInput, @@ -279,13 +279,13 @@ export interface MagentoApiMethods { ): Promise>; customQuery(params: { - query: DocumentNode, + query: string, queryVariables?: QUERY_VARIABLES, fetchPolicy?: FetchPolicy, }): Promise>; customMutation(params: { - mutation: DocumentNode, + mutation: string, mutationVariables: MUTATION_VARIABLES, fetchPolicy?: Extract, }): Promise>; diff --git a/packages/theme/components/TopBar/checkStoresAndCurrency.gql.ts b/packages/theme/components/TopBar/checkStoresAndCurrency.gql.ts index 55536b414..5dbc99ddf 100644 --- a/packages/theme/components/TopBar/checkStoresAndCurrency.gql.ts +++ b/packages/theme/components/TopBar/checkStoresAndCurrency.gql.ts @@ -1,6 +1,4 @@ -import gql from 'graphql-tag'; - -export default gql` +export default ` query getStoresAndCurrencies { availableStores { store_code diff --git a/packages/theme/composables/useApi/index.ts b/packages/theme/composables/useApi/index.ts index e6782b031..9ad887848 100644 --- a/packages/theme/composables/useApi/index.ts +++ b/packages/theme/composables/useApi/index.ts @@ -1,5 +1,4 @@ import { useContext } from '@nuxtjs/composition-api'; -import type { DocumentNode } from 'graphql'; import { Logger } from '~/helpers/logger'; export type FetchPolicy = 'cache-first' | 'network-only' | 'cache-only' | 'no-cache' | 'standby'; @@ -19,7 +18,7 @@ export type Error = { }; export type Request = ( - request: DocumentNode, + request: string, variables?: VARIABLES, fetchPolicy?: FetchPolicy, ) => Promise<{ data: DATA, errors: Error[] }>; @@ -75,10 +74,6 @@ export interface UseApiInterface { mutate: Request; } -function getGqlString(doc: DocumentNode) { - return doc.loc && doc.loc.source.body; -} - /** * Allows executing arbitrary GraphQL queries and mutations. * @@ -93,7 +88,7 @@ export function useApi(): UseApiInterface { variables, ) => { const reqID = `id${Math.random().toString(16).slice(2)}`; - Logger.debug(`customQuery/request/${reqID}`, getGqlString(request)); + Logger.debug(`customQuery/request/${reqID}`, request); const { data, errors } = await context.app.$vsf.$magento.api.customQuery({ query: request, queryVariables: variables }); Logger.debug(`customQuery/result/${reqID}`, { data, errors }); @@ -106,7 +101,7 @@ export function useApi(): UseApiInterface { variables, ) => { const reqID = `id${Math.random().toString(16).slice(2)}`; - Logger.debug(`customQuery/request/${reqID}`, getGqlString(request)); + Logger.debug(`customQuery/request/${reqID}`, request); const { data, errors } = await context.app.$vsf.$magento.api.customMutation({ mutation: request, mutationVariables: variables }); Logger.debug(`customQuery/result/${reqID}`, { data, errors }); diff --git a/packages/theme/modules/catalog/category/components/cms/categoryContent.gql.ts b/packages/theme/modules/catalog/category/components/cms/categoryContent.gql.ts index 88c745af2..dce23a8e0 100644 --- a/packages/theme/modules/catalog/category/components/cms/categoryContent.gql.ts +++ b/packages/theme/modules/catalog/category/components/cms/categoryContent.gql.ts @@ -1,6 +1,4 @@ -import gql from 'graphql-tag'; - -export default gql` +export default ` query getCategoryContentData($filters: CategoryFilterInput) { categoryList(filters: $filters) { uid diff --git a/packages/theme/modules/catalog/category/components/filters/command/getProductFilterByCategory.gql.ts b/packages/theme/modules/catalog/category/components/filters/command/getProductFilterByCategory.gql.ts index 573a1404e..c0cedc9b9 100644 --- a/packages/theme/modules/catalog/category/components/filters/command/getProductFilterByCategory.gql.ts +++ b/packages/theme/modules/catalog/category/components/filters/command/getProductFilterByCategory.gql.ts @@ -1,6 +1,4 @@ -import gql from 'graphql-tag'; - -export default gql` +export default ` query getProductFiltersByCategory($categoryIdFilter: FilterEqualTypeInput!) { products(filter: { category_uid: $categoryIdFilter }) { aggregations { diff --git a/packages/theme/modules/catalog/category/composables/useFacet/getFacetData.gql.ts b/packages/theme/modules/catalog/category/composables/useFacet/getFacetData.gql.ts index 4a9a821c1..41073bdf8 100644 --- a/packages/theme/modules/catalog/category/composables/useFacet/getFacetData.gql.ts +++ b/packages/theme/modules/catalog/category/composables/useFacet/getFacetData.gql.ts @@ -1,10 +1,8 @@ -import gql from 'graphql-tag'; - /** * GraphQL Query that fetches products using received search term and the params * for filter, sort and pagination. */ -export default gql` +export default ` query getFacetData($search: String = "", $filter: ProductAttributeFilterInput, $pageSize: Int = 10, $currentPage: Int = 1, $sort: ProductAttributeSortInput) { products(search: $search, filter: $filter, pageSize: $pageSize, currentPage: $currentPage, sort: $sort) { items { diff --git a/packages/theme/modules/catalog/category/stores/graphql/categoryList.gql.ts b/packages/theme/modules/catalog/category/stores/graphql/categoryList.gql.ts index 1435131eb..aa5582c55 100644 --- a/packages/theme/modules/catalog/category/stores/graphql/categoryList.gql.ts +++ b/packages/theme/modules/catalog/category/stores/graphql/categoryList.gql.ts @@ -1,6 +1,4 @@ -import gql from 'graphql-tag'; - -const fragmentCategory = gql` +const fragmentCategory = ` fragment CategoryFields on CategoryTree { is_anchor name @@ -13,7 +11,7 @@ const fragmentCategory = gql` } `; -export default gql` +export default ` query categoryList { categories { items { diff --git a/packages/theme/modules/catalog/pricing/getPricesQuery.gql.ts b/packages/theme/modules/catalog/pricing/getPricesQuery.gql.ts index 441fa1221..771ec3551 100644 --- a/packages/theme/modules/catalog/pricing/getPricesQuery.gql.ts +++ b/packages/theme/modules/catalog/pricing/getPricesQuery.gql.ts @@ -1,6 +1,4 @@ -import gql from 'graphql-tag'; - -export default gql` +export default ` query productsList($search: String = "", $filter: ProductAttributeFilterInput, $pageSize: Int = 20, $currentPage: Int = 1, $sort: ProductAttributeSortInput) { products(search: $search, filter: $filter, pageSize: $pageSize, currentPage: $currentPage, sort: $sort) { items { diff --git a/packages/theme/modules/catalog/product/queries/getProductPriceBySku.gql.ts b/packages/theme/modules/catalog/product/queries/getProductPriceBySku.gql.ts new file mode 100644 index 000000000..9e98d3525 --- /dev/null +++ b/packages/theme/modules/catalog/product/queries/getProductPriceBySku.gql.ts @@ -0,0 +1,125 @@ +const fragmentPriceRangeFields = ` + fragment PriceRangeFields on PriceRange { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } +`; + +export default ` + query getProductPriceBySku($sku: String) { + products(filter: {sku: {eq: $sku}}) { + items { + price_range { + ...PriceRangeFields + } + + ... on BundleProduct { + items { + position + required + sku + title + type + uid + options { + can_change_quantity + is_default + position + uid + quantity + product { + uid + sku + name + price_range { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } + } + } + } + } + + ... on GroupedProduct { + items { + position + qty + product { + uid + sku + name + stock_status + only_x_left_in_stock + price_range { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } + thumbnail { + url + position + disabled + label + } + } + } + } + + } + } + } + ${fragmentPriceRangeFields} +`; diff --git a/packages/theme/package.json b/packages/theme/package.json index afb30e6fb..30b4ed55e 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -46,7 +46,6 @@ "cookie-universal-nuxt": "^2.1.5", "deepdash": "^5.3.9", "express": "4.17.3", - "graphql-tag": "^2.12.6", "is-https": "^4.0.0", "isomorphic-dompurify": "^0.18.0", "lodash.debounce": "^4.0.8", @@ -73,6 +72,7 @@ "@types/lodash.debounce": "^4.0.6", "@types/lodash.merge": "^4.6.7", "@types/lodash.unescape": "^4.0.7", + "@types/k6": "^0.37.0", "@vue/test-utils": "^1.3.0", "babel-core": "7.0.0-bridge.0", "babel-jest": "^27.4.6", diff --git a/packages/theme/plugins/query/StoreConfig.gql.ts b/packages/theme/plugins/query/StoreConfig.gql.ts index 1d36bb7bd..8bcadb908 100644 --- a/packages/theme/plugins/query/StoreConfig.gql.ts +++ b/packages/theme/plugins/query/StoreConfig.gql.ts @@ -1,7 +1,5 @@ -import gql from 'graphql-tag'; - /** GraphQL Query that fetches store configuration from the API */ -export const StoreConfigQuery = gql` +export const StoreConfigQuery = ` query storeConfig { storeConfig { store_code, diff --git a/packages/theme/tests/load/searchProduct.js b/packages/theme/tests/load/searchProduct.js new file mode 100644 index 000000000..edb21cc2e --- /dev/null +++ b/packages/theme/tests/load/searchProduct.js @@ -0,0 +1,355 @@ +// Creator: k6 Browser Recorder 0.6.2 (+ handmade cleanups) +import { sleep, group } from 'k6'; +import http from 'k6/http'; + +import jsonpath from 'https://jslib.k6.io/jsonpath/1.0.2/index.js'; + +export const options = { + vus: 10, + duration: '5m', +}; + +const { BASE_URL } = __ENV; + +export default function main() { + let response; + + const vars = {}; + + group(`page_1 - ${BASE_URL}/default`, () => { + response = http.get(`${BASE_URL}/default`); + sleep(0.5); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query storeConfig { + storeConfig { + store_code, + default_title, + store_name, + default_display_currency_code, + locale, + header_logo_src, + logo_width, + logo_height, + logo_alt + } + } +"}]`, + ); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query getStoresAndCurrencies { + availableStores { + store_code + } + currency { + available_currency_codes + } + } +"}]`, + ); + sleep(2.6); + + response = http.post( + `${BASE_URL}/api/magento/route`, + '["/women/tops-women/jackets-women.html",null]', + ); + + [vars.uid1] = jsonpath.query(response.json(), '$.data.route.uid'); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query categoryList { + categories { + items { + ...CategoryFields + children { + ...CategoryFields + children { + ...CategoryFields + children { + ...CategoryFields + } + } + } + } + } + } + + fragment CategoryFields on CategoryTree { + is_anchor + name + position + product_count + uid + url_path + url_suffix + include_in_menu + } + +"}]`, + ); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query getCategoryContentData($filters: CategoryFilterInput) { + categoryList(filters: $filters) { + uid + display_mode + landing_page + cms_block { + identifier + content + } + } + } +","queryVariables":{"filters":{"category_uid":{"eq":"${vars.uid1}"}}}}]`, + ); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query getFacetData($search: String = \\"\\", $filter: ProductAttributeFilterInput, $pageSize: Int = 10, $currentPage: Int = 1, $sort: ProductAttributeSortInput) { + products(search: $search, filter: $filter, pageSize: $pageSize, currentPage: $currentPage, sort: $sort) { + items { + __typename + uid + sku + name + stock_status + only_x_left_in_stock + thumbnail { + url + position + disabled + label + } + url_key + url_rewrites { + url + } + price_range { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } + } + page_info { + current_page + page_size + total_pages + } + total_count + } + } +","queryVariables":{"pageSize":10,"search":"","filter":{"category_uid":{"in":["${vars.uid1}"]}},"sort":{},"currentPage":1}}]`, + ); + sleep(0.5); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query getProductFiltersByCategory($categoryIdFilter: FilterEqualTypeInput!) { + products(filter: { category_uid: $categoryIdFilter }) { + aggregations { + label + count + attribute_code + options { + count + label + value + __typename + } + position + __typename + } + __typename + } + } +","queryVariables":{"categoryIdFilter":{"eq":"${vars.uid1}"}}}]`, + ); + sleep(1.8); + + response = http.post( + `${BASE_URL}/api/magento/productDetail`, + '[{"filter":{"sku":{"eq":"WJ12"}},"configurations":[]},{"productDetail":"productDetail"}]', + ); + + response = http.post( + `${BASE_URL}/api/magento/customQuery`, + `[{"query":" + query getProductPriceBySku($sku: String) { + products(filter: {sku: {eq: $sku}}) { + items { + price_range { + ...PriceRangeFields + } + + ... on BundleProduct { + items { + position + required + sku + title + type + uid + options { + can_change_quantity + is_default + position + uid + quantity + product { + uid + sku + name + price_range { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } + } + } + } + } + + ... on GroupedProduct { + items { + position + qty + product { + uid + sku + name + stock_status + only_x_left_in_stock + price_range { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } + thumbnail { + url + position + disabled + label + } + } + } + } + + } + } + } + + fragment PriceRangeFields on PriceRange { + maximum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + minimum_price { + final_price { + currency + value + } + regular_price { + currency + value + } + } + } + +","queryVariables":{"sku":"WJ12"}}]`, + ); + + response = http.post( + `${BASE_URL}/api/magento/upsellProduct`, + '[{"filter":{"sku":{"eq":"WJ12"}}},null]', + ); + + response = http.post( + `${BASE_URL}/api/magento/relatedProduct`, + '[{"filter":{"sku":{"eq":"WJ12"}}},null]', + ); + + response = http.post( + `${BASE_URL}/api/magento/productReviewRatingsMetadata`, + '[null]', + ); + sleep(2.3); + + response = http.post( + `${BASE_URL}/api/magento/productReview`, + '[{"filter":{"sku":{"eq":"WJ12"}}},null]', + ); + + response = http.post( + `${BASE_URL}/api/magento/productReviewRatingsMetadata`, + '[null]', + ); + }); +} diff --git a/yarn.lock b/yarn.lock index 23b4da8fd..8511a95b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3938,6 +3938,11 @@ dependencies: "@types/node" "*" +"@types/k6@^0.37.0": + version "0.37.0" + resolved "https://registry.yarnpkg.com/@types/k6/-/k6-0.37.0.tgz#fb357d5218c8c2b5bc197dae818eebe7085906fc" + integrity sha512-8ou8tK3i/8HJFiyxz9ktrT1CWvtdItlq5ybaysnnwX7wSNLZv3TdR28Q2kW9byYVJKNhuwOnoecB2oM/NytN2Q== + "@types/less@3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/less/-/less-3.0.2.tgz#2761d477678c8374cb9897666871662eb1d1115e"