├── documentation ├── public │ ├── robots.txt │ └── favicon.ico ├── server │ └── tsconfig.json ├── app.vue ├── tsconfig.json ├── components │ ├── Footer.vue │ ├── FeatureCard.vue │ ├── DownloadItem.vue │ ├── AccordionItem.vue │ └── SentenceDefinitionCard.vue ├── .gitignore ├── tailwind.config.js ├── content.config.ts ├── layouts │ └── default.vue ├── nuxt.config.ts ├── assets │ └── css │ │ └── main.css ├── data │ └── sentence.ts ├── pages │ └── configuration.vue ├── package.json ├── composables │ └── useSidebar.ts ├── README.md ├── plugins │ └── highlightjs.ts └── models │ └── sentenceDoc.ts ├── e2e ├── test-files │ ├── documents │ │ └── test.pdf │ ├── images │ │ ├── avatar.png │ │ ├── profile.jpg │ │ └── gallery │ │ │ ├── image1.jpg │ │ │ ├── image2.jpg │ │ │ └── image3.jpg │ └── data │ │ └── sample.csv ├── server │ ├── .yarnrc.yml │ ├── Dockerfile │ ├── Dockerfile.mock │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── main.go │ └── scroll.html ├── yarn.lock ├── features │ ├── frontend │ │ ├── navigation.feature │ │ ├── scroll.feature │ │ ├── macros │ │ │ └── test.macro.feature │ │ ├── details.feature │ │ ├── element_attribute.feature │ │ ├── page_assertions.feature │ │ ├── table.feature │ │ ├── file_upload.feature │ │ ├── assertions.feature │ │ └── visual.feature │ ├── variables │ │ ├── datatablde_variable.feature │ │ └── set_get_variable.feature │ └── graphql │ │ └── graphql_zero.feature ├── Dockerfile └── compose.yml ├── pkg ├── reporters │ ├── html_report.formatter_test.go │ ├── report_formatter.go │ ├── report_test.go │ ├── html_report_types.go │ ├── json_report.go │ ├── testsuitedetails.go │ ├── main.go │ ├── html_report.formatter.go │ └── json_report_formatter.go ├── logger │ ├── success.go │ ├── info.go │ ├── warn.go │ ├── error.go │ └── common.go ├── text_writer.go ├── gherkinparser │ └── types.go ├── browser │ └── rod │ │ ├── browser.go │ │ └── keyboard.go ├── variables │ └── runtime.go └── graphql │ └── types.go ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── internal ├── utils │ ├── fileutils │ │ └── constants.go │ └── stringutils │ │ └── string.go ├── config │ ├── mode.go │ └── utils.go ├── step_definitions │ ├── core │ │ ├── stepbuilder │ │ │ ├── test_step.go │ │ │ ├── types.go │ │ │ ├── variables_validator_context_test.go │ │ │ ├── validation_errors.go │ │ │ ├── documentation.go │ │ │ ├── step_one_var.go │ │ │ ├── step_no_var.go │ │ │ ├── step_two_vars.go │ │ │ ├── step_three_vars.go │ │ │ └── update-page-name.decorator.go │ │ ├── scenario │ │ │ ├── backend_context_headers.go │ │ │ ├── backend_context_protocol.go │ │ │ ├── backend_context_graphql.go │ │ │ ├── backend_context_response.go │ │ │ ├── context.go │ │ │ ├── backend_context_variables.go │ │ │ ├── helpers.go │ │ │ └── backend_context_types.go │ │ ├── wildcards.go │ │ └── wildcards_test.go │ ├── frontend │ │ ├── keyboard │ │ │ ├── main.go │ │ │ └── press_button.go │ │ ├── visual │ │ │ ├── table │ │ │ │ ├── main.go │ │ │ │ ├── common.go │ │ │ │ ├── table_should_contains_the_following_headers.go │ │ │ │ └── should_see_in_table_a_row_containing_the_following_elements.go │ │ │ ├── main.go │ │ │ ├── scroll_to_element.go │ │ │ ├── should_see_on_page.go │ │ │ ├── should_not_see_on_page.go │ │ │ ├── should_see_on_page_x_elements.go │ │ │ └── should_see_on_page_an_element_with_text.go │ │ ├── form │ │ │ ├── fill_field_test.go │ │ │ ├── main.go │ │ │ ├── clear_field.go │ │ │ ├── check_checkbox.go │ │ │ ├── fill_field.go │ │ │ ├── select_radio_button.go │ │ │ ├── uncheck_checkbox.go │ │ │ ├── select_option_with_text_into_dropdown.go │ │ │ ├── select_multiple_options_by_text_into_dropdown.go │ │ │ └── select_option_by_index_into_dropdown.go │ │ ├── mouse │ │ │ ├── main.go │ │ │ ├── simpleelementinteractionfunc.go │ │ │ ├── clicks_on_link.go │ │ │ ├── clicks_on_element.go │ │ │ ├── hover_on_element.go │ │ │ ├── clicks_on_button.go │ │ │ ├── right_click_on_button_or_element.go │ │ │ ├── double_click_on_button_or_element.go │ │ │ ├── click_common.go │ │ │ ├── hover_on_element_which_contains.go │ │ │ ├── click_on_element_which_contains.go │ │ │ ├── right_click_on_element_which_contains.go │ │ │ └── double_click_on_element_which_contains.go │ │ ├── navigation │ │ │ ├── open_a_new_browser_tab.go │ │ │ ├── main.go │ │ │ ├── open_a_private_browser_tab.go │ │ │ ├── refresh_page.go │ │ │ ├── navigate_back.go │ │ │ ├── navigate_to_url.go │ │ │ ├── wait_x_seconds.go │ │ │ ├── user_is_on_homepage.go │ │ │ ├── switch_to_most_opened_window.go │ │ │ ├── navigate_to_page.go │ │ │ ├── switch_to_original_window.go │ │ │ └── wait_for_new_window.go │ │ ├── main.go │ │ └── assertions │ │ │ ├── main.go │ │ │ ├── element_should_exist.go │ │ │ ├── page_title_should_be.go │ │ │ ├── element_should_not_exist.go │ │ │ ├── element_should_not_be_visible.go │ │ │ ├── current_url_should_contain.go │ │ │ ├── element_should_be_visible.go │ │ │ ├── the_field_should_contains.go │ │ │ ├── element_should_contains_text.go │ │ │ ├── element_should_contains_exact_text.go │ │ │ ├── element_should_not_contains_text.go │ │ │ └── checkbox_should_be_checked_or_unchecked.go │ ├── backend │ │ ├── restapi │ │ │ ├── main.go │ │ │ ├── set_body.go │ │ │ ├── set_json_body.go │ │ │ ├── debug_request.go │ │ │ └── set_query_params.go │ │ ├── graphql │ │ │ └── main.go │ │ ├── commonbackendsteps │ │ │ ├── main.go │ │ │ ├── validate_status.go │ │ │ ├── set_headers.go │ │ │ ├── send_request.go │ │ │ └── prepare_request.go │ │ └── main.go │ ├── helpers │ │ ├── get_json_path_value.go │ │ └── get_json_path_value_test.go │ ├── api │ │ └── protocol │ │ │ └── interface.go │ ├── variables │ │ ├── main.go │ │ ├── store_custom_variable.go │ │ ├── store_html_element_content.go │ │ ├── variable_should_contains.go │ │ └── store_response_json_path.go │ └── main.go ├── actions │ ├── version.go │ ├── main.go │ ├── boilerplate │ │ ├── config.boilerplate.yml │ │ └── sample.boilerplate.feature │ └── common.go └── browser │ └── factory.go ├── .dockerignore ├── documentation.dockerfile ├── .github ├── dependabot.yml └── workflows │ ├── e2e.yml │ ├── pull-request.yml │ └── deploy-docs.yml ├── .gitignore ├── .commitlintrc.yml ├── cmd └── testflowkit │ └── main.go ├── .vscode └── launch.json ├── go.mod └── .releaserc.json /documentation/public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/test-files/documents/test.pdf: -------------------------------------------------------------------------------- 1 | Sample PDF document content 2 | -------------------------------------------------------------------------------- /e2e/test-files/images/avatar.png: -------------------------------------------------------------------------------- 1 | Sample avatar image content 2 | -------------------------------------------------------------------------------- /e2e/test-files/images/profile.jpg: -------------------------------------------------------------------------------- 1 | Sample profile image content 2 | -------------------------------------------------------------------------------- /pkg/reporters/html_report.formatter_test.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | -------------------------------------------------------------------------------- /e2e/test-files/images/gallery/image1.jpg: -------------------------------------------------------------------------------- 1 | Sample gallery image 1 content 2 | -------------------------------------------------------------------------------- /e2e/test-files/images/gallery/image2.jpg: -------------------------------------------------------------------------------- 1 | Sample gallery image 2 content 2 | -------------------------------------------------------------------------------- /e2e/test-files/images/gallery/image3.jpg: -------------------------------------------------------------------------------- 1 | Sample gallery image 3 content 2 | -------------------------------------------------------------------------------- /documentation/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /pkg/logger/success.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | func Success(msg string) { 4 | log(success, msg) 5 | } 6 | -------------------------------------------------------------------------------- /e2e/server/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | enableGlobalCache: false 3 | nmHoistingLimits: workspaces 4 | -------------------------------------------------------------------------------- /e2e/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /documentation/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /documentation/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TestFlowKit/testflowkit/HEAD/documentation/public/favicon.ico -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm 2 | RUN sudo apt update && sudo apt install -y chromium -------------------------------------------------------------------------------- /documentation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /internal/utils/fileutils/constants.go: -------------------------------------------------------------------------------- 1 | package fileutils 2 | 3 | const ( 4 | DirPermission = 0755 5 | FilePermission = 0600 6 | ) 7 | -------------------------------------------------------------------------------- /e2e/test-files/data/sample.csv: -------------------------------------------------------------------------------- 1 | name,email,phone 2 | John Doe,john@example.com,123-456-7890 3 | Jane Smith,jane@example.com,098-765-4321 4 | -------------------------------------------------------------------------------- /documentation/components/Footer.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /e2e/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.0-alpine 2 | 3 | LABEL authors="marckent04" 4 | 5 | WORKDIR /app 6 | 7 | RUN apk add --no-cache curl 8 | 9 | COPY . . 10 | 11 | EXPOSE 3000 12 | 13 | CMD go run main.go -------------------------------------------------------------------------------- /internal/config/mode.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Mode string 4 | 5 | const ( 6 | RunMode Mode = "run" 7 | InitMode Mode = "init" 8 | ValidationMode Mode = "validate" 9 | VersionMode Mode = "version" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/test_step.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | type Step interface { 4 | GetDocumentation() Documentation 5 | GetSentences() []string 6 | GetDefinition() any 7 | Validate(*ValidatorContext) any 8 | } 9 | -------------------------------------------------------------------------------- /pkg/logger/info.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "fmt" 4 | 5 | func Info(message string) { 6 | log(info, message) 7 | } 8 | 9 | func InfoFf(format string, args ...interface{}) { 10 | log(info, fmt.Sprintf(format, args...)) 11 | } 12 | -------------------------------------------------------------------------------- /internal/actions/version.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "testflowkit/internal/config" 5 | "testflowkit/pkg/logger" 6 | ) 7 | 8 | func version(conf *config.Config, _ error) { 9 | logger.InfoFf("testflowkit version %s\n", conf.GetVersion()) 10 | } 11 | -------------------------------------------------------------------------------- /e2e/server/Dockerfile.mock: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | WORKDIR /app 4 | 5 | RUN apk add --no-cache curl 6 | 7 | COPY package.json yarn.lock ./ 8 | 9 | RUN yarn install --frozen-lockfile 10 | 11 | COPY . . 12 | 13 | EXPOSE 3001 14 | 15 | CMD ["yarn", "start"] 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Go ignores 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.test 8 | *.out 9 | go.work 10 | /vendor/ 11 | 12 | # Nuxt.js ignores 13 | **/.nuxt 14 | **/.output 15 | **/node_modules 16 | **/dist 17 | **/.cache 18 | **/.env 19 | **/.env.* 20 | **/*.log 21 | **/.DS_Store -------------------------------------------------------------------------------- /e2e/features/frontend/navigation.feature: -------------------------------------------------------------------------------- 1 | @NAVIGATION 2 | Feature: navigation e2e tests 3 | 4 | Scenario: a user can navigate between pages 5 | Given the user opens a new browser tab 6 | When the user goes to the google page 7 | Then the user should be navigated to the google page 8 | -------------------------------------------------------------------------------- /e2e/features/frontend/scroll.feature: -------------------------------------------------------------------------------- 1 | Feature: scroll e2e tests 2 | 3 | Background: 4 | Given the user opens a new browser tab 5 | Then the user goes to the scroll e2e page 6 | 7 | @scroll 8 | Scenario: scroll to element 9 | When the user scrolls to the "scroll target" element 10 | -------------------------------------------------------------------------------- /documentation/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /pkg/logger/warn.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func Warn(msg string, actionsExpected []string) { 8 | if len(actionsExpected) == 0 { 9 | log(warn, msg) 10 | } else { 11 | const format = "%s\nActions expected: \n%s" 12 | log(warn, fmt.Sprintf(format, msg, formatList(actionsExpected))) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/keyboard/main.go: -------------------------------------------------------------------------------- 1 | package keyboard 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type keyboardSteps struct { 8 | } 9 | 10 | func GetSteps() []stepbuilder.Step { 11 | steps := keyboardSteps{} 12 | 13 | return []stepbuilder.Step{ 14 | steps.userPressButton(), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /documentation/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // tailwind.config.js 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | "./components/**/*.{js,vue,ts}", 7 | "./layouts/**/*.vue", 8 | "./pages/**/*.vue", 9 | "./plugins/**/*.{js,ts}", 10 | "./app.vue", 11 | ], 12 | theme: { 13 | extend: {}, 14 | }, 15 | plugins: [], 16 | }; 17 | -------------------------------------------------------------------------------- /e2e/features/frontend/macros/test.macro.feature: -------------------------------------------------------------------------------- 1 | Feature: macro 2 | 3 | @macro 4 | Scenario: the user already checked test checkbox 5 | Given the user checks the "test" checkbox 6 | And the test checkbox should be checked 7 | 8 | 9 | @macro 10 | Scenario: the user is on page 11 | Given the user opens a new browser tab 12 | When the user goes to the |page_name| page 13 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/types.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "github.com/cucumber/godog" 5 | ) 6 | 7 | type supportedTypes interface { 8 | // Add supported types here 9 | string | *godog.Table 10 | } 11 | 12 | type DocParams struct { 13 | Description string 14 | Variables []DocVariable 15 | Example string 16 | Category StepCategory 17 | } 18 | -------------------------------------------------------------------------------- /documentation/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineContentConfig, defineCollection } from "@nuxt/content"; 2 | import { sentenceValidationSchema } from "./data/sentence"; 3 | 4 | export default defineContentConfig({ 5 | collections: { 6 | sentence: defineCollection({ 7 | source: "sentences/**/*.json", 8 | type: "data", 9 | schema: sentenceValidationSchema, 10 | }), 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/restapi/main.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct{} 8 | 9 | func GetSteps() []stepbuilder.Step { 10 | s := steps{} 11 | return []stepbuilder.Step{ 12 | s.setQueryParams(), 13 | s.setPathParams(), 14 | s.setRequestBody(), 15 | s.setJSONBody(), 16 | s.debugRequest(), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/reporters/report_formatter.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | type formatter interface { 8 | WriteReport(details testSuiteDetails) 9 | } 10 | 11 | type disabledFormatter struct { 12 | } 13 | 14 | func (f disabledFormatter) WriteReport(details testSuiteDetails) { 15 | const sentence = "%d tests executed successfully at %s" 16 | log.Printf(sentence, len(details.Scenarios), details.StartDate) 17 | } 18 | -------------------------------------------------------------------------------- /e2e/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.0-alpine AS build 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | WORKDIR /app/cmd/testflowkit 8 | 9 | RUN go build -o tkit 10 | 11 | 12 | FROM alpine:3.21 13 | 14 | LABEL authors="marckent04" 15 | 16 | WORKDIR /app 17 | 18 | COPY ./e2e . 19 | 20 | COPY --from=build /app/cmd/testflowkit . 21 | 22 | RUN apk add --no-cache chromium 23 | 24 | ENTRYPOINT /app/tkit run --location="./features" --timeout="10s" --env="ci" -------------------------------------------------------------------------------- /documentation/components/FeatureCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/graphql/main.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct{} 8 | 9 | func GetSteps() []stepbuilder.Step { 10 | s := steps{} 11 | return []stepbuilder.Step{ 12 | s.setGraphQLVariables(), 13 | s.validateHaveErrors(), 14 | s.validateNoErrors(), 15 | s.validateErrorMessage(), 16 | s.storeGraphQLError(), 17 | s.storeGraphQLErrorMessage(), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/text_writer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type TextWriter struct { 8 | textContent string 9 | } 10 | 11 | func (t *TextWriter) Write(b []byte) (int, error) { 12 | if len(t.textContent) == 0 { 13 | t.textContent = string(b) 14 | return len(b), nil 15 | } 16 | 17 | t.textContent = fmt.Sprintf("%s\n%s", t.textContent, string(b)) 18 | return len(b), nil 19 | } 20 | 21 | func (t *TextWriter) String() string { 22 | return t.textContent 23 | } 24 | -------------------------------------------------------------------------------- /documentation/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /documentation/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | app: { 3 | head: { 4 | title: "TestFlowKit - Behavior-Driven Testing Framework" 5 | } 6 | }, 7 | compatibilityDate: "2024-11-01", 8 | devtools: { enabled: false }, 9 | css: ["~/assets/css/main.css"], 10 | modules: ["@nuxtjs/tailwindcss", "@nuxt/content"], 11 | tailwindcss: { 12 | viewer: false, 13 | }, 14 | plugins: ["~/plugins/highlightjs.ts"], 15 | components: [{ path: "components/global", global: true }], 16 | }); 17 | -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/backend_context_headers.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | func (bc *BackendContext) GetHeader(name string) (string, bool) { 4 | value, exists := bc.Headers[name] 5 | return value, exists 6 | } 7 | 8 | func (bc *BackendContext) SetHeader(name, value string) { 9 | bc.Headers[name] = value 10 | } 11 | 12 | func (bc *BackendContext) GetHeaders() map[string]string { 13 | return bc.Headers 14 | } 15 | 16 | func (bc *BackendContext) ClearHeaders() { 17 | bc.Headers = make(map[string]string) 18 | } 19 | -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/backend_context_protocol.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | func (bc *BackendContext) SetProtocol(p APIProtocol) { 4 | bc.Protocol = p 5 | } 6 | 7 | func (bc *BackendContext) GetProtocol() APIProtocol { 8 | return bc.Protocol 9 | } 10 | 11 | func (bc *BackendContext) IsGraphQL() bool { 12 | return bc.Protocol != nil && bc.Protocol.GetProtocolName() == "GraphQL" 13 | } 14 | func (bc *BackendContext) IsREST() bool { 15 | return bc.Protocol != nil && bc.Protocol.GetProtocolName() == "REST" 16 | } 17 | -------------------------------------------------------------------------------- /documentation.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.0-bookworm AS doc_generation 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod . 6 | 7 | RUN go mod tidy 8 | 9 | COPY . . 10 | 11 | RUN make generate_doc 12 | 13 | FROM node:22-bookworm AS builder 14 | 15 | WORKDIR /app 16 | 17 | COPY --from=doc_generation /app/documentation . 18 | 19 | RUN yarn 20 | 21 | RUN yarn build 22 | 23 | 24 | FROM gcr.io/distroless/nodejs22-debian12 25 | 26 | WORKDIR /app 27 | 28 | COPY --from=builder /app/.output . 29 | 30 | EXPOSE 3000 31 | 32 | CMD ["server/index.mjs"] 33 | 34 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/table/main.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct { 8 | } 9 | 10 | func GetSteps() []stepbuilder.Step { 11 | handlers := steps{} 12 | 13 | return []stepbuilder.Step{ 14 | handlers.clickOnTheRowContainingTheFollowingElements(), 15 | handlers.shouldSeeRowContainingTheFollowingElements(), 16 | handlers.shouldNotSeeRowContainingTheFollowingElements(), 17 | handlers.tableShouldContainsTheFollowingHeaders(), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/gherkinparser/types.go: -------------------------------------------------------------------------------- 1 | package gherkinparser 2 | 3 | import messages "github.com/cucumber/messages/go/v21" 4 | 5 | func newFeature(name string, content []byte, scenarios []*scenario, background *messages.Background) *Feature { 6 | return &Feature{ 7 | Name: name, 8 | Contents: content, 9 | scenarios: scenarios, 10 | background: background, 11 | } 12 | } 13 | 14 | type Feature struct { 15 | Name string 16 | Contents []byte 17 | scenarios []*scenario 18 | background *messages.Background 19 | } 20 | 21 | type scenario = messages.Scenario 22 | -------------------------------------------------------------------------------- /internal/utils/stringutils/string.go: -------------------------------------------------------------------------------- 1 | package stringutils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func SplitAndTrim(s, sep string) []string { 9 | var arr []string 10 | for _, v := range strings.Split(s, sep) { 11 | arr = append(arr, strings.TrimSpace(v)) 12 | } 13 | 14 | return arr 15 | } 16 | 17 | func SuffixWithUnderscore(str, suffix string) string { 18 | return fmt.Sprintf("%s_%s", strings.Trim(str, " "), strings.Trim(suffix, " ")) 19 | } 20 | 21 | func SnakeCase(label string) string { 22 | return strings.ToLower(strings.ReplaceAll(label, " ", "_")) 23 | } 24 | -------------------------------------------------------------------------------- /internal/actions/main.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "fmt" 5 | "testflowkit/internal/config" 6 | "testflowkit/pkg/logger" 7 | ) 8 | 9 | func Execute(cfg *config.Config, cfgErr error, mode config.Mode) { 10 | modes := map[config.Mode]func(*config.Config, error){ 11 | config.RunMode: run, 12 | config.InitMode: initMode, 13 | config.ValidationMode: validate, 14 | config.VersionMode: version, 15 | } 16 | 17 | if action, ok := modes[mode]; ok { 18 | action(cfg, cfgErr) 19 | } else { 20 | logger.Fatal(fmt.Sprintf("unknown mode: %s", mode), nil) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/commonbackendsteps/main.go: -------------------------------------------------------------------------------- 1 | package commonbackendsteps 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct{} 8 | 9 | func GetSteps() []stepbuilder.Step { 10 | s := steps{} 11 | return []stepbuilder.Step{ 12 | s.prepareRequest(), 13 | s.sendRequest(), 14 | s.setHeaders(), 15 | s.storeResponseData(), 16 | s.validateStatusCode(), 17 | s.validateJSONPathExists(), 18 | s.validateJSONPathValue(), 19 | s.validateJSONPathContains(), 20 | s.validateJSONBodyEquals(), 21 | s.validateJSONBodyContains(), 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/step_definitions/helpers/get_json_path_value.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/tidwall/gjson" 8 | ) 9 | 10 | func GetJSONPathValue(jsonBody []byte, path string) (any, error) { 11 | var data interface{} 12 | if err := json.Unmarshal(jsonBody, &data); err != nil { 13 | return nil, fmt.Errorf("failed to unmarshal JSON body: %w", err) 14 | } 15 | 16 | value := gjson.Get(string(jsonBody), path) 17 | if !value.Exists() { 18 | return nil, fmt.Errorf("JSON path '%s' does not exist in response body", path) 19 | } 20 | 21 | return value.Value(), nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/step_definitions/helpers/get_json_path_value_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestGetJSONPathValue(t *testing.T) { 10 | jsonBody := []byte(`{"name": "John", "age": 30}`) 11 | value, err := GetJSONPathValue(jsonBody, "name") 12 | require.NoError(t, err) 13 | require.Equal(t, "John", value) 14 | } 15 | 16 | func TestGetUndefineJSONPathValue(t *testing.T) { 17 | jsonBody := []byte(`{"name": "John", "age": 30}`) 18 | value, err := GetJSONPathValue(jsonBody, "status") 19 | require.Error(t, err) 20 | require.Nil(t, value) 21 | } 22 | -------------------------------------------------------------------------------- /documentation/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --color-primary: #007bff; 7 | /* font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 8 | line-height: 1.5; 9 | font-weight: 400; 10 | 11 | color-scheme: light dark; 12 | 13 | font-synthesis: none; 14 | text-rendering: optimizeLegibility; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; */ 17 | } 18 | 19 | .heading-2 { 20 | @apply text-2xl font-bold mb-4; 21 | } 22 | 23 | .primary-button-bg { 24 | @apply bg-blue-500 hover:bg-blue-700; 25 | } 26 | -------------------------------------------------------------------------------- /documentation/data/sentence.ts: -------------------------------------------------------------------------------- 1 | import { z } from "@nuxt/content"; 2 | 3 | export const sentenceValidationSchema = z.object({ 4 | sentence: z.string(), 5 | description: z.string(), 6 | category: z.string(), 7 | gherkinExample: z.string(), 8 | variables: z.array( 9 | z.object({ 10 | name: z.string(), 11 | type: z.string(), 12 | }), 13 | ), 14 | }); 15 | 16 | export type Sentence = { 17 | sentence: string; 18 | description: string; 19 | category: string; 20 | gherkinExample: string; 21 | variables: Array<{ 22 | name: string; 23 | description?: string; 24 | type: string; 25 | }>; 26 | }; 27 | -------------------------------------------------------------------------------- /pkg/reporters/report_test.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func newReport(formatType string) *Report { 10 | return New(formatType) 11 | } 12 | func TestReportShouldBeDisabledBecauseReportFormatNotRecognized(t *testing.T) { 13 | report := newReport("") 14 | _, isDisabled := report.formatter.(disabledFormatter) 15 | 16 | assert.True(t, isDisabled) 17 | } 18 | 19 | func TestHTMLReportInstantiation(t *testing.T) { 20 | report := newReport("html") 21 | _, isHTMLFormatter := report.formatter.(htmlReportFormatter) 22 | 23 | assert.True(t, isHTMLFormatter) 24 | } 25 | -------------------------------------------------------------------------------- /e2e/server/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | yarn-error.log 4 | 5 | # Build outputs 6 | dist/ 7 | build/ 8 | 9 | # TypeScript 10 | *.tsbuildinfo 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage/ 27 | 28 | # Environment variables 29 | .env 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # IDE 36 | .vscode/ 37 | .idea/ 38 | *.swp 39 | *.swo 40 | 41 | # OS 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /internal/step_definitions/core/wildcards.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Wildcard = string 8 | type WildcardID = string 9 | 10 | const VariablePattern = `\{\{\s*([^}]+)\s*\}\}` 11 | 12 | var ( 13 | stringID WildcardID = "{string}" 14 | numberID WildcardID = "{number}" 15 | wildcard Wildcard = `"?([^"]*)"?` 16 | ) 17 | 18 | var wildcards = map[WildcardID]Wildcard{ 19 | numberID: wildcard, 20 | stringID: wildcard, 21 | } 22 | 23 | func ConvertWildcards(current string) string { 24 | for id, wildcard := range wildcards { 25 | current = strings.ReplaceAll(current, id, wildcard) 26 | } 27 | return current 28 | } 29 | -------------------------------------------------------------------------------- /internal/step_definitions/api/protocol/interface.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type APIProtocol interface { 8 | PrepareRequest(ctx context.Context, name string) (context.Context, error) 9 | 10 | SendRequest(ctx context.Context) (context.Context, error) 11 | 12 | GetResponseBody(ctx context.Context) ([]byte, error) 13 | 14 | GetStatusCode(ctx context.Context) (int, error) 15 | 16 | HasErrors(ctx context.Context) bool 17 | 18 | GetProtocolName() string 19 | } 20 | 21 | type APIProtocolName string 22 | 23 | const ( 24 | ProtocolGraphQL APIProtocolName = "GraphQL" 25 | ProtocolRESTAPI APIProtocolName = "REST" 26 | ) 27 | -------------------------------------------------------------------------------- /internal/step_definitions/variables/main.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct{} 8 | 9 | func GetAllSteps() []stepbuilder.Step { 10 | st := steps{} 11 | return []stepbuilder.Step{ 12 | st.storeJSONPathIntoVariable(), 13 | st.storeElementContentIntoVariable(), 14 | st.storeCustomVariable(), 15 | st.variableShouldContains(), 16 | } 17 | } 18 | 19 | func GetDocs() []stepbuilder.Documentation { 20 | var docs []stepbuilder.Documentation 21 | for _, step := range GetAllSteps() { 22 | docs = append(docs, step.GetDocumentation()) 23 | } 24 | return docs 25 | } 26 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Test Flow Kit framework", 3 | "dockerFile": "Dockerfile", 4 | "features": { 5 | "ghcr.io/devcontainers/features/node:1": {} 6 | }, 7 | "onCreateCommand": { 8 | "install_dependencies": "go get" 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "golang.go", 14 | "766b.go-outliner", 15 | "ms-azuretools.vscode-docker", 16 | "github.vscode-github-actions", 17 | "alexkrechik.cucumberautocomplete", 18 | "vue.volar", 19 | "ms-vscode.makefile-tools" 20 | ] 21 | } 22 | }, 23 | "forwardPorts": [5173] 24 | } 25 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/fill_field_test.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestPrivateIFillTheInputWithSentences(t *testing.T) { 9 | wildcard := "{string}" 10 | const expectedWildcardNumber = 2 11 | 12 | handler := steps{}.userEntersTextIntoField() 13 | re := regexp.MustCompile(wildcard) 14 | 15 | for _, sentence := range handler.GetSentences() { 16 | occurs := len(re.FindAllString(sentence, -1)) 17 | if occurs == expectedWildcardNumber { 18 | continue 19 | } 20 | t.Fatalf("all sentencesmust contains %d wildcars but \"%s\" contains %d", expectedWildcardNumber, sentence, occurs) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/backend_context_graphql.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | import ( 4 | "testflowkit/pkg/graphql" 5 | ) 6 | 7 | func (bc *BackendContext) SetGraphQLRequest(request *graphql.Request) { 8 | bc.GraphQLRequest = request 9 | } 10 | 11 | func (bc *BackendContext) GetGraphQLRequest() *graphql.Request { 12 | return bc.GraphQLRequest 13 | } 14 | 15 | func (bc *BackendContext) GetGraphQLErrors() []graphql.Error { 16 | if bc.Response == nil { 17 | return nil 18 | } 19 | return bc.Response.GraphQLErrors 20 | } 21 | 22 | func (bc *BackendContext) HasGraphQLErrors() bool { 23 | return bc.Response != nil && len(bc.Response.GraphQLErrors) > 0 24 | } 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore" 9 | include: "scope" 10 | groups: 11 | go-dependencies: 12 | patterns: 13 | - "*" 14 | applies-to: version-updates 15 | 16 | - package-ecosystem: "npm" 17 | directory: "/documentation" 18 | schedule: 19 | interval: "weekly" 20 | commit-message: 21 | prefix: "chore" 22 | include: "scope" 23 | groups: 24 | node-dependencies: 25 | patterns: 26 | - "*" 27 | applies-to: version-updates 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, built with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Dependency directories (remove the comment below to include it) 14 | # vendor/ 15 | 16 | # Go workspace file 17 | go.work 18 | go.work.sum 19 | 20 | # env file 21 | .env 22 | report.html 23 | cover 24 | **/.DS_Store 25 | frontend.yml 26 | cli.yml 27 | build/ 28 | dist/ 29 | .DS_Store 30 | **/.idea/* 31 | 32 | documentation/content 33 | 34 | 35 | tkit* 36 | 37 | **/report.json 38 | report/screenshots/ 39 | 40 | config.yml 41 | 42 | 43 | #IDEs 44 | .kiro/ 45 | .cursorrules/ -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/backend_context_response.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | func (bc *BackendContext) SetResponse(response *UnifiedResponse) { 4 | bc.Response = response 5 | } 6 | 7 | func (bc *BackendContext) GetResponse() *UnifiedResponse { 8 | return bc.Response 9 | } 10 | 11 | func (bc *BackendContext) HasResponse() bool { 12 | return bc.Response != nil 13 | } 14 | 15 | func (bc *BackendContext) GetResponseBody() []byte { 16 | if bc.Response == nil { 17 | return nil 18 | } 19 | return bc.Response.Body 20 | } 21 | 22 | func (bc *BackendContext) GetStatusCode() int { 23 | if bc.Response == nil { 24 | return 0 25 | } 26 | return bc.Response.StatusCode 27 | } 28 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/main.go: -------------------------------------------------------------------------------- 1 | package visual 2 | 3 | import ( 4 | "slices" 5 | "testflowkit/internal/step_definitions/core/stepbuilder" 6 | "testflowkit/internal/step_definitions/frontend/visual/table" 7 | ) 8 | 9 | type steps struct { 10 | } 11 | 12 | func GetSteps() []stepbuilder.Step { 13 | handlers := steps{} 14 | 15 | var otherSteps = []stepbuilder.Step{ 16 | handlers.shouldSeeOnPage(), 17 | handlers.shouldNotSeeOnPage(), 18 | handlers.shouldSeeElementWhichContains(), 19 | handlers.shouldSeeOnPageXElements(), 20 | handlers.shouldSeeDetailsOnPage(), 21 | handlers.scrollToElement(), 22 | } 23 | return slices.Concat(table.GetSteps(), otherSteps) 24 | } 25 | -------------------------------------------------------------------------------- /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - '@commitlint/config-conventional' 3 | 4 | rules: 5 | # Sévérité 2 (erreur), doit être un type valide 6 | 'type-enum': [2, 'always', [ 7 | 'docs', 8 | 'feat', 9 | 'fix', 10 | 'perf', 11 | 'refactor', 12 | 'revert', 13 | 'style', 14 | 'test', 15 | 'chore' 16 | ]] 17 | 18 | # Sévérité 2 (erreur), le message doit commencer par un type suivi d'un sujet 19 | 'header-max-length': [2, 'always', 100] 20 | 21 | # Le scope est facultatif 22 | 'scope-empty': [0] 23 | 24 | # Le body du commit est optionnel 25 | 'body-max-length': [0] 26 | 27 | # Les pieds de commit (footers) sont optionnels 28 | 'footer-max-length': [0] 29 | -------------------------------------------------------------------------------- /pkg/reporters/html_report_types.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import "fmt" 4 | 5 | type htmlTestSuiteDetails struct { 6 | testSuiteDetails 7 | Scenarios []htmlScenario 8 | } 9 | 10 | type htmlScenario struct { 11 | Scenario 12 | FmtDuration string 13 | HTMLStatusColorClass string 14 | } 15 | 16 | func newhtmlScenario(sc Scenario) *htmlScenario { 17 | color := "red" 18 | if sc.Result == succeeded { 19 | color = "green" 20 | } 21 | 22 | if sc.ErrorMsg == "" { 23 | sc.ErrorMsg = "-" 24 | } 25 | 26 | return &htmlScenario{ 27 | Scenario: sc, 28 | FmtDuration: sc.Duration.String(), 29 | HTMLStatusColorClass: fmt.Sprintf("bg-%s-500", color), 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/main.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct { 8 | } 9 | 10 | func GetSteps() []stepbuilder.Step { 11 | handlers := steps{} 12 | 13 | return []stepbuilder.Step{ 14 | handlers.userClicksOnButton(), 15 | handlers.clickOnElementWhichContains(), 16 | handlers.doubleClickOn(), 17 | handlers.userClicksOnLink(), 18 | handlers.userClicksOnElement(), 19 | handlers.doubleClickOnElementWhichContains(), 20 | handlers.rightClickOn(), 21 | handlers.rightClickOnElementWhichContains(), 22 | handlers.hoverOnElement(), 23 | handlers.hoverOnElementWhichContains(), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /e2e/features/frontend/details.feature: -------------------------------------------------------------------------------- 1 | @ELEMENT_DETAILS 2 | Feature: product details e2e tests 3 | 4 | Scenario: a user must see computer details 5 | Given the user opens a new private browser tab 6 | When the user goes to the details e2e page 7 | Then the user should see "computer" details on the page 8 | | name | Ordinateur de Bord pour Rameur | 9 | | description | Cet ordinateur de rameur vous permet de suivre vos performances en temps réel | 10 | | price | 149,99 € | 11 | | screen type | LCD rétroéclairé | 12 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/simpleelementinteractionfunc.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/pkg/browser" 7 | ) 8 | 9 | type simpleElementInteractionFunc = func(ctx context.Context, label string) (context.Context, error) 10 | 11 | func commonSimpleElementInteraction(action func(browser.Element) error) simpleElementInteractionFunc { 12 | return func(ctx context.Context, label string) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | element, err := scenarioCtx.GetHTMLElementByLabel(label) 15 | if err != nil { 16 | return ctx, err 17 | } 18 | err = action(element) 19 | return ctx, err 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/actions/boilerplate/config.boilerplate.yml: -------------------------------------------------------------------------------- 1 | active_environment: "local" 2 | 3 | settings: 4 | concurrency: 1 5 | think_time: 1000 6 | report_format: "html" 7 | gherkin_location: "./features" 8 | 9 | environments: 10 | local: 11 | frontend_base_url: "https://testflowkit.dreamsfollowers.me" 12 | 13 | frontend: 14 | default_timeout: 10000 15 | headless: false 16 | screenshot_on_failure: true 17 | 18 | elements: 19 | common: 20 | get_started_button: 21 | - "xpath://a[contains(text(), 'Get Started Now')]" 22 | 23 | sentence_filter_field: 24 | - "input[placeholder*='Search step definitions...']" 25 | 26 | pages: 27 | home: "/" 28 | get_started: "/get-started" 29 | sentences: "/sentences" 30 | -------------------------------------------------------------------------------- /e2e/features/frontend/element_attribute.feature: -------------------------------------------------------------------------------- 1 | @ELEMENT_ATTRIBUTE_ASSERTIONS 2 | Feature: Element Attribute Assertions 3 | 4 | Scenario: Verify that an element attribute matches expected value 5 | When the user goes to the "form e2e" page 6 | Then the "id" attribute of the "page_title" element should be "page-title" 7 | 8 | Scenario: Verify that an element type attribute matches expected value 9 | When the user goes to the "form e2e" page 10 | Then the "type" attribute of the "text_field" element should be "text" 11 | 12 | Scenario: Verify that an element name attribute matches expected value 13 | When the user goes to the "form e2e" page 14 | Then the "name" attribute of the "text_field" element should be "text-input" -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/context.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // ContextKey is the key used to store scenario context in context.Context. 8 | type ContextKey struct{} 9 | 10 | func WithContext(ctx context.Context, scenarioCtx *Context) context.Context { 11 | return context.WithValue(ctx, ContextKey{}, scenarioCtx) 12 | } 13 | 14 | func FromContext(ctx context.Context) *Context { 15 | if scenarioCtx, ok := ctx.Value(ContextKey{}).(*Context); ok { 16 | return scenarioCtx 17 | } 18 | return nil 19 | } 20 | 21 | func MustFromContext(ctx context.Context) *Context { 22 | scenarioCtx := FromContext(ctx) 23 | if scenarioCtx == nil { 24 | panic("scenario context not found in context") 25 | } 26 | return scenarioCtx 27 | } 28 | -------------------------------------------------------------------------------- /internal/step_definitions/main.go: -------------------------------------------------------------------------------- 1 | package stepdefinitions 2 | 3 | import ( 4 | "slices" 5 | "testflowkit/internal/step_definitions/backend" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | "testflowkit/internal/step_definitions/frontend" 8 | "testflowkit/internal/step_definitions/variables" 9 | ) 10 | 11 | func GetAll() []stepbuilder.Step { 12 | allSteps := slices.Concat( 13 | frontend.GetAllSteps(), 14 | backend.GetAllSteps(), 15 | variables.GetAllSteps(), 16 | ) 17 | 18 | var decoratedSteps []stepbuilder.Step 19 | for _, step := range allSteps { 20 | decoratedSteps = append(decoratedSteps, 21 | stepbuilder.NewVariableSubstitutionDecorator( 22 | stepbuilder.NewUpdatePageNameDecorator(step))) 23 | } 24 | 25 | return decoratedSteps 26 | } 27 | -------------------------------------------------------------------------------- /internal/config/utils.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testflowkit/internal/utils/stringutils" 5 | ) 6 | 7 | func IsElementDefined(elementName string) bool { 8 | key := stringutils.SnakeCase(elementName) 9 | for _, pageElements := range cfg.GetFrontendElements() { 10 | if _, ok := pageElements[key]; ok { 11 | return true 12 | } 13 | } 14 | 15 | return false 16 | } 17 | 18 | func IsFileDefined(fileName string) bool { 19 | defs := cfg.GetFileDefinitions() 20 | if defs == nil { 21 | return false 22 | } 23 | 24 | _, exists := defs[fileName] 25 | return exists 26 | } 27 | 28 | func IsPageDefined(pageName string) bool { 29 | pageURL, getFrontendURLErr := cfg.GetFrontendURL(pageName) 30 | if getFrontendURLErr != nil { 31 | return false 32 | } 33 | 34 | return pageURL != "" 35 | } 36 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/open_a_new_browser_tab.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (steps) openANewBrowserTab() stepbuilder.Step { 10 | return stepbuilder.NewWithNoVariables( 11 | []string{"the user opens a new browser tab"}, 12 | func(ctx context.Context) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | scenarioCtx.InitBrowser(false) 15 | return ctx, nil 16 | }, 17 | nil, 18 | stepbuilder.DocParams{ 19 | Description: "opens a new browser tab.", 20 | Variables: nil, 21 | Example: "Given the user opens a new browser tab", 22 | Category: stepbuilder.Navigation, 23 | }, 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/main.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/backend/commonbackendsteps" 5 | "testflowkit/internal/step_definitions/backend/graphql" 6 | "testflowkit/internal/step_definitions/backend/restapi" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | ) 9 | 10 | func GetAllSteps() []stepbuilder.Step { 11 | var allSteps []stepbuilder.Step 12 | allSteps = append(allSteps, commonbackendsteps.GetSteps()...) 13 | allSteps = append(allSteps, restapi.GetSteps()...) 14 | allSteps = append(allSteps, graphql.GetSteps()...) 15 | return allSteps 16 | } 17 | 18 | func GetDocs() []stepbuilder.Documentation { 19 | var docs []stepbuilder.Documentation 20 | for _, step := range GetAllSteps() { 21 | docs = append(docs, step.GetDocumentation()) 22 | } 23 | return docs 24 | } 25 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/main.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct { 8 | } 9 | 10 | func GetSteps() []stepbuilder.Step { 11 | handlers := steps{} 12 | 13 | return []stepbuilder.Step{ 14 | handlers.userEntersTextIntoField(), 15 | handlers.selectOptionWithTextIntoDropdown(), 16 | handlers.selectMultipleOptionsByTextIntoDropdown(), 17 | handlers.userSelectMultipleOptionsByValueIntoDropdown(), 18 | handlers.userSelectOptionWithValueIntoDropdown(), 19 | handlers.userSelectOptionByIndexIntoDropdown(), 20 | handlers.checkCheckbox(), 21 | handlers.uncheckCheckbox(), 22 | handlers.selectRadioButton(), 23 | handlers.clearField(), 24 | handlers.userUploadsFileIntoField(), 25 | handlers.userUploadsMultipleFiles(), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/main.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct { 8 | } 9 | 10 | func GetSteps() []stepbuilder.Step { 11 | handlers := steps{} 12 | 13 | return []stepbuilder.Step{ 14 | handlers.userNavigateToPage(), 15 | handlers.userWait(), 16 | handlers.refreshPage(), 17 | handlers.navigateBack(), 18 | handlers.userNavigateToURL(), 19 | handlers.openANewBrowserTab(), 20 | handlers.openANewPrivateBrowserTab(), 21 | handlers.userIsOnHomepage(), 22 | handlers.userShouldBeNavigatedToPage(), 23 | // TODO: window handling e2e tests 24 | handlers.waitAMomentForNewWindow(), 25 | handlers.switchToMostOpenedWindow(), 26 | handlers.switchToOriginalWindow(), 27 | handlers.switchToNewOpenedWindow(), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/reporters/json_report.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | type jsonReport struct { 4 | StartDate string `json:"start_date"` 5 | Duration string `json:"totalDuration"` 6 | Scenarios []jsonScenarioReport `json:"scenarios"` 7 | } 8 | 9 | type jsonScenarioReport struct { 10 | Title string `json:"title"` 11 | Duration string `json:"duration"` 12 | Result string `json:"result"` 13 | Steps []jsonScenarioStepReport `json:"steps"` 14 | ErrorMessage string `json:"error_message,omitzero"` 15 | } 16 | 17 | type jsonScenarioStepReport struct { 18 | Title string `json:"title"` 19 | Status string `json:"status"` 20 | Duration string `json:"duration"` 21 | ScreenshotPath string `json:"screenshot_path,omitzero"` 22 | } 23 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/open_a_private_browser_tab.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (steps) openANewPrivateBrowserTab() stepbuilder.Step { 10 | return stepbuilder.NewWithNoVariables( 11 | []string{"the user opens a new private browser tab"}, 12 | func(ctx context.Context) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | scenarioCtx.InitBrowser(true) 15 | return ctx, nil 16 | }, 17 | nil, 18 | stepbuilder.DocParams{ 19 | Description: "opens a new private browser tab.", 20 | Variables: nil, 21 | Example: "Given the user opens a new private browser tab", 22 | Category: stepbuilder.Navigation, 23 | }, 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/logger/error.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func Error(msg string, potentialCauses []string, potentialSolutions []string) { 9 | if potentialCauses == nil { 10 | potentialCauses = []string{} 11 | } 12 | 13 | if potentialSolutions == nil { 14 | potentialSolutions = []string{} 15 | } 16 | 17 | if len(potentialCauses) == 0 && len(potentialSolutions) == 0 { 18 | log(erro, msg) 19 | return 20 | } 21 | 22 | const format = "%s\n Potential causes: \n%s\n\nPotential solutions: \n%s" 23 | finalMsg := fmt.Sprintf(format, msg, formatList(potentialCauses), formatList(potentialSolutions)) 24 | log(erro, finalMsg) 25 | } 26 | 27 | func Fatal(context string, err error) { 28 | if err == nil { 29 | log(fatal, context) 30 | } else { 31 | log(fatal, fmt.Sprintf("(%s) %s", context, err)) 32 | } 33 | os.Exit(1) 34 | } 35 | -------------------------------------------------------------------------------- /e2e/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "declaration": true, 16 | "declarationMap": true, 17 | "sourceMap": true, 18 | "outDir": "./dist", 19 | "rootDir": "./", 20 | "baseUrl": "./", 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "types": ["node"] 25 | }, 26 | "include": ["**/*.ts", "**/*.js"], 27 | "exclude": ["node_modules", "dist"], 28 | "ts-node": { 29 | "esm": true, 30 | "experimentalSpecifierResolution": "node" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /documentation/pages/configuration.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/refresh_page.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (steps) refreshPage() stepbuilder.Step { 10 | return stepbuilder.NewWithNoVariables( 11 | []string{`the user refreshes the page`}, 12 | func(ctx context.Context) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 15 | if pageErr != nil { 16 | return ctx, pageErr 17 | } 18 | currentPage.Refresh() 19 | return ctx, nil 20 | }, 21 | nil, 22 | stepbuilder.DocParams{ 23 | Description: "refreshes the current page.", 24 | Example: "When the user refreshes the page", 25 | Category: stepbuilder.Navigation, 26 | }, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /e2e/features/variables/datatablde_variable.feature: -------------------------------------------------------------------------------- 1 | @TEST_VARIABLES 2 | Feature: variables testing with table data 3 | 4 | @tableData 5 | Scenario: Store table data into variable in order to use it in another step 6 | Given I store the value "LCD rétroéclairé" into "screenType" variable 7 | And the user opens a new private browser tab 8 | When the user goes to the details e2e page 9 | Then the user should see "computer" details on the page 10 | | name | Ordinateur de Bord pour Rameur | 11 | | description | Cet ordinateur de rameur vous permet de suivre vos performances en temps réel | 12 | | price | 149,99 € | 13 | | screen type | {{ screenType }} | 14 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/navigate_back.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (steps) navigateBack() stepbuilder.Step { 10 | return stepbuilder.NewWithNoVariables( 11 | []string{`the user navigates back`}, 12 | func(ctx context.Context) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 15 | if pageErr != nil { 16 | return ctx, pageErr 17 | } 18 | currentPage.Back() 19 | return ctx, nil 20 | }, 21 | nil, 22 | stepbuilder.DocParams{ 23 | Description: "navigates back to the previous page in browser history.", 24 | Example: "When the user navigates back", 25 | Category: stepbuilder.Navigation, 26 | }, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@highlightjs/vue-plugin": "^2.1.0", 14 | "@nuxt/content": "^3.6.3", 15 | "@nuxt/kit": "^4.0.2", 16 | "better-sqlite3": "^12.2.0", 17 | "highlight.js": "^11.11.1", 18 | "nuxt": "^4.0.2", 19 | "autoprefixer": "^10.4.20", 20 | "postcss": "^8.4.49", 21 | "tailwindcss": "^3.4.17", 22 | "vue": "latest", 23 | "vue-router": "latest" 24 | }, 25 | "devDependencies": { 26 | "@nuxtjs/tailwindcss": "^6.13.1" 27 | }, 28 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 29 | } 30 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/clicks_on_link.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | "testflowkit/internal/utils/stringutils" 6 | ) 7 | 8 | func (s steps) userClicksOnLink() stepbuilder.Step { 9 | formatLabel := func(label string) string { 10 | return stringutils.SuffixWithUnderscore(label, "link") 11 | } 12 | 13 | common := clickCommonHandler(formatLabel) 14 | return stepbuilder.NewWithOneVariable( 15 | []string{`the user clicks the {string} link`}, 16 | common.handler(), 17 | common.validation(), 18 | stepbuilder.DocParams{ 19 | Description: "performs a click action on the link identified by its logical name", 20 | Variables: []stepbuilder.DocVariable{ 21 | {Name: "name", Description: "The logical name of link to click on.", Type: stepbuilder.VarTypeString}, 22 | }, 23 | Example: "When the user clicks the \"Forgot Password\" link", 24 | Category: stepbuilder.Mouse, 25 | }, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/main.go: -------------------------------------------------------------------------------- 1 | package frontend 2 | 3 | import ( 4 | "slices" 5 | "testflowkit/internal/step_definitions/core/stepbuilder" 6 | "testflowkit/internal/step_definitions/frontend/assertions" 7 | "testflowkit/internal/step_definitions/frontend/form" 8 | "testflowkit/internal/step_definitions/frontend/keyboard" 9 | "testflowkit/internal/step_definitions/frontend/mouse" 10 | "testflowkit/internal/step_definitions/frontend/navigation" 11 | "testflowkit/internal/step_definitions/frontend/visual" 12 | ) 13 | 14 | func GetAllSteps() []stepbuilder.Step { 15 | return slices.Concat( 16 | form.GetSteps(), 17 | keyboard.GetSteps(), 18 | navigation.GetSteps(), 19 | visual.GetSteps(), 20 | mouse.GetSteps(), 21 | assertions.GetSteps(), 22 | ) 23 | } 24 | 25 | func GetDocs() []stepbuilder.Documentation { 26 | var docs []stepbuilder.Documentation 27 | for _, step := range GetAllSteps() { 28 | docs = append(docs, step.GetDocumentation()) 29 | } 30 | return docs 31 | } 32 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/clicks_on_element.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | "testflowkit/internal/utils/stringutils" 6 | ) 7 | 8 | func (s steps) userClicksOnElement() stepbuilder.Step { 9 | formatLabel := func(label string) string { 10 | return stringutils.SuffixWithUnderscore(label, "element") 11 | } 12 | 13 | return stepbuilder.NewWithOneVariable( 14 | []string{`the user clicks the {string} element`}, 15 | clickCommonHandler(formatLabel).handler(), 16 | clickCommonHandler(formatLabel).validation(), 17 | stepbuilder.DocParams{ 18 | Description: "performs a click action on the element identified by its logical name", 19 | Variables: []stepbuilder.DocVariable{ 20 | {Name: "name", Description: "The logical name of element to click on.", Type: stepbuilder.VarTypeString}, 21 | }, 22 | Example: "When the user clicks the \"Main Logo\" element", 23 | Category: stepbuilder.Mouse, 24 | }, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /e2e/features/frontend/page_assertions.feature: -------------------------------------------------------------------------------- 1 | @PAGE_ASSERTIONS 2 | Feature: Page Title and URL Assertions 3 | 4 | Scenario: Verify that the page title matches exactly 5 | When the user goes to the "form e2e" page 6 | Then the page title should be "Formulaire de test E2E" 7 | 8 | Scenario: Verify that the current URL contains the page path 9 | When the user goes to the "form e2e" page 10 | Then the current URL should contain "form" 11 | 12 | Scenario: Verify that the current URL contains the details page path 13 | When the user goes to the "details e2e" page 14 | Then the current URL should contain "details" 15 | 16 | Scenario: Verify that the current URL contains the table page path 17 | When the user goes to the "table e2e" page 18 | Then the current URL should contain "table" 19 | 20 | Scenario: Verify that the current URL contains the visual page path 21 | When the user goes to the "visual e2e" page 22 | Then the current URL should contain "visual" -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/navigate_to_url.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (steps) userNavigateToURL() stepbuilder.Step { 10 | testDefinition := func(ctx context.Context, url string) (context.Context, error) { 11 | scenarioCtx := scenario.MustFromContext(ctx) 12 | scenarioCtx.OpenNewPage(url) 13 | return ctx, nil 14 | } 15 | 16 | return stepbuilder.NewWithOneVariable( 17 | []string{`the user navigate to the URL {string}`}, 18 | testDefinition, 19 | nil, 20 | stepbuilder.DocParams{ 21 | Description: "directs the browser to open the specified absolute URL", 22 | Variables: []stepbuilder.DocVariable{ 23 | {Name: "URL", Description: "the absolute URL", Type: stepbuilder.VarTypeString}, 24 | }, 25 | Example: "When the user navigates to the URL \"https://myapp.com/login\"", 26 | Category: stepbuilder.Navigation, 27 | }, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/wait_x_seconds.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "time" 9 | ) 10 | 11 | func (steps) userWait() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the user waits for {number} seconds`}, 14 | func(ctx context.Context, seconds string) (context.Context, error) { 15 | secondsInt, err := strconv.Atoi(seconds) 16 | if err != nil { 17 | return ctx, fmt.Errorf("invalid seconds: %s", seconds) 18 | } 19 | 20 | time.Sleep(time.Duration(secondsInt) * time.Second) 21 | return ctx, nil 22 | }, 23 | nil, 24 | stepbuilder.DocParams{ 25 | Description: "waits for a specified number of seconds.", 26 | Variables: []stepbuilder.DocVariable{ 27 | {Name: "seconds", Description: "The number of seconds to wait.", Type: stepbuilder.VarTypeInt}, 28 | }, 29 | Example: "When the user waits for 3 seconds", 30 | Category: stepbuilder.Navigation, 31 | }, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /e2e/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testflowkit-mock-server", 3 | "version": "1.0.0", 4 | "description": "Mock server with validation for TestFlowKit API testing", 5 | "main": "server.ts", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node --experimental-transform-types simple-server.ts", 9 | "dev": "nodemon --exec ts-node simple-server.ts", 10 | "build": "tsc", 11 | "json-server": "json-server --watch db.json --port 3001 --middlewares ./validation.js" 12 | }, 13 | "dependencies": { 14 | "ajv": "^8.12.0", 15 | "ajv-formats": "^3.0.1", 16 | "express": "^4.18.2", 17 | "json-server": "^0.17.4", 18 | "cors": "^2.8.5" 19 | }, 20 | "devDependencies": { 21 | "@types/express": "^4.17.21", 22 | "@types/json-server": "^0.14.8", 23 | "@types/node": "^20.10.0", 24 | "nodemon": "^3.0.1", 25 | "ts-node": "^10.9.0", 26 | "typescript": "^5.3.0" 27 | }, 28 | "keywords": [ 29 | "mock-server", 30 | "api-testing", 31 | "validation" 32 | ], 33 | "author": "TestFlowKit Team", 34 | "license": "MIT" 35 | } 36 | -------------------------------------------------------------------------------- /e2e/features/variables/set_get_variable.feature: -------------------------------------------------------------------------------- 1 | @TEST_VARIABLES 2 | Feature: variables testing 3 | 4 | 5 | Scenario: Write API response field into another field 6 | Given I prepare a REST request to "get_post_by_id" 7 | And I set the following path parameters: 8 | | id | 1 | 9 | And I send the request 10 | And I store the JSON path "title" from the response into "postTitle" variable 11 | When the user goes to the "form e2e" page 12 | And the user enters "{{postTitle}}" into the "text" field 13 | Then the value of the text field should be "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" 14 | 15 | 16 | Scenario: Write html element context into another field 17 | The page title is "Formulaire de test E2E" 18 | 19 | Given the user goes to the "form e2e" page 20 | And I store the content of "page title" into "pageTitle" variable 21 | And the user enters "{{ pageTitle }}" into the "text" field 22 | Then the value of the text field should be "Formulaire de test E2E" -------------------------------------------------------------------------------- /documentation/composables/useSidebar.ts: -------------------------------------------------------------------------------- 1 | import { ref, provide, type InjectionKey, type Ref } from "vue"; 2 | 3 | export type UseSidebar = { 4 | isOpen: Ref; 5 | toggleSidebar: () => void; 6 | }; 7 | 8 | export const useSidebarKey = Symbol() as InjectionKey; 9 | 10 | export default function useSidebar() { 11 | const isOpen = ref(true); 12 | 13 | const breakpoint = 1024; 14 | function toggleSidebar() { 15 | isOpen.value = !isOpen.value; 16 | } 17 | 18 | onMounted(() => { 19 | handleResize(); 20 | window.addEventListener("resize", handleResize); 21 | }); 22 | 23 | onUnmounted(() => { 24 | window.removeEventListener("resize", handleResize); 25 | }); 26 | 27 | const handleResize = () => { 28 | if (window.innerWidth >= breakpoint) { 29 | // Desktop: always keep sidebar open 30 | isOpen.value = true; 31 | } else { 32 | // Mobile: close sidebar when switching to mobile view 33 | isOpen.value = false; 34 | } 35 | }; 36 | 37 | provide(useSidebarKey, { 38 | isOpen, 39 | toggleSidebar, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /internal/step_definitions/core/wildcards_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestShouldReplaceTheOnlyStringWildcard(t *testing.T) { 9 | expected := fmt.Sprintf("^I am redirected to %s page$", wildcard) 10 | result := ConvertWildcards("^I am redirected to {string} page$") 11 | 12 | if result != expected { 13 | t.Fatalf(`"%s" expected but "%s" received`, expected, result) 14 | } 15 | } 16 | 17 | func TestShouldReplaceTheOnlyNumberWildcard(t *testing.T) { 18 | expected := fmt.Sprintf("^I must see %s links", wildcard) 19 | result := ConvertWildcards("^I must see {number} links") 20 | 21 | if result != expected { 22 | t.Fatalf(`"%s" expected but "%s" received`, expected, result) 23 | } 24 | } 25 | 26 | func TestShouldReplaceManyWildcard(t *testing.T) { 27 | expected := fmt.Sprintf("^I must see %s links which contains %s", wildcard, wildcard) 28 | result := ConvertWildcards("^I must see {number} links which contains {string}") 29 | 30 | if result != expected { 31 | t.Fatalf(`"%s" expected but "%s" received`, expected, result) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/hover_on_element.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "testflowkit/internal/config" 5 | "testflowkit/internal/step_definitions/core/stepbuilder" 6 | "testflowkit/pkg/browser" 7 | ) 8 | 9 | func (steps) hoverOnElement() stepbuilder.Step { 10 | return stepbuilder.NewWithOneVariable( 11 | []string{`the user hovers on {string}`}, 12 | commonSimpleElementInteraction(func(element browser.Element) error { 13 | return element.Hover() 14 | }), 15 | func(label string) stepbuilder.ValidationErrors { 16 | vc := stepbuilder.ValidationErrors{} 17 | if !config.IsElementDefined(label) { 18 | vc.AddMissingElement(label) 19 | } 20 | return vc 21 | }, 22 | stepbuilder.DocParams{ 23 | Description: "performs a hover action on the element identified by its logical name", 24 | Variables: []stepbuilder.DocVariable{ 25 | {Name: "name", Description: "The logical name of element to hover on.", Type: stepbuilder.VarTypeString}, 26 | }, 27 | Example: "When the user hovers on \"Submit button\"", 28 | Category: stepbuilder.Mouse, 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/clicks_on_button.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/stepbuilder" 6 | "testflowkit/internal/utils/stringutils" 7 | ) 8 | 9 | func (steps) userClicksOnButton() stepbuilder.Step { 10 | formatLabel := func(label string) string { 11 | return stringutils.SuffixWithUnderscore(label, "button") 12 | } 13 | 14 | return stepbuilder.NewWithOneVariable( 15 | []string{`the user clicks the {string} button`}, 16 | func(ctx context.Context, name string) (context.Context, error) { 17 | return clickCommonHandler(formatLabel).handler()(ctx, name) 18 | }, 19 | clickCommonHandler(formatLabel).validation(), 20 | stepbuilder.DocParams{ 21 | Description: "performs a click action on the button identified by its logical name", 22 | Variables: []stepbuilder.DocVariable{ 23 | {Name: "name", Description: "The logical name of button to click on.", Type: stepbuilder.VarTypeString}, 24 | }, 25 | Example: "When the user clicks the \"Submit Order\" button", 26 | Category: stepbuilder.Mouse, 27 | }, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/backend_context_variables.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | import ( 4 | "maps" 5 | ) 6 | 7 | // GetVariable gets a variable by name. 8 | func (bc *BackendContext) GetVariable(name string) (any, bool) { 9 | value, exists := bc.Variables[name] 10 | return value, exists 11 | } 12 | 13 | // SetVariable sets a variable with a pre-parsed value. 14 | func (bc *BackendContext) SetVariable(name string, value any) { 15 | bc.Variables[name] = value 16 | } 17 | 18 | // SetVariablesFromStrings sets multiple variables by parsing string values. 19 | func (bc *BackendContext) SetVariablesFromStrings(variables map[string]string) error { 20 | parsedVariables, err := bc.parser.ParseVariables(variables) 21 | if err != nil { 22 | return err 23 | } 24 | maps.Copy(bc.Variables, parsedVariables) 25 | return nil 26 | } 27 | 28 | // GetVariables returns all variables. 29 | func (bc *BackendContext) GetVariables() map[string]any { 30 | return bc.Variables 31 | } 32 | 33 | // ClearVariables clears all variables. 34 | func (bc *BackendContext) ClearVariables() { 35 | bc.Variables = make(map[string]any) 36 | } 37 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/main.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "testflowkit/internal/step_definitions/core/stepbuilder" 5 | ) 6 | 7 | type steps struct { 8 | } 9 | 10 | func GetSteps() []stepbuilder.Step { 11 | handlers := steps{} 12 | 13 | return []stepbuilder.Step{ 14 | handlers.checkCheckboxStatus(), 15 | handlers.theFieldShouldContain(), 16 | handlers.radioButtonShouldBeSelectedOrNot(), 17 | handlers.dropdownHasValuesSelected(), 18 | handlers.elementShouldContainsText(), 19 | handlers.elementShouldNotContainsText(), 20 | handlers.elementShouldContainsExactText(), 21 | handlers.elementShouldBeVisible(), 22 | handlers.elementShouldNotExist(), 23 | handlers.elementShouldNotBeVisible(), 24 | handlers.elementShouldExist(), 25 | handlers.pageTitleShouldBe(), 26 | handlers.currentURLShouldContain(), 27 | handlers.elementAttributeShouldBe(), 28 | } 29 | } 30 | 31 | func GetDocs() []stepbuilder.Documentation { 32 | var docs []stepbuilder.Documentation 33 | for _, step := range GetSteps() { 34 | docs = append(docs, step.GetDocumentation()) 35 | } 36 | return docs 37 | } 38 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/variables_validator_context_test.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestValidatorContext_AddMissingElement_dontAddDuplicate(t *testing.T) { 10 | vc := ValidatorContext{} 11 | 12 | vc.addMissingElement("element") 13 | vc.addMissingElement("element") 14 | 15 | assert.Len(t, vc.missingElements, 1) 16 | } 17 | 18 | func TestValidatorContext_AddMissingPage_dontAddDuplicate(t *testing.T) { 19 | vc := ValidatorContext{} 20 | 21 | vc.addMissingPage("page") 22 | vc.addMissingPage("page") 23 | 24 | assert.Len(t, vc.missingPages, 1) 25 | } 26 | 27 | func TestValidatorContext_AddMissingElement_Convert_to_key_before_adding(t *testing.T) { 28 | vc := ValidatorContext{} 29 | 30 | vc.addMissingElement("element one") 31 | 32 | assert.Contains(t, vc.missingElements, "element_one") 33 | } 34 | 35 | func TestValidatorContext_AddMissingPage_Convert_to_key_before_adding(t *testing.T) { 36 | vc := ValidatorContext{} 37 | 38 | vc.addMissingPage("page one") 39 | 40 | assert.Contains(t, vc.missingPages, "page_one") 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | 4 | jobs: 5 | e2e_tests: 6 | name: e2e tests running 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Build and start server 13 | run: docker compose -f e2e/compose.yml up server -d --build 14 | 15 | - name: Wait for server to be ready 16 | run: | 17 | echo "Waiting for server to be ready..." 18 | docker compose -f e2e/compose.yml logs server 19 | echo "Server container started!" 20 | 21 | - name: Launch e2e tests 22 | run: docker compose -f e2e/compose.yml up e2e --exit-code-from=e2e --build 23 | 24 | - name: Get test results 25 | if: failure() 26 | run: docker compose -f e2e/compose.yml cp e2e:/app/report . 27 | 28 | - name: Upload test results 29 | if: failure() 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: test-results 33 | path: report 34 | 35 | - name: Clean up containers 36 | if: always() 37 | run: docker compose -f e2e/compose.yml down --remove-orphans 38 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/right_click_on_button_or_element.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "testflowkit/internal/config" 5 | "testflowkit/internal/step_definitions/core/stepbuilder" 6 | "testflowkit/pkg/browser" 7 | ) 8 | 9 | func (steps) rightClickOn() stepbuilder.Step { 10 | return stepbuilder.NewWithOneVariable( 11 | []string{`the user right clicks on {string}`}, 12 | commonSimpleElementInteraction(func(element browser.Element) error { 13 | return element.RightClick() 14 | }), 15 | func(label string) stepbuilder.ValidationErrors { 16 | vc := stepbuilder.ValidationErrors{} 17 | if !config.IsElementDefined(label) { 18 | vc.AddMissingElement(label) 19 | } 20 | return vc 21 | }, 22 | stepbuilder.DocParams{ 23 | Description: "performs a right click action on the element identified by its logical name", 24 | Variables: []stepbuilder.DocVariable{ 25 | {Name: "name", Description: "The logical name of element to right click on.", Type: stepbuilder.VarTypeString}, 26 | }, 27 | Example: "When the user right clicks on \"Submit button\"", 28 | Category: stepbuilder.Mouse, 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /documentation/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Minimal Starter 2 | 3 | Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | 19 | # bun 20 | bun install 21 | ``` 22 | 23 | ## Development Server 24 | 25 | Start the development server on `http://localhost:3000`: 26 | 27 | ```bash 28 | # npm 29 | npm run dev 30 | 31 | # pnpm 32 | pnpm dev 33 | 34 | # yarn 35 | yarn dev 36 | 37 | # bun 38 | bun run dev 39 | ``` 40 | 41 | ## Production 42 | 43 | Build the application for production: 44 | 45 | ```bash 46 | # npm 47 | npm run build 48 | 49 | # pnpm 50 | pnpm build 51 | 52 | # yarn 53 | yarn build 54 | 55 | # bun 56 | bun run build 57 | ``` 58 | 59 | Locally preview production build: 60 | 61 | ```bash 62 | # npm 63 | npm run preview 64 | 65 | # pnpm 66 | pnpm preview 67 | 68 | # yarn 69 | yarn preview 70 | 71 | # bun 72 | bun run preview 73 | ``` 74 | 75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 76 | -------------------------------------------------------------------------------- /e2e/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | container_name: server 4 | networks: 5 | - default 6 | build: 7 | context: server 8 | dockerfile: Dockerfile 9 | 10 | healthcheck: 11 | test: ["CMD", "curl", "-f", "http://localhost:3000"] 12 | interval: 3s 13 | timeout: 5s 14 | retries: 5 15 | start_period: 2s 16 | 17 | mock-server: 18 | container_name: mock_server 19 | networks: 20 | - default 21 | build: 22 | context: server 23 | dockerfile: Dockerfile.mock 24 | ports: 25 | - "3001:3001" 26 | environment: 27 | - PORT=3001 28 | healthcheck: 29 | test: ["CMD", "curl", "-f", "http://localhost:3001/health"] 30 | interval: 5s 31 | timeout: 3s 32 | retries: 3 33 | start_period: 10s 34 | 35 | e2e: 36 | container_name: e2e_tests 37 | networks: 38 | - default 39 | build: 40 | context: ../ 41 | dockerfile: e2e/Dockerfile 42 | depends_on: 43 | server: 44 | condition: service_healthy 45 | mock-server: 46 | condition: service_healthy 47 | 48 | networks: 49 | default: 50 | driver: bridge 51 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/validation_errors.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | type ValidationErrors struct { 4 | missingPages []string 5 | missingElements []string 6 | missingFiles []string 7 | otherErrors []string 8 | undefinedSteps []string 9 | } 10 | 11 | func (ve *ValidationErrors) AddError(s string) { 12 | ve.otherErrors = append(ve.otherErrors, s) 13 | } 14 | 15 | func (ve *ValidationErrors) AddMissingPage(name string) { 16 | ve.missingPages = append(ve.missingPages, name) 17 | } 18 | 19 | func (ve *ValidationErrors) AddMissingElement(name string) { 20 | ve.missingElements = append(ve.missingElements, name) 21 | } 22 | 23 | func (ve *ValidationErrors) AddMissingFile(name string) { 24 | ve.missingFiles = append(ve.missingFiles, name) 25 | } 26 | 27 | func (ve *ValidationErrors) AddUndefinedStep(text string) { 28 | ve.undefinedSteps = append(ve.undefinedSteps, text) 29 | } 30 | 31 | func (ve *ValidationErrors) HasErrors() bool { 32 | frontErrors := len(ve.missingPages) > 0 || len(ve.missingElements) > 0 || len(ve.missingFiles) > 0 33 | otherErrors := len(ve.otherErrors) > 0 || len(ve.undefinedSteps) > 0 34 | return frontErrors || otherErrors 35 | } 36 | -------------------------------------------------------------------------------- /e2e/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | "time" 11 | ) 12 | 13 | func main() { 14 | files := []string{ 15 | "form", 16 | "table", 17 | "visual", 18 | "details", 19 | "scroll", 20 | "file-upload", 21 | } 22 | 23 | _, filename, _, _ := runtime.Caller(0) 24 | currDir := filepath.Dir(filename) 25 | 26 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 27 | filePath := path.Join(currDir, "index.html") 28 | http.ServeFile(w, r, filePath) 29 | }) 30 | 31 | for _, file := range files { 32 | http.HandleFunc("/"+file, func(w http.ResponseWriter, r *http.Request) { 33 | log.Println(r.URL.Path, "requested") 34 | filePath := path.Join(currDir, file+".html") 35 | http.ServeFile(w, r, filePath) 36 | }) 37 | } 38 | 39 | const port = 3000 40 | const timeout = 3 41 | server := &http.Server{ 42 | Addr: fmt.Sprintf(":%d", port), 43 | ReadHeaderTimeout: timeout * time.Second, 44 | } 45 | 46 | log.Printf("tests e2e server launched on port %d\n", port) 47 | err := server.ListenAndServe() 48 | if err != nil { 49 | panic(err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /documentation/components/DownloadItem.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 38 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/documentation.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import "strings" 4 | 5 | type Documentation struct { 6 | Sentence string 7 | Description string 8 | Variables []DocVariable 9 | Example string 10 | Category StepCategory 11 | } 12 | 13 | type DocVariable struct { 14 | Name, Description string 15 | Type VarType 16 | } 17 | 18 | type StepCategory string 19 | 20 | const ( 21 | Form StepCategory = "form" 22 | Visual StepCategory = "visual" 23 | Keyboard StepCategory = "keyboard" 24 | Navigation StepCategory = "navigation" 25 | Mouse StepCategory = "mouse" 26 | RESTAPI StepCategory = "restapi" 27 | GraphQL StepCategory = "graphql" 28 | Backend StepCategory = "backend" 29 | Variable StepCategory = "variable" 30 | Assertions StepCategory = "assertions" 31 | ) 32 | 33 | type VarType string 34 | 35 | const ( 36 | VarTypeString VarType = "string" 37 | VarTypeInt VarType = "int" 38 | VarTypeFloat VarType = "float" 39 | VarTypeBool VarType = "bool" 40 | VarTypeTable VarType = "table" 41 | ) 42 | 43 | func VarTypeEnum(values ...string) VarType { 44 | return VarType(strings.Join(values, ", ")) 45 | } 46 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/double_click_on_button_or_element.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "testflowkit/internal/config" 5 | "testflowkit/internal/step_definitions/core/stepbuilder" 6 | "testflowkit/pkg/browser" 7 | ) 8 | 9 | func (steps) doubleClickOn() stepbuilder.Step { 10 | const docDescription = "The logical name of element to double click on." 11 | 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the user double clicks on {string}`}, 14 | commonSimpleElementInteraction(func(element browser.Element) error { 15 | return element.DoubleClick() 16 | }), 17 | func(label string) stepbuilder.ValidationErrors { 18 | vc := stepbuilder.ValidationErrors{} 19 | if !config.IsElementDefined(label) { 20 | vc.AddMissingElement(label) 21 | } 22 | return vc 23 | }, 24 | stepbuilder.DocParams{ 25 | Description: "performs a double click action on the element identified by its logical name", 26 | Variables: []stepbuilder.DocVariable{ 27 | {Name: "name", Description: docDescription, Type: stepbuilder.VarTypeString}, 28 | }, 29 | Example: "When the user double clicks on \"File item\"", 30 | Category: stepbuilder.Mouse, 31 | }, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /documentation/plugins/highlightjs.ts: -------------------------------------------------------------------------------- 1 | import "highlight.js/styles/atom-one-dark.css"; 2 | 3 | import hljs from "highlight.js"; 4 | import bash from "highlight.js/lib/languages/bash"; 5 | import gherkin from "highlight.js/lib/languages/gherkin"; 6 | import yaml from "highlight.js/lib/languages/yaml"; 7 | 8 | hljs.registerLanguage("bash", bash); 9 | hljs.registerLanguage("gherkin", gherkin); 10 | hljs.registerLanguage("yaml", yaml); 11 | 12 | export default defineNuxtPlugin((nuxtApp) => { 13 | const highlightElement = (el: HTMLElement) => { 14 | const blocks = el.querySelectorAll("pre code"); 15 | blocks.forEach((block) => { 16 | hljs.highlightElement(block as HTMLElement); 17 | }); 18 | }; 19 | 20 | const rehighlight = () => { 21 | const blocks = document.querySelectorAll("pre code"); 22 | blocks.forEach((block) => { 23 | hljs.highlightElement(block as HTMLElement); 24 | }); 25 | }; 26 | 27 | nuxtApp.vueApp.directive("highlight", { 28 | mounted(el) { 29 | highlightElement(el); 30 | }, 31 | updated(el) { 32 | highlightElement(el); 33 | }, 34 | }); 35 | 36 | return { 37 | provide: { 38 | rehighlight, 39 | }, 40 | }; 41 | }); 42 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_exist.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | ) 9 | 10 | func (steps) elementShouldExist() stepbuilder.Step { 11 | return stepbuilder.NewWithOneVariable( 12 | []string{`the {string} should exist`}, 13 | func(ctx context.Context, name string) (context.Context, error) { 14 | scenarioCtx := scenario.MustFromContext(ctx) 15 | _, err := scenarioCtx.GetHTMLElementByLabel(name) 16 | return ctx, err 17 | }, 18 | func(name string) stepbuilder.ValidationErrors { 19 | vc := stepbuilder.ValidationErrors{} 20 | if !config.IsElementDefined(name) { 21 | vc.AddMissingElement(name) 22 | } 23 | 24 | return vc 25 | }, 26 | stepbuilder.DocParams{ 27 | Description: "verifies that an element exists on the page.", 28 | Variables: []stepbuilder.DocVariable{ 29 | {Name: "name", Description: "The logical name of the element.", Type: stepbuilder.VarTypeString}, 30 | }, 31 | Example: "Then the submit button should exist", 32 | Category: stepbuilder.Visual, 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/testflowkit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testflowkit/internal/actions" 6 | "testflowkit/internal/config" 7 | "testflowkit/pkg/logger" 8 | ) 9 | 10 | // Version is injected at build time via ldflags. 11 | var Version string 12 | 13 | func main() { 14 | args := getAppArgs() 15 | 16 | mode, err := args.getMode() 17 | if err != nil { 18 | logger.Fatal("Failed to get mode", err) 19 | } 20 | 21 | cfg, err := getConfig(args, err) 22 | 23 | actions.Execute(cfg, err, mode) 24 | } 25 | 26 | func getConfig(args argsConfig, err error) (*config.Config, error) { 27 | cfgPath, getcfigPathErr := args.getConfigPath() 28 | defaultConf := &config.Config{} 29 | defaultConf.SetVersion(Version) 30 | if getcfigPathErr != nil { 31 | return defaultConf, fmt.Errorf("failed to get config path: %w", getcfigPathErr) 32 | } 33 | 34 | configLoadErr := config.Load(cfgPath, args.getAppConfigOverrides()) 35 | if configLoadErr != nil { 36 | return defaultConf, fmt.Errorf("failed to load config: %w", configLoadErr) 37 | } 38 | 39 | cfg, configGetErr := config.Get() 40 | if configGetErr != nil { 41 | return defaultConf, fmt.Errorf("failed to get config: %w", err) 42 | } 43 | 44 | cfg.SetVersion(Version) 45 | 46 | return cfg, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/click_common.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | ) 9 | 10 | type clickCommon struct { 11 | labelFormatter func(string) string 12 | } 13 | 14 | func (c clickCommon) handler() func(context.Context, string) (context.Context, error) { 15 | return func(ctx context.Context, label string) (context.Context, error) { 16 | scCtx := scenario.MustFromContext(ctx) 17 | element, err := scCtx.GetHTMLElementByLabel(c.labelFormatter(label)) 18 | if err != nil { 19 | return ctx, err 20 | } 21 | err = element.Click() 22 | return ctx, err 23 | } 24 | } 25 | 26 | func (c clickCommon) validation() func(string) stepbuilder.ValidationErrors { 27 | return func(label string) stepbuilder.ValidationErrors { 28 | vc := stepbuilder.ValidationErrors{} 29 | formattedLabel := c.labelFormatter(label) 30 | if !config.IsElementDefined(formattedLabel) { 31 | vc.AddMissingElement(formattedLabel) 32 | } 33 | return vc 34 | } 35 | } 36 | 37 | func clickCommonHandler(labelFormatter func(string) string) *clickCommon { 38 | return &clickCommon{ 39 | labelFormatter: labelFormatter, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/step_definitions/variables/store_custom_variable.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | "testflowkit/pkg/logger" 8 | ) 9 | 10 | func (steps) storeCustomVariable() stepbuilder.Step { 11 | return stepbuilder.NewWithTwoVariables( 12 | []string{ 13 | `I store the value {string} into {string} variable`, 14 | }, 15 | func(ctx context.Context, value, varName string) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | 18 | scenarioCtx.SetVariable(varName, value) 19 | logger.InfoFf("Stored value '%s' into variable '%s'", value, varName) 20 | 21 | return ctx, nil 22 | }, 23 | nil, 24 | stepbuilder.DocParams{ 25 | Description: "Stores a custom value into a scenario variable.", 26 | Variables: []stepbuilder.DocVariable{ 27 | {Name: "value", Description: "The value to store in the variable", Type: stepbuilder.VarTypeString}, 28 | {Name: "varName", Description: "The name of the variable to store the value in", Type: stepbuilder.VarTypeString}, 29 | }, 30 | Example: `When I store the value "John Doe" into "displayed_name" variable`, 31 | Category: stepbuilder.Variable, 32 | }, 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/browser/rod/browser.go: -------------------------------------------------------------------------------- 1 | package rod 2 | 3 | import ( 4 | "testflowkit/pkg/browser" 5 | "time" 6 | 7 | "github.com/go-rod/rod" 8 | "github.com/go-rod/rod/lib/launcher" 9 | ) 10 | 11 | type rodBrowser struct { 12 | browser *rod.Browser 13 | } 14 | 15 | func (rb *rodBrowser) NewPage(url string) browser.Page { 16 | page := rb.browser.MustPage(url) 17 | 18 | page.MustWaitNavigation() 19 | page = page.MustWaitIdle() 20 | return newRodPage(page) 21 | } 22 | 23 | func (rb *rodBrowser) GetPages() []browser.Page { 24 | rodPages := rb.browser.MustPages() 25 | var pages []browser.Page 26 | for _, rodPage := range rodPages { 27 | pages = append(pages, newRodPage(rodPage)) 28 | } 29 | 30 | return pages 31 | } 32 | 33 | func (rb *rodBrowser) Close() { 34 | rb.browser.MustClose() 35 | } 36 | 37 | // New creates a new rod browser client instance. 38 | func New(headlessMode bool, thinkTime time.Duration, incognitoMode bool) browser.Client { 39 | path, _ := launcher.LookPath() 40 | u := launcher.New().Bin(path). 41 | Headless(headlessMode). 42 | MustLaunch() 43 | 44 | newBrowser := rod.New().ControlURL(u).SlowMotion(thinkTime).MustConnect() 45 | if incognitoMode { 46 | newBrowser = newBrowser.MustIncognito() 47 | } 48 | 49 | return &rodBrowser{ 50 | browser: newBrowser, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/browser/factory.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "testflowkit/pkg/browser" 5 | "testflowkit/pkg/browser/rod" 6 | "time" 7 | ) 8 | 9 | // DriverType represents the type of browser driver. 10 | type DriverType string 11 | 12 | const ( 13 | // DriverRod represents the Rod browser driver. 14 | DriverRod DriverType = "rod" 15 | // Future: DriverPlaywright DriverType = "playwright". 16 | ) 17 | 18 | // Config holds the configuration for creating a browser client instance. 19 | type Config struct { 20 | DriverType DriverType 21 | HeadlessMode bool 22 | ThinkTime time.Duration 23 | IncognitoMode bool 24 | } 25 | 26 | // CreateInstance creates a new browser client instance using the specified configuration. 27 | // If DriverType is empty or unknown, it defaults to DriverRod. 28 | func CreateInstance(cfg Config) browser.Client { 29 | if cfg.DriverType == "" { 30 | cfg.DriverType = DriverRod 31 | } 32 | 33 | switch cfg.DriverType { 34 | case DriverRod: 35 | return rod.New(cfg.HeadlessMode, cfg.ThinkTime, cfg.IncognitoMode) 36 | // Future: case DriverPlaywright: 37 | // return playwright.New(cfg.HeadlessMode, cfg.ThinkTime, cfg.IncognitoMode) 38 | default: 39 | // Default to Rod driver 40 | return rod.New(cfg.HeadlessMode, cfg.ThinkTime, cfg.IncognitoMode) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/scroll_to_element.go: -------------------------------------------------------------------------------- 1 | package visual 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | ) 9 | 10 | func (steps) scrollToElement() stepbuilder.Step { 11 | return stepbuilder.NewWithOneVariable( 12 | []string{`the user scrolls to the {string} element`}, 13 | func(ctx context.Context, elementName string) (context.Context, error) { 14 | scenarioCtx := scenario.MustFromContext(ctx) 15 | 16 | element, err := scenarioCtx.GetHTMLElementByLabel(elementName + "_element") 17 | 18 | if err != nil { 19 | return ctx, err 20 | } 21 | errScroll := element.ScrollIntoView() 22 | if errScroll != nil { 23 | return ctx, fmt.Errorf("failed to scroll to elementName '%s': %w", elementName, errScroll) 24 | } 25 | return ctx, nil 26 | }, 27 | nil, 28 | stepbuilder.DocParams{ 29 | Description: "scrolls to a specific element on the page.", 30 | Variables: []stepbuilder.DocVariable{ 31 | {Name: "elementName", Description: "The name of the element to scroll to.", Type: stepbuilder.VarTypeString}, 32 | }, 33 | Example: "When the user scrolls to the submit button element", 34 | Category: stepbuilder.Visual, 35 | }, 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /documentation/models/sentenceDoc.ts: -------------------------------------------------------------------------------- 1 | import type { SentenceCollectionItem } from "@nuxt/content"; 2 | 3 | export class SentenceDocImpl { 4 | readonly sentence: string; 5 | readonly description: string; 6 | readonly gherkinExample: string; 7 | readonly variables: SentenceVariable[]; 8 | constructor(_data: SentenceCollectionItem) { 9 | const data = unref(_data); 10 | 11 | this.variables = []; 12 | console.log(data.variables); 13 | 14 | // for (const element of data.value.variables) { 15 | // console.log(element); 16 | 17 | // // this.variables.push({ 18 | // // name: element.name, 19 | // // type: element.type, 20 | // // description: element.description, 21 | // // example: element.example, 22 | // // }); 23 | // } 24 | 25 | this.sentence = data.sentence; 26 | this.description = data.description; 27 | 28 | // this.variables = []; 29 | this.gherkinExample = ""; 30 | } 31 | } 32 | 33 | export type SentenceDoc = { 34 | readonly sentence: string; 35 | readonly description: string; 36 | readonly gherkinExample: string; 37 | readonly variables: SentenceVariable[]; 38 | }; 39 | 40 | export type SentenceVariable = { 41 | name: string; 42 | type: string; 43 | description?: string; 44 | example?: string; 45 | }; 46 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/should_see_on_page.go: -------------------------------------------------------------------------------- 1 | package visual 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) shouldSeeOnPage() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the user should see "{string}" on the page`}, 14 | func(ctx context.Context, word string) (context.Context, error) { 15 | scenarioCtx := scenario.MustFromContext(ctx) 16 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 17 | if pageErr != nil { 18 | return ctx, pageErr 19 | } 20 | elt, err := currentPage.GetOneBySelector("body") 21 | if err != nil { 22 | return ctx, err 23 | } 24 | if !strings.Contains(elt.TextContent(), word) { 25 | return ctx, fmt.Errorf("%s should be visible", word) 26 | } 27 | return ctx, nil 28 | }, 29 | nil, 30 | stepbuilder.DocParams{ 31 | Description: "checks if a word is visible on the page.", 32 | Variables: []stepbuilder.DocVariable{ 33 | {Name: "word", Description: "The word to check.", Type: stepbuilder.VarTypeString}, 34 | }, 35 | Example: "Then the user should see \"Submit\" on the page", 36 | Category: stepbuilder.Visual, 37 | }, 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /internal/actions/boilerplate/sample.boilerplate.feature: -------------------------------------------------------------------------------- 1 | @SAMPLE 2 | Feature: TestFlowKit Documentation Site Sample Test 3 | This is a sample feature file that demonstrates common TestFlowKit testing patterns. 4 | 5 | Background: 6 | Given the user opens a new browser tab 7 | 8 | Scenario: Navigate to documentation site and verify homepage 9 | When the user goes to the "home" page 10 | Then the page title should be "TestFlowKit - Behavior-Driven Testing Framework" 11 | And the user should see "TestFlowKit" on the page 12 | And the "get started button" should be visible 13 | 14 | Scenario: Navigate to Get Started page and verify content 15 | Given the user goes to the "home" page 16 | When the user clicks the "get started" button 17 | Then the current URL should contain "get-started" 18 | And the user should see "Getting Started" on the page 19 | 20 | Scenario: Explore the Sentences documentation 21 | When the user goes to the "sentences" page 22 | Then the current URL should contain "sentences" 23 | And the user should see "Step Definitions" on the page 24 | And the "sentence filter field" should be visible 25 | When the user enters "click" into the "sentence filter" field 26 | Then the user should see "clicks on an element which contains a specific text." on the page 27 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/page_title_should_be.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | ) 9 | 10 | func (steps) pageTitleShouldBe() stepbuilder.Step { 11 | return stepbuilder.NewWithOneVariable( 12 | []string{`the page title should be {string}`}, 13 | func(ctx context.Context, expectedTitle string) (context.Context, error) { 14 | scenarioCtx := scenario.MustFromContext(ctx) 15 | page, pageErr := scenarioCtx.GetCurrentPageOnly() 16 | if pageErr != nil { 17 | return ctx, pageErr 18 | } 19 | pageInfo := page.GetInfo() 20 | if pageInfo.Title != expectedTitle { 21 | return ctx, fmt.Errorf("expected page title to be '%s', but found '%s'", expectedTitle, pageInfo.Title) 22 | } 23 | return ctx, nil 24 | }, 25 | nil, 26 | stepbuilder.DocParams{ 27 | Description: "This assertion checks if the current page title matches the specified title exactly.", 28 | Variables: []stepbuilder.DocVariable{ 29 | {Name: "expectedTitle", Description: "The expected page title to match.", Type: stepbuilder.VarTypeString}, 30 | }, 31 | Example: `Then the page title should be "Welcome to TestFlowKit"`, 32 | Category: stepbuilder.Assertions, 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_not_exist.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/config" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) elementShouldNotExist() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the {string} should not exist`}, 14 | func(ctx context.Context, name string) (context.Context, error) { 15 | scenarioCtx := scenario.MustFromContext(ctx) 16 | _, err := scenarioCtx.GetHTMLElementByLabel(name) 17 | if err == nil { 18 | return ctx, fmt.Errorf("%s should not exist", name) 19 | } 20 | return ctx, nil 21 | }, 22 | func(name string) stepbuilder.ValidationErrors { 23 | vc := stepbuilder.ValidationErrors{} 24 | if !config.IsElementDefined(name) { 25 | vc.AddMissingElement(name) 26 | } 27 | 28 | return vc 29 | }, 30 | stepbuilder.DocParams{ 31 | Description: "verifies that an element does not exist on the page.", 32 | Variables: []stepbuilder.DocVariable{ 33 | {Name: "name", Description: "The logical name of the element.", Type: stepbuilder.VarTypeString}, 34 | }, 35 | Example: "Then the submit button should not exist", 36 | Category: stepbuilder.Visual, 37 | }, 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /e2e/features/frontend/table.feature: -------------------------------------------------------------------------------- 1 | @TABLE 2 | Feature: Table e2e tests 3 | 4 | Background: 5 | Given the user is on page 6 | | page_name | 7 | | table e2e | 8 | 9 | Scenario: User should see a specific table row 10 | Then the user should see a row containing the following elements 11 | | name | description | price | 12 | | Produit 1 | Description détaillée du produit 1 | 19.99 € | 13 | 14 | Scenario: User should not see a specific table row 15 | Then the user should not see a row containing the following elements 16 | | name | description | price | 17 | | Produit 1 | Description détaillée du produit 0 | 19.99 € | 18 | 19 | Scenario: User should click a specific table row 20 | When the user clicks on the row containing the following elements 21 | | name | description | price | 22 | | Produit 1 | Description détaillée du produit 1 | 19.99 € | 23 | Then the user should see "Description détaillée du produit 1 clicked !" on the page 24 | 25 | Scenario: User should see a table with following headers 26 | Then the user should see a table with the following headers 27 | | product name | Produit | 28 | | product description | Description | 29 | | product price | Prix | 30 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/user_is_on_homepage.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/pkg/logger" 9 | ) 10 | 11 | func (steps) userIsOnHomepage() stepbuilder.Step { 12 | const descriptionContext = "indicating that the user begins on the application's primary or default page" 13 | const moreDetails = "It assumes a predefined base URL for the \"homepage.\"" 14 | return stepbuilder.NewWithNoVariables( 15 | []string{"the user is on the homepage"}, 16 | func(ctx context.Context) (context.Context, error) { 17 | const settingsVariable = "homepage" 18 | scenarioCtx := scenario.MustFromContext(ctx) 19 | url, err := scenarioCtx.GetConfig().GetFrontendURL(settingsVariable) 20 | if err != nil { 21 | logger.Fatal(fmt.Sprintf("Url for page %s not configured", settingsVariable), err) 22 | return ctx, err 23 | } 24 | scenarioCtx.OpenNewPage(url) 25 | return ctx, nil 26 | }, 27 | nil, 28 | stepbuilder.DocParams{ 29 | Description: fmt.Sprintf("establishes the initial context, %s %s", descriptionContext, moreDetails), 30 | Variables: nil, 31 | Example: "Given the user is on the homepage", 32 | Category: stepbuilder.Navigation, 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/logger/common.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | coreLogger "log" 6 | "strings" 7 | 8 | "github.com/fatih/color" 9 | ) 10 | 11 | func log(level logLevel, message string) { 12 | logColor := getLevelColor(level) 13 | coreLogger.Printf("[%s] %s\n", logColor(string(level)), logColor(message)) 14 | } 15 | 16 | func getLevelColor(level logLevel) func(format string, a ...interface{}) string { 17 | switch level { 18 | case info: 19 | return color.BlueString 20 | case success: 21 | return color.GreenString 22 | case warn: 23 | return color.YellowString 24 | case erro: 25 | return color.RedString 26 | case fatal: 27 | return color.RedString 28 | default: 29 | return color.WhiteString 30 | } 31 | } 32 | 33 | type logLevel string 34 | 35 | const ( 36 | info logLevel = "INFO" 37 | warn logLevel = "WARN" 38 | success logLevel = "SUCCESS" 39 | erro logLevel = "ERROR" 40 | fatal logLevel = "FATAL" 41 | ) 42 | 43 | const indent = " " 44 | 45 | func GetIndents(number int) string { 46 | var identsSb47 strings.Builder 47 | for range number { 48 | identsSb47.WriteString(indent) 49 | } 50 | return identsSb47.String() 51 | } 52 | 53 | func formatList(elements []string) string { 54 | for i, element := range elements { 55 | elements[i] = fmt.Sprintf("%s- %s", indent, element) 56 | } 57 | 58 | return strings.Join(elements, "\n") 59 | } 60 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/should_not_see_on_page.go: -------------------------------------------------------------------------------- 1 | package visual 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) shouldNotSeeOnPage() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the user should not see {string} on the page`}, 14 | func(ctx context.Context, text string) (context.Context, error) { 15 | scenarioCtx := scenario.MustFromContext(ctx) 16 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 17 | if pageErr != nil { 18 | return ctx, pageErr 19 | } 20 | body, err := currentPage.GetOneBySelector("body") 21 | if err != nil { 22 | return ctx, err 23 | } 24 | 25 | if strings.Contains(body.TextContent(), text) { 26 | return ctx, fmt.Errorf("%s should not be visible", text) 27 | } 28 | return ctx, nil 29 | }, 30 | nil, 31 | stepbuilder.DocParams{ 32 | Description: "checks if a specific text is not visible on the page.", 33 | Variables: []stepbuilder.DocVariable{ 34 | {Name: "text", Description: "The text that should not be visible on the page.", Type: stepbuilder.VarTypeString}, 35 | }, 36 | Example: "Then the user should not see \"Error\" on the page", 37 | Category: stepbuilder.Visual, 38 | }, 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /e2e/features/frontend/file_upload.feature: -------------------------------------------------------------------------------- 1 | @FILE_UPLOAD @FORM 2 | Feature: File Upload e2e tests 3 | 4 | Background: 5 | Given the user goes to the "file upload e2e" page 6 | 7 | @SINGLE_FILE_UPLOAD 8 | Scenario: a user can upload a single file successfully 9 | When the user uploads the "avatar_image" file into the "Avatar" field 10 | Then the "files uploaded block" should contain the text "avatar.png" 11 | 12 | @MULTIPLE_FILE_UPLOAD 13 | Scenario: a user can upload multiple files successfully 14 | When the user uploads the "gallery_image1, gallery_image2, gallery_image3" files into the "Gallery" field 15 | Then the "files uploaded block" should contain the text "image1.jpg" 16 | And the "files uploaded block" should contain the text "image2.jpg" 17 | And the "files uploaded block" should contain the text "image3.jpg" 18 | 19 | @DOCUMENT_UPLOAD 20 | Scenario: a user can upload a document successfully 21 | When the user uploads the "test_document" file into the "Document" field 22 | Then the "files uploaded block" should contain the text "test.pdf" 23 | 24 | @CSV_UPLOAD 25 | Scenario: a user can upload a CSV file successfully 26 | When the user uploads the "sample_csv" file into the "Data" field 27 | Then the "files uploaded block" should contain the text "sample.csv" 28 | 29 | 30 | -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/helpers.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/pkg/browser" 7 | ) 8 | 9 | func GetPage(ctx context.Context) (browser.Page, string, error) { 10 | scenarioCtx := MustFromContext(ctx) 11 | return scenarioCtx.GetCurrentPage() 12 | } 13 | 14 | func GetPageOnly(ctx context.Context) (browser.Page, error) { 15 | scenarioCtx := MustFromContext(ctx) 16 | return scenarioCtx.GetCurrentPageOnly() 17 | } 18 | 19 | func GetConfig(ctx context.Context) *config.Config { 20 | scenarioCtx := MustFromContext(ctx) 21 | return scenarioCtx.GetConfig() 22 | } 23 | 24 | func OpenPage(ctx context.Context, url string) { 25 | scenarioCtx := MustFromContext(ctx) 26 | scenarioCtx.OpenNewPage(url) 27 | } 28 | 29 | func SetPage(ctx context.Context, page browser.Page) error { 30 | scenarioCtx := MustFromContext(ctx) 31 | return scenarioCtx.SetCurrentPage(page) 32 | } 33 | 34 | func GetKeyboard(ctx context.Context) browser.Keyboard { 35 | scenarioCtx := MustFromContext(ctx) 36 | return scenarioCtx.GetCurrentPageKeyboard() 37 | } 38 | 39 | func GetPages(ctx context.Context) []browser.Page { 40 | scenarioCtx := MustFromContext(ctx) 41 | return scenarioCtx.GetPages() 42 | } 43 | 44 | func UpdatePageName(ctx context.Context) { 45 | scenarioCtx := MustFromContext(ctx) 46 | scenarioCtx.UpdatePageNameIfNeeded() 47 | } 48 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/switch_to_most_opened_window.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/pkg/logger" 9 | ) 10 | 11 | func (steps) switchToMostOpenedWindow() stepbuilder.Step { 12 | return stepbuilder.NewWithNoVariables( 13 | []string{"the user switches to the most recently window opened"}, 14 | func(ctx context.Context) (context.Context, error) { 15 | scenarioCtx := scenario.MustFromContext(ctx) 16 | pages := scenarioCtx.GetPages() 17 | const minPages = 2 18 | if len(pages) < minPages { 19 | return ctx, fmt.Errorf("no additional windows found to switch to (only %d window open)", len(pages)) 20 | } 21 | newPage := pages[0] 22 | if err := scenarioCtx.SetCurrentPage(newPage); err != nil { 23 | return ctx, fmt.Errorf("failed to set current page: %w", err) 24 | } 25 | logger.Info("Switched to new window with URL: " + newPage.GetInfo().URL) 26 | return ctx, nil 27 | }, 28 | nil, 29 | stepbuilder.DocParams{ 30 | Description: "switches to the most recently opened browser window.", 31 | Variables: []stepbuilder.DocVariable{}, 32 | Example: "When the user switches to the most recently window opened", 33 | Category: stepbuilder.Navigation, 34 | }, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug all testflowkit E2E Tests", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "${workspaceFolder}/cmd/testflowkit", 13 | "args": ["run", "-c", "e2e/config.yml"], 14 | "cwd": "${workspaceFolder}" 15 | }, 16 | { 17 | "name": "Debug testflowkit backend E2E Tests", 18 | "type": "go", 19 | "request": "launch", 20 | "mode": "debug", 21 | "program": "${workspaceFolder}/cmd/testflowkit", 22 | "args": ["run", "-c", "e2e/config.yml", "--tags", "@API"], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "name": "Debug testflowkit E2E Tests by test tag", 27 | "type": "go", 28 | "request": "launch", 29 | "mode": "debug", 30 | "program": "${workspaceFolder}/cmd/testflowkit", 31 | "args": ["run", "-c", "e2e/config.yml", "--tags", "@TEST"], 32 | "cwd": "${workspaceFolder}" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/hover_on_element_which_contains.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (s steps) hoverOnElementWhichContains() stepbuilder.Step { 10 | return stepbuilder.NewWithTwoVariables( 11 | []string{`the user hovers on {string} which contains {string}`}, 12 | func(ctx context.Context, _, text string) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 15 | if pageErr != nil { 16 | return ctx, pageErr 17 | } 18 | element, err := currentPage.GetOneByTextContent(text) 19 | if err != nil { 20 | return ctx, err 21 | } 22 | err = element.Hover() 23 | return ctx, err 24 | }, 25 | nil, 26 | stepbuilder.DocParams{ 27 | Description: "hovers on an element which contains a specific text.", 28 | Variables: []stepbuilder.DocVariable{ 29 | {Name: "name", Description: "The logical name of the element to hover on.", Type: stepbuilder.VarTypeString}, 30 | {Name: "text", Description: "The text that the element should contain.", Type: stepbuilder.VarTypeString}, 31 | }, 32 | Example: "When the user hovers on \"Submit button\" which contains \"Submit\"", 33 | Category: stepbuilder.Mouse, 34 | }, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/restapi/set_body.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "context" 5 | 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/pkg/logger" 9 | ) 10 | 11 | func (steps) setRequestBody() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{ 14 | `I set the request body to:`, 15 | }, 16 | func(ctx context.Context, body string) (context.Context, error) { 17 | scenarioCtx := scenario.MustFromContext(ctx) 18 | backend := scenarioCtx.GetBackendContext() 19 | 20 | // Check if it looks like a file path (second sentence pattern) 21 | // File paths would be passed directly, multiline strings would have newlines 22 | 23 | backend.SetRequestBody([]byte(body)) 24 | logger.InfoFf("Request body set (%d bytes)", len(body)) 25 | return ctx, nil 26 | }, 27 | nil, 28 | stepbuilder.DocParams{ 29 | Description: "Sets the raw request body for the REST API request.", 30 | Variables: []stepbuilder.DocVariable{ 31 | { 32 | Name: "body", 33 | Description: "Raw body content or file path", 34 | Type: stepbuilder.VarTypeString, 35 | }, 36 | }, 37 | Example: `Given I set the request body to: 38 | """ 39 | {"name": "John", "email": "john@example.com"} 40 | """ 41 | `, 42 | Category: stepbuilder.RESTAPI, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/click_on_element_which_contains.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (s steps) clickOnElementWhichContains() stepbuilder.Step { 10 | return stepbuilder.NewWithTwoVariables( 11 | []string{`the user clicks on {string} which contains "{string}"`}, 12 | func(ctx context.Context, _, text string) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 15 | if pageErr != nil { 16 | return ctx, pageErr 17 | } 18 | element, err := currentPage.GetOneByTextContent(text) 19 | if err != nil { 20 | return ctx, err 21 | } 22 | err = element.Click() 23 | return ctx, err 24 | }, 25 | nil, 26 | stepbuilder.DocParams{ 27 | Description: "clicks on an element which contains a specific text.", 28 | Variables: []stepbuilder.DocVariable{ 29 | {Name: "name", Description: "The logical name of the element to click on.", Type: stepbuilder.VarTypeString}, 30 | {Name: "text", Description: "The text that the element should contain.", Type: stepbuilder.VarTypeString}, 31 | }, 32 | Example: "When the user clicks on \"Submit button\" which contains \"Submit\"", 33 | Category: stepbuilder.Mouse, 34 | }, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/step_one_var.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type stepOneVar[T supportedTypes] struct { 8 | sentences []string 9 | definition func(context.Context, T) (context.Context, error) 10 | validator func(T) ValidationErrors 11 | doc DocParams 12 | } 13 | 14 | func (s stepOneVar[T]) GetSentences() []string { 15 | return s.sentences 16 | } 17 | 18 | func (s stepOneVar[T]) GetDefinition() any { 19 | return s.definition 20 | } 21 | 22 | func (s stepOneVar[T]) GetDocumentation() Documentation { 23 | return Documentation{ 24 | Sentence: s.sentences[0], 25 | Description: s.doc.Description, 26 | Example: s.doc.Example, 27 | Category: s.doc.Category, 28 | Variables: s.doc.Variables, 29 | } 30 | } 31 | 32 | func (s stepOneVar[T]) Validate(vc *ValidatorContext) any { 33 | return func(t T) { 34 | if s.validator == nil { 35 | return 36 | } 37 | 38 | validations := s.validator(t) 39 | if validations.HasErrors() { 40 | vc.AddValidationErrors(validations) 41 | } 42 | } 43 | } 44 | 45 | func NewWithOneVariable[T supportedTypes]( 46 | sentences []string, 47 | definition func(context.Context, T) (context.Context, error), 48 | validator func(T) ValidationErrors, 49 | documentation DocParams, 50 | ) Step { 51 | return stepOneVar[T]{ 52 | sentences, 53 | definition, 54 | validator, 55 | documentation, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull request CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | env: 12 | BRANCH_NAME_REGEX: "^(docs|feat|fix|perf|refactor|revert|style|test|chore|releases)/" 13 | BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 14 | jobs: 15 | quality_check: 16 | name: code_quality 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup nodeJS 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: "22" 28 | 29 | - name: Install commitlint 30 | run: | 31 | yarn init --yes 32 | yarn add @commitlint/config-conventional commitlint@latest validate-branch-name 33 | 34 | - name: Validate PR commits with commitlint 35 | run: npx commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose 36 | 37 | - name: Setup Go 38 | uses: actions/setup-go@v5 39 | with: 40 | go-version: ${{ vars.GO_VERSION }} 41 | 42 | - name: Golangci-lint 43 | uses: golangci/golangci-lint-action@v9.2.0 44 | 45 | 46 | - name: Run unit tests 47 | run: go test ./... 48 | 49 | e2e_tests: 50 | uses: "./.github/workflows/e2e.yml" 51 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/restapi/set_json_body.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "context" 5 | 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/pkg/logger" 9 | ) 10 | 11 | // setJSONBody sets a JSON request body with validation. 12 | func (steps) setJSONBody() stepbuilder.Step { 13 | return stepbuilder.NewWithOneVariable( 14 | []string{`I set the JSON request body to:`}, 15 | func(ctx context.Context, jsonBody string) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | backend := scenarioCtx.GetBackendContext() 18 | 19 | backend.SetRequestBody([]byte(jsonBody)) 20 | logger.InfoFf("JSON request body set and validated (%d bytes)", len(jsonBody)) 21 | return ctx, nil 22 | }, 23 | nil, 24 | stepbuilder.DocParams{ 25 | Description: "Sets a JSON request body for the REST API request with JSON validation. The body must be valid JSON.", 26 | Variables: []stepbuilder.DocVariable{ 27 | { 28 | Name: "json", 29 | Description: "JSON body content", 30 | Type: stepbuilder.VarTypeString, 31 | }, 32 | }, 33 | Example: `Given I set the JSON request body to: 34 | """ 35 | { 36 | "name": "John Doe", 37 | "email": "john@example.com", 38 | "age": 30, 39 | "tags": ["developer", "golang"] 40 | } 41 | """`, 42 | Category: stepbuilder.RESTAPI, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/right_click_on_element_which_contains.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (s steps) rightClickOnElementWhichContains() stepbuilder.Step { 10 | return stepbuilder.NewWithTwoVariables( 11 | []string{`the user right clicks on {string} which contains "{string}"`}, 12 | func(ctx context.Context, _, text string) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 15 | if pageErr != nil { 16 | return ctx, pageErr 17 | } 18 | element, err := currentPage.GetOneByTextContent(text) 19 | if err != nil { 20 | return ctx, err 21 | } 22 | err = element.RightClick() 23 | return ctx, err 24 | }, 25 | nil, 26 | stepbuilder.DocParams{ 27 | Description: "right clicks on an element which contains a specific text.", 28 | Variables: []stepbuilder.DocVariable{ 29 | {Name: "name", Description: "The logical name of the element to right click on.", Type: stepbuilder.VarTypeString}, 30 | {Name: "text", Description: "The text that the element should contain.", Type: stepbuilder.VarTypeString}, 31 | }, 32 | Example: "When the user right clicks on \"Submit button\" which contains \"Submit\"", 33 | Category: stepbuilder.Mouse, 34 | }, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/step_no_var.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type stepWithoutVar struct { 8 | sentences []string 9 | definition func(context.Context) (context.Context, error) 10 | validator func() ValidationErrors 11 | doc DocParams 12 | } 13 | 14 | func (s stepWithoutVar) GetSentences() []string { 15 | return s.sentences 16 | } 17 | 18 | func (s stepWithoutVar) GetDefinition() any { 19 | return s.definition 20 | } 21 | 22 | func (s stepWithoutVar) GetDocumentation() Documentation { 23 | return Documentation{ 24 | Sentence: s.sentences[0], 25 | Description: s.doc.Description, 26 | Example: s.doc.Example, 27 | Category: s.doc.Category, 28 | Variables: s.doc.Variables, 29 | } 30 | } 31 | 32 | func (s stepWithoutVar) Validate(vc *ValidatorContext) any { 33 | return func() { 34 | if s.validator == nil { 35 | return 36 | } 37 | 38 | validations := s.validator() 39 | if validations.HasErrors() { 40 | vc.AddValidationErrors(validations) 41 | } 42 | } 43 | } 44 | 45 | type noVarDef func(context.Context) (context.Context, error) 46 | type noVarValidator func() ValidationErrors 47 | 48 | func NewWithNoVariables( 49 | sentences []string, 50 | definition noVarDef, 51 | validator noVarValidator, 52 | documentation DocParams, 53 | ) Step { 54 | return stepWithoutVar{ 55 | sentences, 56 | definition, 57 | validator, 58 | documentation, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/mouse/double_click_on_element_which_contains.go: -------------------------------------------------------------------------------- 1 | package mouse 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | ) 8 | 9 | func (s steps) doubleClickOnElementWhichContains() stepbuilder.Step { 10 | return stepbuilder.NewWithTwoVariables( 11 | []string{`the user double clicks on {string} which contains "{string}"`}, 12 | func(ctx context.Context, _, text string) (context.Context, error) { 13 | scenarioCtx := scenario.MustFromContext(ctx) 14 | currentPage, pageErr := scenarioCtx.GetCurrentPageOnly() 15 | if pageErr != nil { 16 | return ctx, pageErr 17 | } 18 | element, err := currentPage.GetOneByTextContent(text) 19 | if err != nil { 20 | return ctx, err 21 | } 22 | err = element.DoubleClick() 23 | return ctx, err 24 | }, 25 | nil, 26 | stepbuilder.DocParams{ 27 | Description: "double clicks on an element which contains a specific text.", 28 | Variables: []stepbuilder.DocVariable{ 29 | {Name: "name", Description: "The logical name of the element to double click on.", Type: stepbuilder.VarTypeString}, 30 | {Name: "text", Description: "The text that the element should contain.", Type: stepbuilder.VarTypeString}, 31 | }, 32 | Example: "When the user double clicks on \"File item\" which contains \"document.pdf\"", 33 | Category: stepbuilder.Mouse, 34 | }, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_not_be_visible.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/config" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) elementShouldNotBeVisible() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the {string} should not be visible`}, 14 | func(ctx context.Context, name string) (context.Context, error) { 15 | scenarioCtx := scenario.MustFromContext(ctx) 16 | element, err := scenarioCtx.GetHTMLElementByLabel(name) 17 | if err != nil { 18 | return ctx, err 19 | } 20 | 21 | if element.IsVisible() { 22 | return ctx, fmt.Errorf("%s should not be visible", name) 23 | } 24 | 25 | return ctx, nil 26 | }, 27 | func(name string) stepbuilder.ValidationErrors { 28 | vc := stepbuilder.ValidationErrors{} 29 | if !config.IsElementDefined(name) { 30 | vc.AddMissingElement(name) 31 | } 32 | 33 | return vc 34 | }, 35 | stepbuilder.DocParams{ 36 | Description: "verifies that an element is not visible on the page.", 37 | Variables: []stepbuilder.DocVariable{ 38 | {Name: "name", Description: "The logical name of the element.", Type: stepbuilder.VarTypeString}, 39 | }, 40 | Example: "Then \"Submit button\" should not be visible", 41 | Category: stepbuilder.Visual, 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/browser/rod/keyboard.go: -------------------------------------------------------------------------------- 1 | package rod 2 | 3 | import ( 4 | "testflowkit/pkg/browser" 5 | 6 | "github.com/go-rod/rod" 7 | "github.com/go-rod/rod/lib/input" 8 | ) 9 | 10 | // keyMap maps abstract browser.Key to rod input.Key. 11 | var keyMap = map[browser.Key]input.Key{ 12 | browser.KeyEnter: input.Enter, 13 | browser.KeyTab: input.Tab, 14 | browser.KeyDelete: input.Delete, 15 | browser.KeyEscape: input.Escape, 16 | browser.KeySpace: input.Space, 17 | browser.KeyArrowUp: input.ArrowUp, 18 | browser.KeyArrowRight: input.ArrowRight, 19 | browser.KeyArrowDown: input.ArrowDown, 20 | browser.KeyArrowLeft: input.ArrowLeft, 21 | } 22 | 23 | type rodKeyboard struct { 24 | keyboard *rod.Keyboard 25 | } 26 | 27 | func (k *rodKeyboard) Press(key browser.Key) error { 28 | rodKey, ok := keyMap[key] 29 | if !ok { 30 | // If key not found in map, try to use it as a rune (for single character keys) 31 | if len(key) == 1 { 32 | rodKey = input.Key(key[0]) 33 | } else { 34 | return &UnknownKeyError{Key: key} 35 | } 36 | } 37 | return k.keyboard.Press(rodKey) 38 | } 39 | 40 | func newRodKeyboard(keyboard *rod.Keyboard) browser.Keyboard { 41 | return &rodKeyboard{keyboard: keyboard} 42 | } 43 | 44 | // UnknownKeyError represents an error when an unknown key is pressed. 45 | type UnknownKeyError struct { 46 | Key browser.Key 47 | } 48 | 49 | func (e *UnknownKeyError) Error() string { 50 | return "unknown key: " + string(e.Key) 51 | } 52 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/current_url_should_contain.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) currentURLShouldContain() stepbuilder.Step { 12 | const description = "The URL substring that should be contained in the current URL." 13 | return stepbuilder.NewWithOneVariable( 14 | []string{`the current URL should contain {string}`}, 15 | func(ctx context.Context, expectedURLPart string) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | page, pageErr := scenarioCtx.GetCurrentPageOnly() 18 | if pageErr != nil { 19 | return ctx, pageErr 20 | } 21 | page.WaitLoading() 22 | 23 | pageInfo := page.GetInfo() 24 | if !strings.Contains(pageInfo.URL, expectedURLPart) { 25 | return ctx, fmt.Errorf("current URL '%s' does not contain '%s'", pageInfo.URL, expectedURLPart) 26 | } 27 | return ctx, nil 28 | }, 29 | nil, 30 | stepbuilder.DocParams{ 31 | Description: "This assertion checks if the current page URL contains the specified substring.", 32 | Variables: []stepbuilder.DocVariable{ 33 | {Name: "expectedURLPart", Description: description, Type: stepbuilder.VarTypeString}, 34 | }, 35 | Example: `Then the current URL should contain "dashboard"`, 36 | Category: stepbuilder.Assertions, 37 | }, 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/step_two_vars.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type stepTwoVars[T supportedTypes, U supportedTypes] struct { 8 | sentences []string 9 | definition func(context.Context, T, U) (context.Context, error) 10 | validator func(T, U) ValidationErrors 11 | doc DocParams 12 | } 13 | 14 | func (s stepTwoVars[T, U]) GetDocumentation() Documentation { 15 | return Documentation{ 16 | Sentence: s.sentences[0], 17 | Description: s.doc.Description, 18 | Example: s.doc.Example, 19 | Category: s.doc.Category, 20 | Variables: s.doc.Variables, 21 | } 22 | } 23 | 24 | func (s stepTwoVars[T, U]) GetSentences() []string { 25 | return s.sentences 26 | } 27 | 28 | func (s stepTwoVars[T, U]) GetDefinition() any { 29 | return s.definition 30 | } 31 | 32 | func (s stepTwoVars[T, U]) Validate(vc *ValidatorContext) any { 33 | return func(t T, u U) { 34 | if s.validator == nil { 35 | return 36 | } 37 | 38 | validations := s.validator(t, u) 39 | if validations.HasErrors() { 40 | vc.AddValidationErrors(validations) 41 | } 42 | } 43 | } 44 | 45 | func NewWithTwoVariables[T supportedTypes, U supportedTypes](sentences []string, 46 | definition func(context.Context, T, U) (context.Context, error), 47 | validator func(T, U) ValidationErrors, 48 | documentation DocParams, 49 | ) Step { 50 | return stepTwoVars[T, U]{ 51 | sentences, 52 | definition, 53 | validator, 54 | documentation, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_be_visible.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/config" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) elementShouldBeVisible() stepbuilder.Step { 12 | return stepbuilder.NewWithOneVariable( 13 | []string{`the {string} should be visible`}, 14 | func(ctx context.Context, elementName string) (context.Context, error) { 15 | scenarioCtx := scenario.MustFromContext(ctx) 16 | element, err := scenarioCtx.GetHTMLElementByLabel(elementName) 17 | if err != nil { 18 | return ctx, err 19 | } 20 | 21 | if !element.IsVisible() { 22 | return ctx, fmt.Errorf("%s is not visible", elementName) 23 | } 24 | 25 | return ctx, nil 26 | }, 27 | func(name string) stepbuilder.ValidationErrors { 28 | vc := stepbuilder.ValidationErrors{} 29 | if !config.IsElementDefined(name) { 30 | vc.AddMissingElement(name) 31 | } 32 | 33 | return vc 34 | }, 35 | stepbuilder.DocParams{ 36 | Description: "This assertion checks if the element is present in the DOM and displayed on the page.", 37 | Variables: []stepbuilder.DocVariable{ 38 | {Name: "name", Description: "The logical name of the element.", Type: stepbuilder.VarTypeString}, 39 | }, 40 | Example: "Then the submit button should be visible", 41 | Category: stepbuilder.Visual, 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/table/common.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "slices" 8 | "strings" 9 | "testflowkit/pkg/browser" 10 | ) 11 | 12 | func getTableRowByCellsContent(currentPage browser.Page, cellsContent []string) (browser.Element, error) { 13 | return getTableRowOrHeaderByCellsContent(currentPage, "td", cellsContent) 14 | } 15 | 16 | func getTableHeaderByCellsContent(currentPage browser.Page, cellsContent []string) (browser.Element, error) { 17 | return getTableRowOrHeaderByCellsContent(currentPage, "th", cellsContent) 18 | } 19 | 20 | func getTableRowOrHeaderByCellsContent(page browser.Page, selector string, content []string) (browser.Element, error) { 21 | allowedValues := []string{"th", "td"} 22 | if !slices.Contains(allowedValues, selector) { 23 | log.Panicf("only %s allowed", strings.Join(allowedValues, ", ")) 24 | } 25 | 26 | var xpathParts []string 27 | for _, value := range content { 28 | xpathParts = append(xpathParts, fmt.Sprintf("%s[contains(text(), '%s')]", selector, value)) 29 | } 30 | 31 | xPath := fmt.Sprintf("//tr[%s]", strings.Join(xpathParts, " and ")) 32 | 33 | element, err := page.GetOneByXPath(xPath) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if element == nil { 39 | return nil, errors.New("row not found") 40 | } 41 | 42 | if !element.IsVisible() { 43 | // TODO: better message 44 | return nil, errors.New("row is not visible") 45 | } 46 | 47 | return element, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/navigate_to_page.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/config" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | "testflowkit/pkg/logger" 10 | ) 11 | 12 | func (steps) userNavigateToPage() stepbuilder.Step { 13 | testDefinition := func(ctx context.Context, page string) (context.Context, error) { 14 | scenarioCtx := scenario.MustFromContext(ctx) 15 | url, err := scenarioCtx.GetConfig().GetFrontendURL(page) 16 | if err != nil { 17 | logger.Fatal(fmt.Sprintf("Url for page %s not configured", page), err) 18 | return ctx, err 19 | } 20 | scenarioCtx.OpenNewPage(url) 21 | return ctx, nil 22 | } 23 | 24 | return stepbuilder.NewWithOneVariable( 25 | []string{`the user goes to the {string} page`}, 26 | testDefinition, 27 | func(page string) stepbuilder.ValidationErrors { 28 | vc := stepbuilder.ValidationErrors{} 29 | if !config.IsPageDefined(page) { 30 | vc.AddMissingPage(page) 31 | } 32 | 33 | return vc 34 | }, 35 | stepbuilder.DocParams{ 36 | Description: "Navigates to a page identified by a logical name.", 37 | Variables: []stepbuilder.DocVariable{ 38 | {Name: "page", Description: "The name of the page to navigate to.", Type: stepbuilder.VarTypeString}, 39 | }, 40 | Example: "When the user goes to the \"Login\" page", 41 | Category: stepbuilder.Navigation, 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /e2e/features/frontend/assertions.feature: -------------------------------------------------------------------------------- 1 | @ASSERTIONS 2 | Feature: Text Assertions 3 | 4 | Background: 5 | Given the user opens a new browser tab 6 | 7 | Scenario: Verify that product name is displayed and matches exactly the expected value 8 | Given the user goes to the details e2e page 9 | Then the product name element should contain the text "Ordinateur de Bord pour Rameur" 10 | 11 | Scenario: Verify that product name is displayed and matches partially 12 | Given the user goes to the details e2e page 13 | Then the product name element should contain the text "Ordinateur de Bord" 14 | 15 | Scenario: Verify that product description is displayed and matches partially 16 | Given the user goes to the details e2e page 17 | Then the product description element should contain the text "Cet ordinateur de rameur vous permet de suivre vos performances en temps réel" 18 | 19 | Scenario: Verify that product name displayed not matches with an incorrect value 20 | Given the user goes to the details e2e page 21 | Then the product name element should not contain the text "Nintendo switch 2" 22 | 23 | @TEXT_FIELD 24 | Scenario Outline: a user can type into field 25 | Given the user goes to the "form e2e" page 26 | When the user enters "" into the "" field 27 | Then the value of the field should be "" 28 | 29 | Examples: 30 | | type | value | 31 | | text | Hello Test ! | 32 | | textarea | Hello Test area ! | 33 | -------------------------------------------------------------------------------- /e2e/server/scroll.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Scroll Test 7 | 30 | 31 | 32 |

Scroll to an Element Test Page

33 | 34 |
35 |

36 | This is a large empty space to create a scrollable distance.
Scroll 37 | down manually. 38 |

39 |
40 | 41 |
42 |

🎯 Here I am! The Target Element!

43 |

You have successfully scrolled to the destination.

44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/commonbackendsteps/validate_status.go: -------------------------------------------------------------------------------- 1 | package commonbackendsteps 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | 9 | "testflowkit/internal/step_definitions/core/scenario" 10 | "testflowkit/internal/step_definitions/core/stepbuilder" 11 | ) 12 | 13 | func (steps) validateStatusCode() stepbuilder.Step { 14 | return stepbuilder.NewWithOneVariable( 15 | []string{`the response status code should be {number}`}, 16 | func(ctx context.Context, expectedCodeStr string) (context.Context, error) { 17 | expectedCode, err := strconv.Atoi(expectedCodeStr) 18 | if err != nil { 19 | return ctx, fmt.Errorf("invalid status code: %s", expectedCodeStr) 20 | } 21 | scenarioCtx := scenario.MustFromContext(ctx) 22 | backend := scenarioCtx.GetBackendContext() 23 | 24 | if !backend.HasResponse() { 25 | return ctx, errors.New("no response available to validate") 26 | } 27 | 28 | actualCode := backend.GetStatusCode() 29 | if actualCode != expectedCode { 30 | return ctx, fmt.Errorf("status code mismatch: expected %d, got %d", expectedCode, actualCode) 31 | } 32 | 33 | return ctx, nil 34 | }, 35 | nil, 36 | stepbuilder.DocParams{ 37 | Description: "Validates the HTTP response status code.", 38 | Variables: []stepbuilder.DocVariable{ 39 | {Name: "statusCode", Description: "Expected HTTP status code", Type: stepbuilder.VarTypeInt}, 40 | }, 41 | Example: `Then the response status code should be 200`, 42 | Category: stepbuilder.Backend, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/clear_field.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | ) 10 | 11 | func (steps) clearField() stepbuilder.Step { 12 | formatLabel := func(label string) string { 13 | return stringutils.SuffixWithUnderscore(label, "field") 14 | } 15 | 16 | return stepbuilder.NewWithOneVariable( 17 | []string{`the user clears the {string} field`}, 18 | func(ctx context.Context, inputLabel string) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | input, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(inputLabel)) 21 | if err != nil { 22 | return ctx, err 23 | } 24 | 25 | err = input.Clear() 26 | return ctx, err 27 | }, 28 | func(inputLabel string) stepbuilder.ValidationErrors { 29 | vc := stepbuilder.ValidationErrors{} 30 | label := formatLabel(inputLabel) 31 | if !config.IsElementDefined(label) { 32 | vc.AddMissingElement(label) 33 | } 34 | 35 | return vc 36 | }, 37 | stepbuilder.DocParams{ 38 | Description: "clears the content of an input field.", 39 | Variables: []stepbuilder.DocVariable{ 40 | {Name: "inputLabel", Description: "The label of the input field to clear.", Type: stepbuilder.VarTypeString}, 41 | }, 42 | Example: "When the user clears the \"Username\" field", 43 | Category: stepbuilder.Form, 44 | }, 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /documentation/components/AccordionItem.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 32 | 33 | 45 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/step_three_vars.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type stepThreeVars[T supportedTypes, U supportedTypes, V supportedTypes] struct { 8 | sentences []string 9 | definition func(context.Context, T, U, V) (context.Context, error) 10 | validator func(T, U, V) ValidationErrors 11 | doc DocParams 12 | } 13 | 14 | func (s stepThreeVars[T, U, V]) GetDocumentation() Documentation { 15 | return Documentation{ 16 | Sentence: s.sentences[0], 17 | Description: s.doc.Description, 18 | Example: s.doc.Example, 19 | Category: s.doc.Category, 20 | Variables: s.doc.Variables, 21 | } 22 | } 23 | 24 | func (s stepThreeVars[T, U, V]) GetSentences() []string { 25 | return s.sentences 26 | } 27 | 28 | func (s stepThreeVars[T, U, V]) GetDefinition() any { 29 | return s.definition 30 | } 31 | 32 | func (s stepThreeVars[T, U, V]) Validate(vc *ValidatorContext) any { 33 | return func(t T, u U, v V) { 34 | if s.validator == nil { 35 | return 36 | } 37 | 38 | validations := s.validator(t, u, v) 39 | if validations.HasErrors() { 40 | vc.AddValidationErrors(validations) 41 | } 42 | } 43 | } 44 | 45 | func NewWithThreeVariables[T supportedTypes, U supportedTypes, V supportedTypes](sentences []string, 46 | definition func(context.Context, T, U, V) (context.Context, error), 47 | validator func(T, U, V) ValidationErrors, 48 | documentation DocParams, 49 | ) Step { 50 | return stepThreeVars[T, U, V]{ 51 | sentences, 52 | definition, 53 | validator, 54 | documentation, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/commonbackendsteps/set_headers.go: -------------------------------------------------------------------------------- 1 | package commonbackendsteps 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | "testflowkit/pkg/logger" 10 | 11 | "github.com/cucumber/godog" 12 | "github.com/rdumont/assistdog" 13 | ) 14 | 15 | func (steps) setHeaders() stepbuilder.Step { 16 | return stepbuilder.NewWithOneVariable( 17 | []string{`I set the following headers:`}, 18 | func(ctx context.Context, table *godog.Table) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | backend := scenarioCtx.GetBackendContext() 21 | 22 | headers, parseErr := assistdog.NewDefault().ParseMap(table) 23 | if parseErr != nil { 24 | return ctx, fmt.Errorf("failed to parse headers map: %w", parseErr) 25 | } 26 | 27 | for name, value := range headers { 28 | backend.SetHeader(name, value) 29 | } 30 | 31 | logger.InfoFf("Headers set: %v", headers) 32 | return ctx, nil 33 | }, 34 | nil, 35 | stepbuilder.DocParams{ 36 | Description: "Sets HTTP headers for the request.", 37 | Variables: []stepbuilder.DocVariable{ 38 | {Name: "headers", Description: "Table with header name and value pairs.", Type: stepbuilder.VarTypeTable}, 39 | }, 40 | Example: `And I set the following headers: 41 | | Authorization | Bearer {{token}} | 42 | | Content-Type | application/json | 43 | | X-Request-ID | {{requestId}} |`, 44 | Category: stepbuilder.Backend, 45 | }, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/commonbackendsteps/send_request.go: -------------------------------------------------------------------------------- 1 | package commonbackendsteps 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | "testflowkit/pkg/logger" 11 | ) 12 | 13 | func (steps) sendRequest() stepbuilder.Step { 14 | return stepbuilder.NewWithNoVariables( 15 | []string{ 16 | `I send the request`, 17 | `I execute the request`, 18 | }, 19 | func(ctx context.Context) (context.Context, error) { 20 | scenarioCtx := scenario.MustFromContext(ctx) 21 | backend := scenarioCtx.GetBackendContext() 22 | 23 | if backend.GetProtocol() == nil { 24 | return ctx, errors.New("no request has been prepared - use 'I prepare a request' step first") 25 | } 26 | 27 | protocol := backend.GetProtocol() 28 | logger.InfoFf("Sending %s request...", protocol.GetProtocolName()) 29 | 30 | // Substitute variables in the backend context before sending 31 | if err := backend.SubstituteVariables(scenarioCtx); err != nil { 32 | return ctx, fmt.Errorf("failed to substitute variables: %w", err) 33 | } 34 | 35 | // Send the request using the protocol adapter 36 | ctx, err := protocol.SendRequest(ctx) 37 | if err != nil { 38 | return ctx, fmt.Errorf("failed to send request: %w", err) 39 | } 40 | 41 | return ctx, nil 42 | }, 43 | nil, 44 | stepbuilder.DocParams{ 45 | Description: "Sends the prepared request", 46 | Example: `When I send the request`, 47 | Category: stepbuilder.Backend, 48 | }, 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /e2e/features/frontend/visual.feature: -------------------------------------------------------------------------------- 1 | @VISUAL 2 | Feature: visual e2e tests 3 | 4 | Background: 5 | Given the user is on page 6 | | page_name | 7 | | visual e2e | 8 | 9 | Scenario: User should see certain things on page 10 | Given the user should not see "L'élément a été caché." on the page 11 | And the user should see "Cet élément va disparaître quand vous cliquerez sur le bouton." on the page 12 | When the user clicks on the button which contains "Cacher l'élément" 13 | Then the user should not see "Cet élément va disparaître quand vous cliquerez sur le bouton." on the page 14 | And the user should see "L'élément a été caché." on the page 15 | 16 | @doubleClick 17 | Scenario: double click on element which contains 18 | Given the user should not see "Vous avez double cliqué sur le bouton." on the page 19 | When the user double clicks on the button which contains "double click" 20 | Then the user should see "Vous avez double cliqué sur le bouton." on the page 21 | 22 | @doubleClick 23 | Scenario: double click on element 24 | Given the user should not see "Vous avez double cliqué sur le bouton." on the page 25 | When the user double clicks on double click button 26 | Then the user should see "Vous avez double cliqué sur le bouton." on the page 27 | 28 | @visibility 29 | Scenario: element should exist but not visible 30 | Then the hidden button should exist 31 | And the hidden button should not be visible 32 | 33 | @visibility 34 | Scenario: element should not exist so not visible 35 | Then the non-existent button should not exist 36 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/restapi/debug_request.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/step_definitions/core/scenario" 6 | "testflowkit/internal/step_definitions/core/stepbuilder" 7 | "testflowkit/pkg/logger" 8 | ) 9 | 10 | func (steps) debugRequest() stepbuilder.Step { 11 | return stepbuilder.NewWithNoVariables( 12 | []string{`I debug the current request`}, 13 | func(ctx context.Context) (context.Context, error) { 14 | scenarioCtx := scenario.MustFromContext(ctx) 15 | 16 | logger.InfoFf("=== REQUEST DEBUG INFO ===") 17 | 18 | endpoint := scenarioCtx.GetEndpoint() 19 | if endpoint.Path != "" { 20 | logger.InfoFf("Endpoint: %s", endpoint.GetFullURL()) 21 | logger.InfoFf("Method: %s", endpoint.Method) 22 | } else { 23 | logger.InfoFf("No endpoint configured") 24 | } 25 | 26 | headers := scenarioCtx.GetRequestHeaders() 27 | if len(headers) > 0 { 28 | logger.InfoFf("Headers: %v", headers) 29 | } else { 30 | logger.InfoFf("No headers set") 31 | } 32 | 33 | body := scenarioCtx.GetRequestBody() 34 | if body != nil { 35 | logger.InfoFf("Body: %s (%d bytes)", string(body), len(body)) 36 | } else { 37 | logger.InfoFf("No body set") 38 | } 39 | 40 | logger.InfoFf("=== END DEBUG INFO ===") 41 | 42 | return ctx, nil 43 | }, 44 | nil, 45 | stepbuilder.DocParams{ 46 | Description: "Debug helper to show the current request configuration.", 47 | Variables: []stepbuilder.DocVariable{}, 48 | Example: `When I debug the current request`, 49 | Category: stepbuilder.RESTAPI, 50 | }, 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/switch_to_original_window.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | "testflowkit/pkg/logger" 10 | ) 11 | 12 | func (steps) switchToOriginalWindow() stepbuilder.Step { 13 | return stepbuilder.NewWithNoVariables( 14 | []string{"the user switches back to the original window"}, 15 | func(ctx context.Context) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | pages := scenarioCtx.GetPages() 18 | 19 | const minPages = 2 20 | if len(pages) < minPages { 21 | return ctx, errors.New("only one window is open, no original window to switch back to") 22 | } 23 | 24 | originalPage := pages[len(pages)-1] 25 | 26 | originalPage.Focus() 27 | 28 | originalPage.WaitLoading() 29 | 30 | if err := scenarioCtx.SetCurrentPage(originalPage); err != nil { 31 | return ctx, fmt.Errorf("failed to set current page: %w", err) 32 | } 33 | 34 | logger.Info("Switched back to original window with URL: " + originalPage.GetInfo().URL) 35 | 36 | return ctx, nil 37 | }, 38 | func() stepbuilder.ValidationErrors { 39 | return stepbuilder.ValidationErrors{} 40 | }, 41 | stepbuilder.DocParams{ 42 | Description: "switches back to the original browser window (usually the first window).", 43 | Variables: []stepbuilder.DocVariable{}, 44 | Example: "When the user switches back to the original window", 45 | Category: stepbuilder.Navigation, 46 | }, 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/variables/runtime.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | // ReplaceInString replaces all variable placeholders in the format {{variableName}} with their actual values. 9 | func (p *Parser) ReplaceInString(input string) string { 10 | // Pattern to match {{variableName}} 11 | re := regexp.MustCompile(`\{\{([^}]+)\}\}`) 12 | 13 | return re.ReplaceAllStringFunc(input, func(match string) string { 14 | // Extract variable name from {{variableName}} 15 | varName := strings.TrimSpace(match[2 : len(match)-2]) 16 | 17 | // Get variable value from context 18 | value, exists := p.store.GetVariable(varName) 19 | if !exists { 20 | return match // Return original if variable not found 21 | } 22 | 23 | // Convert value to string 24 | str, err := p.SerializeValue(value) 25 | if err != nil { 26 | return match // Return original if serialization fails 27 | } 28 | 29 | return str 30 | }) 31 | } 32 | 33 | // ReplaceInBytes replaces variable placeholders in byte slice (useful for request bodies). 34 | func (p *Parser) ReplaceInBytes(input []byte) []byte { 35 | replaced := p.ReplaceInString(string(input)) 36 | return []byte(replaced) 37 | } 38 | 39 | // ReplaceInMap replaces variable placeholders in all values of a map (useful for headers and query params). 40 | func (p *Parser) ReplaceInMap(input map[string]string) map[string]string { 41 | result := make(map[string]string) 42 | for key, value := range input { 43 | result[key] = p.ReplaceInString(value) 44 | } 45 | return result 46 | } 47 | 48 | type Store interface { 49 | GetVariable(name string) (any, bool) 50 | } 51 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/check_checkbox.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | ) 10 | 11 | func (steps) checkCheckbox() stepbuilder.Step { 12 | formatLabel := func(label string) string { 13 | return stringutils.SuffixWithUnderscore(label, "checkbox") 14 | } 15 | 16 | return stepbuilder.NewWithOneVariable( 17 | []string{`the user checks the {string} checkbox`}, 18 | func(ctx context.Context, checkBoxName string) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | checkBox, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(checkBoxName)) 21 | if err != nil { 22 | return ctx, err 23 | } 24 | 25 | if !checkBox.IsChecked() { 26 | err = checkBox.Click() 27 | return ctx, err 28 | } 29 | 30 | return ctx, nil 31 | }, 32 | func(checkBoxName string) stepbuilder.ValidationErrors { 33 | vc := stepbuilder.ValidationErrors{} 34 | label := formatLabel(checkBoxName) 35 | if !config.IsElementDefined(label) { 36 | vc.AddMissingElement(label) 37 | } 38 | return vc 39 | }, 40 | stepbuilder.DocParams{ 41 | Description: "checks a checkbox if it is not already checked.", 42 | Variables: []stepbuilder.DocVariable{ 43 | {Name: "name", Description: "checkbox logical name", Type: stepbuilder.VarTypeString}, 44 | }, 45 | Example: "When the user checks the \"Terms\" checkbox", 46 | Category: stepbuilder.Form, 47 | }, 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /internal/step_definitions/variables/store_html_element_content.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/pkg/logger" 9 | ) 10 | 11 | func (steps) storeElementContentIntoVariable() stepbuilder.Step { 12 | return stepbuilder.NewWithTwoVariables( 13 | []string{ 14 | `I store the content of {string} into {string} variable`, 15 | }, 16 | func(ctx context.Context, elementName, varName string) (context.Context, error) { 17 | scenarioCtx := scenario.MustFromContext(ctx) 18 | 19 | element, err := scenarioCtx.GetHTMLElementByLabel(elementName) 20 | if err != nil { 21 | return ctx, fmt.Errorf("failed to find element '%s': %w", elementName, err) 22 | } 23 | 24 | content := element.TextContent() 25 | 26 | scenarioCtx.SetVariable(varName, content) 27 | logger.InfoFf("Stored content '%s' from element '%s' into variable '%s'", content, elementName, varName) 28 | 29 | return ctx, nil 30 | }, 31 | nil, 32 | stepbuilder.DocParams{ 33 | Description: "Stores the text content of an HTML element into a scenario variable.", 34 | Variables: []stepbuilder.DocVariable{ 35 | {Name: "elementName", Description: "The logical name of the HTML element", Type: stepbuilder.VarTypeString}, 36 | {Name: "varName", Description: "The name of the variable to store the content in", Type: stepbuilder.VarTypeString}, 37 | }, 38 | Example: `When I store the content of "user_name_label" into "displayed_name" variable`, 39 | Category: stepbuilder.Variable, 40 | }, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /e2e/features/graphql/graphql_zero.feature: -------------------------------------------------------------------------------- 1 | Feature: GraphQL Zero API Testing 2 | 3 | Scenario: Fetch a user successfully 4 | Given I prepare a graphql request to "getUser" 5 | And I set the following GraphQL variables: 6 | | id | 1 | 7 | When I send the request 8 | Then the GraphQL response should not have errors 9 | And the response should have field "user.username" 10 | And the response should contain "Bret" 11 | 12 | Scenario: Create a post successfully 13 | Given I prepare a graphql request to "createPost" 14 | And I set the following GraphQL variables: 15 | | input | {"title": "Test Post", "body": "This is a test post"} | 16 | When I send the request 17 | Then the GraphQL response should not have errors 18 | And the response should have field "createPost.id" 19 | And the response should contain "Test Post" 20 | 21 | Scenario: Fetch a user with error handling (Simulated) 22 | # GraphQLZero might not error easily on ID, but let's try to verify we can check for errors if they occurred. 23 | # Since we can't easily force an error on this public API without changing the query structure (which is fixed in config), 24 | # we will just verify the success path again but using error checking steps negatively. 25 | Given I prepare a graphql request to "getUser" 26 | And I set the following GraphQL variables: 27 | | id | 1 | 28 | When I send the request 29 | Then the GraphQL response should not have errors 30 | # If we could force an error, we would use: 31 | # Then the GraphQL response should have errors 32 | # And the GraphQL error message should contain "Some error" 33 | -------------------------------------------------------------------------------- /internal/step_definitions/variables/variable_should_contains.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) variableShouldContains() stepbuilder.Step { 12 | contentDesc := "The string that should be present in the variable" 13 | return stepbuilder.NewWithTwoVariables( 14 | []string{ 15 | `the variable {string} should contain {string}`, 16 | }, 17 | func(ctx context.Context, varName, content string) (context.Context, error) { 18 | scenarioCtx := scenario.MustFromContext(ctx) 19 | 20 | variable, exists := scenarioCtx.GetVariable(varName) 21 | if !exists { 22 | return ctx, fmt.Errorf("variable '%s' not found", varName) 23 | } 24 | 25 | variableValue, ok := variable.(string) 26 | if !ok { 27 | return ctx, fmt.Errorf("variable '%s' is not a string", varName) 28 | } 29 | 30 | if !strings.Contains(variableValue, content) { 31 | return ctx, fmt.Errorf("variable '%s' does not contain '%s'", varName, content) 32 | } 33 | 34 | return ctx, nil 35 | }, 36 | nil, 37 | stepbuilder.DocParams{ 38 | Description: "Verifies that a variable contains a specific string.", 39 | Variables: []stepbuilder.DocVariable{ 40 | {Name: "varName", Description: "The name of the variable to check", Type: stepbuilder.VarTypeString}, 41 | {Name: "content", Description: contentDesc, Type: stepbuilder.VarTypeString}, 42 | }, 43 | Example: `Then the variable "user_name_label" should contain "John Doe"`, 44 | Category: stepbuilder.Variable, 45 | }, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/reporters/testsuitedetails.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | type testSuiteDetails struct { 10 | ExecutionDate string 11 | TotalExecutionTime string 12 | TotalTests string 13 | SucceededTests string 14 | FailedTests string 15 | SuccessRate string 16 | StartDate time.Time 17 | Scenarios []Scenario 18 | } 19 | 20 | func (ts *testSuiteDetails) getTestSuiteDurationInSeconds() int { 21 | var total time.Duration 22 | for _, sc := range ts.Scenarios { 23 | total += sc.Duration 24 | } 25 | return int(total.Seconds()) 26 | } 27 | 28 | func (ts *testSuiteDetails) getScenarioResults() (int, int) { 29 | var succeedSc, failedSc int 30 | for _, sc := range ts.Scenarios { 31 | if sc.Result == succeeded { 32 | succeedSc++ 33 | } else { 34 | failedSc++ 35 | } 36 | } 37 | return succeedSc, failedSc 38 | } 39 | 40 | func newTestSuiteDetails(startDate time.Time, scenarios []Scenario) *testSuiteDetails { 41 | ts := testSuiteDetails{ 42 | StartDate: startDate, 43 | Scenarios: scenarios, 44 | } 45 | 46 | dateTime := ts.StartDate.Format("01-02-2006 at 15:04") 47 | 48 | total := len(ts.Scenarios) 49 | succeedSc, failedSc := ts.getScenarioResults() 50 | 51 | ts.TotalTests = strconv.Itoa(total) 52 | ts.SucceededTests = strconv.Itoa(succeedSc) 53 | ts.FailedTests = strconv.Itoa(failedSc) 54 | ts.ExecutionDate = dateTime 55 | ts.TotalExecutionTime = fmt.Sprintf("%ds", ts.getTestSuiteDurationInSeconds()) 56 | if total > 0 { 57 | ts.SuccessRate = strconv.Itoa(succeedSc * 100 / total) 58 | } else { 59 | ts.SuccessRate = "0" 60 | } 61 | return &ts 62 | } 63 | -------------------------------------------------------------------------------- /pkg/graphql/types.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Request represents a GraphQL request. 8 | type Request struct { 9 | Query string `json:"query"` 10 | Variables map[string]interface{} `json:"variables,omitempty"` 11 | } 12 | 13 | // Response represents a GraphQL response. 14 | type Response struct { 15 | Data json.RawMessage `json:"data,omitempty"` 16 | Errors []Error `json:"errors,omitempty"` 17 | Extensions map[string]interface{} `json:"extensions,omitempty"` 18 | StatusCode int `json:"-"` // HTTP status code (not part of GraphQL spec) 19 | } 20 | 21 | // Error represents a GraphQL error. 22 | type Error struct { 23 | Message string `json:"message"` 24 | Locations []ErrorLocation `json:"locations,omitempty"` 25 | Path []interface{} `json:"path,omitempty"` 26 | Extensions map[string]interface{} `json:"extensions,omitempty"` 27 | } 28 | 29 | // ErrorLocation represents the location of a GraphQL error. 30 | type ErrorLocation struct { 31 | Line int `json:"line"` 32 | Column int `json:"column"` 33 | } 34 | 35 | // ErrorSummary provides a structured overview of errors in a GraphQL response. 36 | type ErrorSummary struct { 37 | TotalErrors int `json:"total_errors"` 38 | ErrorsByType map[string]int `json:"errors_by_type"` 39 | ErrorsBySeverity map[string]int `json:"errors_by_severity"` 40 | Messages []string `json:"messages"` 41 | } 42 | 43 | // GetParser returns a ResponseParser for this response. 44 | func (r *Response) GetParser() *ResponseParser { 45 | return NewResponseParser(r) 46 | } 47 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/table/table_should_contains_the_following_headers.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "context" 5 | "maps" 6 | "slices" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | 10 | "github.com/cucumber/godog" 11 | "github.com/rdumont/assistdog" 12 | ) 13 | 14 | func (steps) tableShouldContainsTheFollowingHeaders() stepbuilder.Step { 15 | example := ` 16 | When the user should see a table with the following headers 17 | | Name | Age | 18 | ` 19 | 20 | return stepbuilder.NewWithOneVariable( 21 | []string{`the user should see a table with the following headers`}, 22 | func(ctx context.Context, table *godog.Table) (context.Context, error) { 23 | scenarioCtx := scenario.MustFromContext(ctx) 24 | data, err := assistdog.NewDefault().ParseMap(table) 25 | if err != nil { 26 | return ctx, err 27 | } 28 | 29 | parsedData, err := scenario.ReplaceVariablesInMap(scenarioCtx, data) 30 | if err != nil { 31 | return ctx, err 32 | } 33 | 34 | currentPage, errPage := scenarioCtx.GetCurrentPageOnly() 35 | if errPage != nil { 36 | return ctx, errPage 37 | } 38 | 39 | _, err = getTableHeaderByCellsContent(currentPage, slices.Sorted(maps.Values(parsedData))) 40 | return ctx, err 41 | }, 42 | nil, 43 | stepbuilder.DocParams{ 44 | Description: "checks if a table contains the following headers.", 45 | Variables: []stepbuilder.DocVariable{ 46 | {Name: "table", Description: "The table containing the headers to check.", Type: stepbuilder.VarTypeTable}, 47 | }, 48 | Example: example, 49 | Category: stepbuilder.Visual, 50 | }, 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /internal/step_definitions/core/stepbuilder/update-page-name.decorator.go: -------------------------------------------------------------------------------- 1 | package stepbuilder 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | ) 9 | 10 | type updatePageNameDecorator struct { 11 | step Step 12 | } 13 | 14 | func (d *updatePageNameDecorator) Validate(context *ValidatorContext) any { 15 | return d.step.Validate(context) 16 | } 17 | 18 | func (d *updatePageNameDecorator) GetDocumentation() Documentation { 19 | return d.step.GetDocumentation() 20 | } 21 | 22 | func (d *updatePageNameDecorator) GetSentences() []string { 23 | return d.step.GetSentences() 24 | } 25 | 26 | func (d *updatePageNameDecorator) GetDefinition() any { 27 | originalFunc := d.step.GetDefinition() 28 | originalFuncValue := reflect.ValueOf(originalFunc) 29 | 30 | if originalFuncValue.Kind() != reflect.Func { 31 | return func() error { 32 | return fmt.Errorf("expected a function, but got %T", originalFunc) 33 | } 34 | } 35 | 36 | wrapperFunc := func(args []reflect.Value) []reflect.Value { 37 | if len(args) == 0 { 38 | panic("context is required") 39 | } 40 | 41 | ctxValue := args[0] 42 | if ctxValue.Type() == reflect.TypeOf((*context.Context)(nil)).Elem() { 43 | if ctx, ok := ctxValue.Interface().(context.Context); ok { 44 | scenarioCtx := scenario.FromContext(ctx) 45 | if scenarioCtx != nil { 46 | scenarioCtx.UpdatePageNameIfNeeded() 47 | } 48 | } 49 | } 50 | return originalFuncValue.Call(args) 51 | } 52 | 53 | return reflect.MakeFunc(originalFuncValue.Type(), wrapperFunc).Interface() 54 | } 55 | 56 | func NewUpdatePageNameDecorator(step Step) Step { 57 | return &updatePageNameDecorator{step: step} 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module testflowkit 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/alexflint/go-arg v1.6.0 7 | github.com/cucumber/godog v0.15.1 8 | github.com/go-rod/rod v0.116.2 9 | github.com/goccy/go-yaml v1.19.0 10 | github.com/stretchr/testify v1.11.1 11 | github.com/tdewolff/parse v2.3.4+incompatible 12 | ) 13 | 14 | require github.com/cucumber/messages/go/v21 v21.0.1 15 | 16 | require ( 17 | github.com/fatih/color v1.18.0 18 | github.com/gofrs/uuid/v5 v5.4.0 19 | github.com/tidwall/gjson v1.18.0 20 | ) 21 | 22 | require ( 23 | github.com/mattn/go-colorable v0.1.13 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/tidwall/match v1.1.1 // indirect 26 | github.com/tidwall/pretty v1.2.1 // indirect 27 | golang.org/x/sys v0.25.0 // indirect 28 | ) 29 | 30 | require ( 31 | github.com/alexflint/go-scalar v1.2.0 // indirect 32 | github.com/cucumber/gherkin/go/v26 v26.2.0 33 | github.com/davecgh/go-spew v1.1.1 // indirect 34 | github.com/gofrs/uuid v4.4.0+incompatible // indirect 35 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 36 | github.com/hashicorp/go-memdb v1.3.4 // indirect 37 | github.com/hashicorp/golang-lru v1.0.2 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/rdumont/assistdog v0.0.0-20240711132531-b5b791dd7452 40 | github.com/spf13/pflag v1.0.7 // indirect 41 | github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 // indirect 42 | github.com/ysmood/fetchup v0.2.4 // indirect 43 | github.com/ysmood/goob v0.4.0 // indirect 44 | github.com/ysmood/got v0.40.0 // indirect 45 | github.com/ysmood/gson v0.7.3 // indirect 46 | github.com/ysmood/leakless v0.9.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /pkg/reporters/main.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "testflowkit/pkg/logger" 7 | "time" 8 | ) 9 | 10 | type scenarioResult string 11 | 12 | const ( 13 | succeeded scenarioResult = "succeeded" 14 | failed scenarioResult = "failed" 15 | ) 16 | 17 | type Report struct { 18 | scenarios []Scenario 19 | startDate time.Time 20 | formatter formatter 21 | AreAllTestsPassed bool 22 | } 23 | 24 | func (r *Report) AddScenario(sc Scenario) { 25 | r.scenarios = append(r.scenarios, sc) 26 | 27 | result := sc.Result 28 | addedScenarioLoggedMessage := fmt.Sprintf("'%s' %s in %fs", sc.Title, result, sc.Duration.Seconds()) 29 | 30 | if result == failed { 31 | r.AreAllTestsPassed = false 32 | logger.Error(addedScenarioLoggedMessage, nil, nil) 33 | } else { 34 | logger.Success(addedScenarioLoggedMessage) 35 | } 36 | } 37 | 38 | func (r *Report) Start() { 39 | r.startDate = time.Now() 40 | } 41 | 42 | func (r *Report) Write() { 43 | ts := newTestSuiteDetails( 44 | r.startDate, 45 | r.scenarios, 46 | ) 47 | 48 | r.formatter.WriteReport(*ts) 49 | } 50 | 51 | func (r *Report) HasScenarios() bool { 52 | return len(r.scenarios) > 0 53 | } 54 | 55 | func New(formatType string) *Report { 56 | reportFormatter := getFormatter(formatType) 57 | return &Report{ 58 | formatter: reportFormatter, 59 | AreAllTestsPassed: true, 60 | } 61 | } 62 | 63 | func getFormatter(formatType string) formatter { 64 | switch formatType { 65 | case "html": 66 | return htmlReportFormatter{} 67 | case "json": 68 | return jsonReportFormatter{} 69 | default: 70 | log.Printf("'%s' report format not supported\n", formatType) 71 | return disabledFormatter{} 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | name: Deploy Documentation 6 | 7 | on: 8 | workflow_call: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build_and_push: 13 | name: Build and Push Documentation Image 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Log in to GitHub Container Registry 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: ghcr.io/${{ github.repository }}/documentation 38 | tags: | 39 | type=ref,event=branch 40 | type=sha,format=long 41 | latest 42 | 43 | - name: Build and push Docker image 44 | uses: docker/build-push-action@v5 45 | with: 46 | context: . 47 | file: documentation.dockerfile 48 | push: true 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | platforms: linux/amd64,linux/arm64 52 | cache-from: type=gha 53 | cache-to: type=gha,mode=max 54 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/fill_field.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | ) 10 | 11 | func (steps) userEntersTextIntoField() stepbuilder.Step { 12 | formatLabel := func(label string) string { 13 | return stringutils.SuffixWithUnderscore(label, "field") 14 | } 15 | 16 | return stepbuilder.NewWithTwoVariables( 17 | []string{`the user enters {string} into the {string} field`}, 18 | func(ctx context.Context, text, inputLabel string) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | 21 | input, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(inputLabel)) 22 | if err != nil { 23 | return ctx, err 24 | } 25 | err = input.Input(text) 26 | return ctx, err 27 | }, 28 | func(_, inputLabel string) stepbuilder.ValidationErrors { 29 | vc := stepbuilder.ValidationErrors{} 30 | label := formatLabel(inputLabel) 31 | if !config.IsElementDefined(label) { 32 | vc.AddMissingElement(label) 33 | } 34 | 35 | return vc 36 | }, 37 | stepbuilder.DocParams{ 38 | Description: "Types the specified text into an input field identified by its logical name.", 39 | Variables: []stepbuilder.DocVariable{ 40 | {Name: "text", Description: "The text to type.", Type: stepbuilder.VarTypeString}, 41 | {Name: "name", Description: "The logical name of the input field.", Type: stepbuilder.VarTypeString}, 42 | }, 43 | Example: `When the user enters "myUsername" into the "Username" field`, 44 | Category: stepbuilder.Form, 45 | }, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/reporters/html_report.formatter.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import ( 4 | _ "embed" 5 | "html/template" 6 | "log" 7 | "os" 8 | "testflowkit/internal/utils/fileutils" 9 | "testflowkit/pkg" 10 | "testflowkit/pkg/logger" 11 | ) 12 | 13 | //go:embed html_report.template.html 14 | var reportTemplate string 15 | 16 | type htmlReportFormatter struct{} 17 | 18 | func (r htmlReportFormatter) format(ts htmlTestSuiteDetails) string { 19 | tmpl, err := template.New("report").Parse(reportTemplate) 20 | if err != nil { 21 | logger.Fatal("cannot parse report template", err) 22 | } 23 | 24 | wr := pkg.TextWriter{} 25 | err = tmpl.Execute(&wr, ts) 26 | if err != nil { 27 | logger.Fatal("cannot execute template", err) 28 | } 29 | 30 | return wr.String() 31 | } 32 | 33 | func (r htmlReportFormatter) WriteReport(details testSuiteDetails) { 34 | content := r.formatContent(details) 35 | 36 | if err := os.MkdirAll("report", fileutils.DirPermission); err != nil { 37 | log.Panicf("cannot create report directory ( %s )\n", err) 38 | } 39 | 40 | file, err := os.Create("report/report.html") 41 | if err != nil { 42 | log.Panicf("cannot create reporters file in this folder ( %s )\n", err) 43 | } 44 | defer file.Close() 45 | 46 | _, err = file.WriteString(content) 47 | if err != nil { 48 | log.Panicf("error when reporters filling ( %s )", err) 49 | } 50 | } 51 | 52 | func (r htmlReportFormatter) formatContent(details testSuiteDetails) string { 53 | htmlScenarios := make([]htmlScenario, len(details.Scenarios)) 54 | for i, sc := range details.Scenarios { 55 | htmlScenarios[i] = *newhtmlScenario(sc) 56 | } 57 | 58 | return r.format(htmlTestSuiteDetails{ 59 | testSuiteDetails: details, 60 | Scenarios: htmlScenarios, 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/commonbackendsteps/prepare_request.go: -------------------------------------------------------------------------------- 1 | package commonbackendsteps 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "testflowkit/internal/step_definitions/api/protocol" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | "testflowkit/pkg/logger" 11 | ) 12 | 13 | func (steps) prepareRequest() stepbuilder.Step { 14 | return stepbuilder.NewWithTwoVariables( 15 | []string{ 16 | `I prepare a (?i)(graphql|rest) request to {string}`, 17 | }, 18 | func(ctx context.Context, protocolType string, name string) (context.Context, error) { 19 | var adapter protocol.APIProtocol 20 | switch strings.ToLower(protocolType) { 21 | case "graphql": 22 | adapter = protocol.NewGraphQLAdapter() 23 | case "rest": 24 | adapter = protocol.NewRESTAPIAdapter() 25 | default: 26 | return ctx, fmt.Errorf("unsupported protocol type: %s", protocolType) 27 | } 28 | 29 | // Prepare the request using the protocol adapter 30 | ctx, err := adapter.PrepareRequest(ctx, name) 31 | if err != nil { 32 | return ctx, fmt.Errorf("failed to prepare request: %w", err) 33 | } 34 | 35 | logger.InfoFf("Request prepared: %s - %s", adapter.GetProtocolName(), name) 36 | return ctx, nil 37 | }, 38 | nil, 39 | stepbuilder.DocParams{ 40 | Description: "Prepares a request", 41 | Variables: []stepbuilder.DocVariable{ 42 | {Name: "protocolType", Description: "The API protocol type (graphql or REST)", Type: stepbuilder.VarTypeString}, 43 | {Name: "name", Description: "The name of the operation or endpoint", Type: stepbuilder.VarTypeString}, 44 | }, 45 | Example: `Given I prepare a REST request to "getUser"`, 46 | Category: stepbuilder.Backend, 47 | }, 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/select_radio_button.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | "testflowkit/pkg/logger" 10 | ) 11 | 12 | func (steps) selectRadioButton() stepbuilder.Step { 13 | formatLabel := func(label string) string { 14 | return stringutils.SuffixWithUnderscore(label, "radio_button") 15 | } 16 | 17 | return stepbuilder.NewWithOneVariable( 18 | []string{`the user selects the {string} radio button`}, 19 | func(ctx context.Context, radioName string) (context.Context, error) { 20 | scenarioCtx := scenario.MustFromContext(ctx) 21 | radio, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(radioName)) 22 | if err != nil { 23 | return ctx, err 24 | } 25 | 26 | if radio.IsChecked() { 27 | logger.Warn("Radio button already selected", []string{}) 28 | return ctx, nil 29 | } 30 | 31 | err = radio.Click() 32 | return ctx, err 33 | }, 34 | func(radioName string) stepbuilder.ValidationErrors { 35 | vc := stepbuilder.ValidationErrors{} 36 | label := formatLabel(radioName) 37 | if !config.IsElementDefined(label) { 38 | vc.AddMissingElement(label) 39 | } 40 | return vc 41 | }, 42 | stepbuilder.DocParams{ 43 | Description: "Selects a radio button by its logical name.", 44 | Variables: []stepbuilder.DocVariable{ 45 | {Name: "name", Description: "The logical name of the radio button.", Type: stepbuilder.VarTypeString}, 46 | }, 47 | Example: `When the user selects the "Gender Male" radio button`, 48 | Category: stepbuilder.Form, 49 | }, 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/should_see_on_page_x_elements.go: -------------------------------------------------------------------------------- 1 | package visual 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testflowkit/internal/browser" 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | ) 11 | 12 | func (steps) shouldSeeOnPageXElements() stepbuilder.Step { 13 | return stepbuilder.NewWithTwoVariables( 14 | []string{`the user should see {number} {string} elements on the page`}, 15 | func(ctx context.Context, expectedCount, elementName string) (context.Context, error) { 16 | expectedCountInt, err := strconv.Atoi(expectedCount) 17 | if err != nil { 18 | return ctx, fmt.Errorf("invalid expected count: %s", expectedCount) 19 | } 20 | 21 | scenarioCtx := scenario.MustFromContext(ctx) 22 | currentPage, pageName, err := scenarioCtx.GetCurrentPage() 23 | if err != nil { 24 | return ctx, err 25 | } 26 | elementCount := browser.GetElementCount(currentPage, pageName, elementName) 27 | if elementCount != expectedCountInt { 28 | return ctx, fmt.Errorf("%d %s expected but %d %s found", expectedCountInt, elementName, elementCount, elementName) 29 | } 30 | return ctx, nil 31 | }, 32 | nil, 33 | stepbuilder.DocParams{ 34 | Description: "checks if a specific number of elements are visible on the page.", 35 | Variables: []stepbuilder.DocVariable{ 36 | {Name: "expectedCount", Description: "The expected number of elements.", Type: stepbuilder.VarTypeInt}, 37 | {Name: "elementName", Description: "The logical name of the element to check.", Type: stepbuilder.VarTypeString}, 38 | }, 39 | Example: "Then the user should see 3 buttons elements on the page", 40 | Category: stepbuilder.Visual, 41 | }, 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/keyboard/press_button.go: -------------------------------------------------------------------------------- 1 | package keyboard 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | "testflowkit/pkg/browser" 10 | ) 11 | 12 | func (k keyboardSteps) userPressButton() stepbuilder.Step { 13 | dic := map[string]browser.Key{ 14 | "Enter": browser.KeyEnter, 15 | "Tab": browser.KeyTab, 16 | "Delete": browser.KeyDelete, 17 | "Escape": browser.KeyEscape, 18 | "Space": browser.KeySpace, 19 | "Arrow Up": browser.KeyArrowUp, 20 | "Arrow Right": browser.KeyArrowRight, 21 | "Arrow Down": browser.KeyArrowDown, 22 | "Arrow Left": browser.KeyArrowLeft, 23 | } 24 | 25 | var supportedKeys []string 26 | for key := range dic { 27 | supportedKeys = append(supportedKeys, key) 28 | } 29 | 30 | return stepbuilder.NewWithOneVariable( 31 | []string{fmt.Sprintf(`the user presses the "(%s)" key`, strings.Join(supportedKeys, "|"))}, 32 | func(ctx context.Context, key string) (context.Context, error) { 33 | scenarioCtx := scenario.MustFromContext(ctx) 34 | inputKey, ok := dic[key] 35 | if !ok { 36 | return ctx, fmt.Errorf("%s key not recognized", key) 37 | } 38 | 39 | return ctx, scenarioCtx.GetCurrentPageKeyboard().Press(inputKey) 40 | }, 41 | nil, 42 | stepbuilder.DocParams{ 43 | Description: "Simulates pressing a specific keyboard key (e.g., \"Enter\", \"Tab\", \"Escape\").", 44 | Variables: []stepbuilder.DocVariable{ 45 | {Name: "key", Description: "The button to press.", Type: stepbuilder.VarTypeEnum(supportedKeys...)}, 46 | }, 47 | Example: "When the user presses the \"Enter\" key", 48 | Category: stepbuilder.Keyboard, 49 | }, 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/should_see_on_page_an_element_with_text.go: -------------------------------------------------------------------------------- 1 | package visual 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | ) 9 | 10 | func (steps) shouldSeeElementWhichContains() stepbuilder.Step { 11 | return stepbuilder.NewWithTwoVariables( 12 | []string{`the user should see a (link|button|element) which contains "{string}"`}, 13 | func(ctx context.Context, elementLabel, text string) (context.Context, error) { 14 | scenarioCtx := scenario.MustFromContext(ctx) 15 | cases := map[string]string{ 16 | "link": "a", 17 | "button": "button", 18 | "element": "*", 19 | } 20 | 21 | xPath := fmt.Sprintf("//%s[contains(text(),\"%s\")]", cases[elementLabel], text) 22 | page, errPage := scenarioCtx.GetCurrentPageOnly() 23 | if errPage != nil { 24 | return ctx, errPage 25 | } 26 | 27 | elt, err := page.GetOneByXPath(xPath) 28 | cErr := fmt.Errorf("no %s is visible with text \"%s\"", elementLabel, text) 29 | if err != nil { 30 | return ctx, cErr 31 | } 32 | 33 | if !elt.IsVisible() { 34 | return ctx, cErr 35 | } 36 | 37 | return ctx, nil 38 | }, 39 | nil, 40 | stepbuilder.DocParams{ 41 | Description: "checks if a link, button or element is visible and contains a specific text.", 42 | Variables: []stepbuilder.DocVariable{ 43 | {Name: "name", Description: "The logical name of the element to check.", Type: stepbuilder.VarTypeString}, 44 | {Name: "text", Description: "The text that the element should contain.", Type: stepbuilder.VarTypeString}, 45 | }, 46 | Example: "Then the user should see a button which contains \"Submit\"", 47 | Category: stepbuilder.Visual, 48 | }, 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/uncheck_checkbox.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | "testflowkit/pkg/logger" 10 | ) 11 | 12 | func (steps) uncheckCheckbox() stepbuilder.Step { 13 | formatLabel := func(label string) string { 14 | return stringutils.SuffixWithUnderscore(label, "checkbox") 15 | } 16 | 17 | return stepbuilder.NewWithOneVariable( 18 | []string{`the user unchecks the {string} checkbox`}, 19 | func(ctx context.Context, checkBoxName string) (context.Context, error) { 20 | scenarioCtx := scenario.MustFromContext(ctx) 21 | checkBox, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(checkBoxName)) 22 | if err != nil { 23 | return ctx, err 24 | } 25 | 26 | if checkBox.IsChecked() { 27 | err = checkBox.Click() 28 | return ctx, err 29 | } 30 | 31 | logger.Warn(checkBoxName+" checkbox is not unchecked because it is already unchecked", []string{}) 32 | return ctx, nil 33 | }, 34 | func(checkBoxName string) stepbuilder.ValidationErrors { 35 | vc := stepbuilder.ValidationErrors{} 36 | label := formatLabel(checkBoxName) 37 | if !config.IsElementDefined(label) { 38 | vc.AddMissingElement(label) 39 | } 40 | return vc 41 | }, 42 | stepbuilder.DocParams{ 43 | Description: "unchecks a checkbox if it is currently checked.", 44 | Variables: []stepbuilder.DocVariable{ 45 | {Name: "checkBoxName", Description: "The name of the checkbox to uncheck.", Type: stepbuilder.VarTypeString}, 46 | }, 47 | Example: "When the user unchecks the \"Newsletter\" checkbox", 48 | Category: stepbuilder.Form, 49 | }, 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /documentation/components/SentenceDefinitionCard.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 41 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/the_field_should_contains.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testflowkit/internal/config" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | ) 10 | 11 | func (steps) theFieldShouldContain() stepbuilder.Step { 12 | formatFieldID := func(fieldId string) string { 13 | return fieldId + "_field" 14 | } 15 | 16 | return stepbuilder.NewWithTwoVariables( 17 | []string{`the value of the {string} field should be {string}`}, 18 | func(ctx context.Context, fieldId, text string) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | input, err := scenarioCtx.GetHTMLElementByLabel(formatFieldID(fieldId)) 21 | if err != nil { 22 | return ctx, err 23 | } 24 | 25 | if input.TextContent() == text { 26 | return ctx, nil 27 | } 28 | 29 | return ctx, fmt.Errorf(`field should be contains "%s" but contains "%s"`, text, input.TextContent()) 30 | }, 31 | func(fieldId, _ string) stepbuilder.ValidationErrors { 32 | vc := stepbuilder.ValidationErrors{} 33 | if !config.IsElementDefined(formatFieldID(fieldId)) { 34 | vc.AddMissingElement(formatFieldID(fieldId)) 35 | } 36 | 37 | return vc 38 | }, 39 | stepbuilder.DocParams{ 40 | Description: "This assertion checks if the current value of an input field matches the specified value.", 41 | Variables: []stepbuilder.DocVariable{ 42 | {Name: "fieldId", Description: "The id of the field.", Type: stepbuilder.VarTypeString}, 43 | {Name: "text", Description: "The text to check.", Type: stepbuilder.VarTypeString}, 44 | }, 45 | Example: `Then the value of the "Username" field should be "myUsername".`, 46 | Category: stepbuilder.Form, 47 | }, 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_contains_text.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/config" 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | ) 11 | 12 | func (steps) elementShouldContainsText() stepbuilder.Step { 13 | return stepbuilder.NewWithTwoVariables( 14 | []string{`the {string} should contain the text {string}`}, 15 | func(ctx context.Context, name, expectedText string) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | element, err := scenarioCtx.GetHTMLElementByLabel(name) 18 | if err != nil { 19 | return ctx, err 20 | } 21 | 22 | if !element.IsVisible() { 23 | return ctx, fmt.Errorf("%s is not visible", name) 24 | } 25 | 26 | if !strings.Contains(element.TextContent(), expectedText) { 27 | return ctx, fmt.Errorf("%s does not contain text '%s'", name, expectedText) 28 | } 29 | 30 | return ctx, nil 31 | }, 32 | func(name, _ string) stepbuilder.ValidationErrors { 33 | vc := stepbuilder.ValidationErrors{} 34 | if !config.IsElementDefined(name) { 35 | vc.AddMissingElement(name) 36 | } 37 | 38 | return vc 39 | }, 40 | stepbuilder.DocParams{ 41 | Description: "This assertion checks if the element's visible text includes the specified substring.", 42 | Variables: []stepbuilder.DocVariable{ 43 | {Name: "name", Description: "The logical name of the element to check.", Type: stepbuilder.VarTypeString}, 44 | {Name: "expectedText", Description: "The text that should be contained.", Type: stepbuilder.VarTypeString}, 45 | }, 46 | Example: `Then the welcome card should contain the text "Hello John"`, 47 | Category: stepbuilder.Visual, 48 | }, 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_contains_exact_text.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/config" 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | ) 11 | 12 | func (steps) elementShouldContainsExactText() stepbuilder.Step { 13 | return stepbuilder.NewWithTwoVariables( 14 | []string{`the text of the {string} element should be exactly {string}`}, 15 | func(ctx context.Context, name, expectedText string) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | element, err := scenarioCtx.GetHTMLElementByLabel(name) 18 | if err != nil { 19 | return ctx, err 20 | } 21 | 22 | actualText := element.TextContent() 23 | if strings.TrimSpace(actualText) != expectedText { 24 | return ctx, fmt.Errorf("element %s contains '%s' but expected '%s'", name, actualText, expectedText) 25 | } 26 | 27 | return ctx, nil 28 | }, 29 | func(name, _ string) stepbuilder.ValidationErrors { 30 | vc := stepbuilder.ValidationErrors{} 31 | if !config.IsElementDefined(name) { 32 | vc.AddMissingElement(name) 33 | } 34 | 35 | return vc 36 | }, 37 | stepbuilder.DocParams{ 38 | Description: "This assertion checks if the element's visible text is an exact match to the specified string.", 39 | Variables: []stepbuilder.DocVariable{ 40 | {Name: "name", Description: "The logical name of the element to check.", Type: stepbuilder.VarTypeString}, 41 | {Name: "expectedText", Description: "The exact text that should be contained.", Type: stepbuilder.VarTypeString}, 42 | }, 43 | Example: `Then the text of the "Welcome Message" element should be exactly "Hello John".`, 44 | Category: stepbuilder.Visual, 45 | }, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /internal/step_definitions/core/scenario/backend_context_types.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | import ( 4 | "context" 5 | "testflowkit/pkg/graphql" 6 | "testflowkit/pkg/variables" 7 | ) 8 | 9 | // APIProtocol defines the interface for different API protocol implementations 10 | // This is defined here to avoid import cycles with the protocol package. 11 | type APIProtocol interface { 12 | PrepareRequest(ctx context.Context, name string) (context.Context, error) 13 | 14 | // SendRequest executes the prepared request and stores the response 15 | SendRequest(ctx context.Context) (context.Context, error) 16 | 17 | // GetResponseBody returns the raw response body as bytes 18 | GetResponseBody(ctx context.Context) ([]byte, error) 19 | 20 | GetStatusCode(ctx context.Context) (int, error) 21 | 22 | HasErrors(ctx context.Context) bool 23 | 24 | GetProtocolName() string 25 | } 26 | 27 | // BackendContext is the unified context for both GraphQL and REST API testing. 28 | type BackendContext struct { 29 | // Shared fields 30 | Headers map[string]string 31 | Variables map[string]any 32 | Response *UnifiedResponse 33 | Protocol APIProtocol 34 | parser *variables.Parser 35 | 36 | // REST-specific fields (used when Protocol is RESTAPIAdapter) 37 | Endpoint *EndpointEnricher 38 | RequestBody []byte 39 | 40 | // GraphQL-specific fields (used when Protocol is GraphQLAdapter) 41 | GraphQLRequest *graphql.Request 42 | } 43 | 44 | type UnifiedResponse struct { 45 | StatusCode int 46 | Body []byte 47 | Headers map[string]string 48 | GraphQLErrors []graphql.Error 49 | } 50 | 51 | func NewBackendContext() *BackendContext { 52 | ctx := &BackendContext{ 53 | Headers: make(map[string]string), 54 | Variables: make(map[string]any), 55 | Response: nil, 56 | } 57 | ctx.parser = variables.NewParser(ctx) 58 | return ctx 59 | } 60 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/select_option_with_text_into_dropdown.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | ) 10 | 11 | func (steps) selectOptionWithTextIntoDropdown() stepbuilder.Step { 12 | formatLabel := func(label string) string { 13 | return stringutils.SuffixWithUnderscore(label, "dropdown") 14 | } 15 | 16 | return stepbuilder.NewWithTwoVariables( 17 | []string{`the user selects the option with text {string} from the {string} dropdown`}, 18 | func(ctx context.Context, optionText, dropdownName string) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | dropdown, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(dropdownName)) 21 | if err != nil { 22 | return ctx, err 23 | } 24 | 25 | err = dropdown.SelectByText([]string{optionText}) 26 | return ctx, err 27 | }, 28 | func(_, dropdownName string) stepbuilder.ValidationErrors { 29 | vc := stepbuilder.ValidationErrors{} 30 | label := formatLabel(dropdownName) 31 | if !config.IsElementDefined(label) { 32 | vc.AddMissingElement(label) 33 | } 34 | 35 | return vc 36 | }, 37 | stepbuilder.DocParams{ 38 | Description: "selects an option from a dropdown by its text.", 39 | Variables: []stepbuilder.DocVariable{ 40 | {Name: "text", Description: "The text of the option to select.", Type: stepbuilder.VarTypeString}, 41 | {Name: "name", Description: "The logical name of the dropdown.", Type: stepbuilder.VarTypeString}, 42 | }, 43 | Example: "When the user selects the option with text \"United States\" from the \"Country\" dropdown", 44 | Category: stepbuilder.Form, 45 | }, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/changelog", 6 | [ 7 | "@semantic-release/release-notes-generator", 8 | { 9 | "preset": "conventionalcommits", 10 | "presetConfig": { 11 | "types": [ 12 | { 13 | "type": "feat", 14 | "section": ":sparkles: Features", 15 | "hidden": false 16 | }, 17 | { 18 | "type": "fix", 19 | "section": ":bug: Fixes", 20 | "hidden": false 21 | }, 22 | { 23 | "type": "docs", 24 | "section": ":memo: Documentation", 25 | "hidden": false 26 | }, 27 | { 28 | "type": "style", 29 | "section": ":barber: Code-style", 30 | "hidden": false 31 | }, 32 | { 33 | "type": "refactor", 34 | "section": ":zap: Refactor", 35 | "hidden": false 36 | }, 37 | { 38 | "type": "perf", 39 | "section": ":fast_forward: Performance", 40 | "hidden": false 41 | }, 42 | { 43 | "type": "test", 44 | "section": ":white_check_mark: Tests", 45 | "hidden": false 46 | }, 47 | { 48 | "type": "ci", 49 | "section": ":repeat: CI", 50 | "hidden": false 51 | }, 52 | { 53 | "type": "chore", 54 | "section": ":repeat: Chore", 55 | "hidden": false 56 | } 57 | ] 58 | } 59 | } 60 | ], 61 | "@semantic-release/github" 62 | ], 63 | "tagFormat": "${version}" 64 | } 65 | -------------------------------------------------------------------------------- /pkg/reporters/json_report_formatter.go: -------------------------------------------------------------------------------- 1 | package reporters 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | "testflowkit/internal/utils/fileutils" 8 | ) 9 | 10 | type jsonReportFormatter struct{} 11 | 12 | func (f jsonReportFormatter) WriteReport(details testSuiteDetails) { 13 | scenariosReports := make([]jsonScenarioReport, len(details.Scenarios)) 14 | for i, sc := range details.Scenarios { 15 | scenariosReports[i] = jsonScenarioReport{ 16 | Title: sc.Title, 17 | Duration: sc.Duration.String(), 18 | Result: string(sc.Result), 19 | Steps: make([]jsonScenarioStepReport, len(sc.Steps)), 20 | ErrorMessage: sc.ErrorMsg, 21 | } 22 | 23 | for j, step := range sc.Steps { 24 | scenariosReports[i].Steps[j] = jsonScenarioStepReport{ 25 | Title: step.Title, 26 | Status: step.Status, 27 | Duration: step.Duration.String(), 28 | ScreenshotPath: step.ScreenshotPath, 29 | } 30 | } 31 | } 32 | 33 | report := jsonReport{ 34 | Scenarios: scenariosReports, 35 | StartDate: details.ExecutionDate, 36 | Duration: details.TotalExecutionTime, 37 | } 38 | 39 | jsonData, err := json.MarshalIndent(report, "", " ") 40 | if err != nil { 41 | log.Printf("Erreur lors de la sérialisation en JSON : %v\n", err) 42 | return 43 | } 44 | 45 | if mkdirErr := os.MkdirAll("report", fileutils.DirPermission); mkdirErr != nil { 46 | log.Panicf("cannot create report directory ( %s )\n", mkdirErr) 47 | } 48 | 49 | file, reportCreationErr := os.Create("report/report.json") 50 | if reportCreationErr != nil { 51 | log.Panicf("cannot create reporters file in this folder ( %s )\n", reportCreationErr) 52 | } 53 | defer file.Close() 54 | 55 | _, jsonWriteErr := file.Write(jsonData) 56 | if jsonWriteErr != nil { 57 | log.Panicf("error when reporters filling ( %s )", jsonWriteErr) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/element_should_not_contains_text.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testflowkit/internal/config" 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | ) 11 | 12 | func (steps) elementShouldNotContainsText() stepbuilder.Step { 13 | return stepbuilder.NewWithTwoVariables( 14 | []string{`the {string} should not contain the text {string}`}, 15 | func(ctx context.Context, name, unexpectedText string) (context.Context, error) { 16 | scenarioCtx := scenario.MustFromContext(ctx) 17 | element, err := scenarioCtx.GetHTMLElementByLabel(name) 18 | if err != nil { 19 | return ctx, err 20 | } 21 | 22 | if !element.IsVisible() { 23 | return ctx, fmt.Errorf("%s is not visible", name) 24 | } 25 | 26 | if strings.Contains(element.TextContent(), unexpectedText) { 27 | return ctx, fmt.Errorf("%s unexpectedly contains text '%s'", name, unexpectedText) 28 | } 29 | 30 | return ctx, nil 31 | }, 32 | func(name, _ string) stepbuilder.ValidationErrors { 33 | vc := stepbuilder.ValidationErrors{} 34 | if !config.IsElementDefined(name) { 35 | vc.AddMissingElement(name) 36 | } 37 | 38 | return vc 39 | }, 40 | stepbuilder.DocParams{ 41 | Description: "This assertion checks if the element's visible text does not include the specified substring.", 42 | Variables: []stepbuilder.DocVariable{ 43 | {Name: "name", Description: "The logical name of the element to check.", Type: stepbuilder.VarTypeString}, 44 | {Name: "unexpectedText", Description: "The text that should not be contained.", Type: stepbuilder.VarTypeString}, 45 | }, 46 | Example: `Then the "Welcome Message" element should not contain the text "Hello John"`, 47 | Category: stepbuilder.Visual, 48 | }, 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/select_multiple_options_by_text_into_dropdown.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "testflowkit/internal/config" 6 | "testflowkit/internal/step_definitions/core/scenario" 7 | "testflowkit/internal/step_definitions/core/stepbuilder" 8 | "testflowkit/internal/utils/stringutils" 9 | ) 10 | 11 | func (steps) selectMultipleOptionsByTextIntoDropdown() stepbuilder.Step { 12 | formatLabel := func(label string) string { 13 | return stringutils.SuffixWithUnderscore(label, "dropdown") 14 | } 15 | 16 | return stepbuilder.NewWithTwoVariables( 17 | []string{`the user selects the options with text {string} from the {string} dropdown`}, 18 | func(ctx context.Context, optionLabels, dropdownId string) (context.Context, error) { 19 | scenarioCtx := scenario.MustFromContext(ctx) 20 | input, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(dropdownId)) 21 | if err != nil { 22 | return ctx, err 23 | } 24 | return ctx, input.SelectByText(stringutils.SplitAndTrim(optionLabels, ",")) 25 | }, 26 | func(_, dropdownName string) stepbuilder.ValidationErrors { 27 | vc := stepbuilder.ValidationErrors{} 28 | label := formatLabel(dropdownName) 29 | if !config.IsElementDefined(label) { 30 | vc.AddMissingElement(label) 31 | } 32 | 33 | return vc 34 | }, 35 | stepbuilder.DocParams{ 36 | Description: "selects multiple options from a dropdown by their text.", 37 | Variables: []stepbuilder.DocVariable{ 38 | {Name: "options", Description: "Comma-separated list of option texts to select.", Type: stepbuilder.VarTypeString}, 39 | {Name: "name", Description: "The logical name of the dropdown.", Type: stepbuilder.VarTypeString}, 40 | }, 41 | Example: `When the user selects the options with text "Konoha,Hidden Leaf Village" from the "Country" dropdown`, 42 | Category: stepbuilder.Form, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /internal/step_definitions/variables/store_response_json_path.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | "testflowkit/internal/step_definitions/helpers" 10 | "testflowkit/pkg/logger" 11 | ) 12 | 13 | func (steps) storeJSONPathIntoVariable() stepbuilder.Step { 14 | finalDescription := "The JSON path to extract the value from (e.g., 'data.user.id', 'items[0].name')" 15 | return stepbuilder.NewWithTwoVariables( 16 | []string{ 17 | `I store the JSON path {string} from the response into {string} variable`, 18 | }, 19 | func(ctx context.Context, jsonPath, varName string) (context.Context, error) { 20 | scenarioCtx := scenario.MustFromContext(ctx) 21 | 22 | response := scenarioCtx.GetResponse() 23 | if response == nil { 24 | return ctx, errors.New("no response available. Please send a request first") 25 | } 26 | 27 | value, err := helpers.GetJSONPathValue(response.Body, jsonPath) 28 | if err != nil { 29 | return ctx, fmt.Errorf("failed to extract JSON path '%s': %w", jsonPath, err) 30 | } 31 | 32 | scenarioCtx.SetVariable(varName, value) 33 | logger.InfoFf("Stored JSON path '%s' value '%v' into variable '%s'", jsonPath, value, varName) 34 | 35 | return ctx, nil 36 | }, 37 | nil, 38 | stepbuilder.DocParams{ 39 | Description: "Stores a value from a JSON response using a JSON path into a scenario variable.", 40 | Variables: []stepbuilder.DocVariable{ 41 | {Name: "jsonPath", Description: finalDescription, Type: stepbuilder.VarTypeString}, 42 | {Name: "varName", Description: "The name of the variable to store the value in", Type: stepbuilder.VarTypeString}, 43 | }, 44 | Example: `When I store the JSON path "data.user.id" from the response into "user_id" variable`, 45 | Category: stepbuilder.Variable, 46 | }, 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/visual/table/should_see_in_table_a_row_containing_the_following_elements.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "context" 5 | "maps" 6 | "slices" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | 10 | "github.com/cucumber/godog" 11 | "github.com/rdumont/assistdog" 12 | ) 13 | 14 | func (steps) shouldSeeRowContainingTheFollowingElements() stepbuilder.Step { 15 | example := ` 16 | When the user should see a row containing the following elements 17 | | Name | Age | 18 | | John | 30 | 19 | ` 20 | return stepbuilder.NewWithOneVariable( 21 | []string{`the user should see a row containing the following elements`}, 22 | func(ctx context.Context, table *godog.Table) (context.Context, error) { 23 | scenarioCtx := scenario.MustFromContext(ctx) 24 | data, err := assistdog.NewDefault().ParseSlice(table) 25 | if err != nil { 26 | return ctx, err 27 | } 28 | 29 | parsedData, err := scenario.ReplaceVariablesInArray(scenarioCtx, data) 30 | if err != nil { 31 | return ctx, err 32 | } 33 | 34 | currentPage, errPage := scenarioCtx.GetCurrentPageOnly() 35 | if errPage != nil { 36 | return ctx, errPage 37 | } 38 | 39 | for _, rowDetails := range parsedData { 40 | values := slices.Sorted(maps.Values(rowDetails)) 41 | _, getRowErr := getTableRowByCellsContent(currentPage, values) 42 | if getRowErr != nil { 43 | return ctx, getRowErr 44 | } 45 | } 46 | 47 | return ctx, nil 48 | }, 49 | nil, 50 | stepbuilder.DocParams{ 51 | Description: "checks if a row containing the following elements is visible in the table.", 52 | Variables: []stepbuilder.DocVariable{ 53 | {Name: "table", Description: "The table containing the elements to check.", Type: stepbuilder.VarTypeTable}, 54 | }, 55 | Example: example, 56 | Category: stepbuilder.Visual, 57 | }, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/assertions/checkbox_should_be_checked_or_unchecked.go: -------------------------------------------------------------------------------- 1 | package assertions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "testflowkit/internal/config" 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | sb "testflowkit/internal/step_definitions/core/stepbuilder" 10 | ) 11 | 12 | func (steps) checkCheckboxStatus() sb.Step { 13 | formatVar := func(label string) string { 14 | return label + "_checkbox" 15 | } 16 | definition := func(ctx context.Context, checkboxId, status string) (context.Context, error) { 17 | scenarioCtx := scenario.MustFromContext(ctx) 18 | input, err := scenarioCtx.GetHTMLElementByLabel(formatVar(checkboxId)) 19 | if err != nil { 20 | return ctx, err 21 | } 22 | checkValue, isBoolean := input.GetAttributeValue("checked", reflect.Bool).(bool) 23 | 24 | if isBoolean && checkValue && status == "checked" || !checkValue && status == "unchecked" { 25 | return ctx, nil 26 | } 27 | 28 | return ctx, fmt.Errorf("%s checkbox is not %s", checkboxId, status) 29 | } 30 | 31 | validator := func(checkboxId, _ string) sb.ValidationErrors { 32 | vc := sb.ValidationErrors{} 33 | checkboxLabel := formatVar(checkboxId) 34 | 35 | if !config.IsElementDefined(checkboxLabel) { 36 | vc.AddMissingElement(checkboxLabel) 37 | } 38 | 39 | return vc 40 | } 41 | 42 | return sb.NewWithTwoVariables( 43 | []string{`the {string} checkbox should be (checked|unchecked)`}, 44 | definition, 45 | validator, 46 | sb.DocParams{ 47 | Description: "checks if the checkbox is checked or unchecked.", 48 | Variables: []sb.DocVariable{ 49 | {Name: "checkboxId", Description: "The id of the checkbox.", Type: sb.VarTypeString}, 50 | {Name: "status", Description: "The status of the checkbox.", Type: sb.VarTypeEnum("checked", "unchecked")}, 51 | }, 52 | Example: `Then the "terms" checkbox should be checked`, 53 | Category: sb.Form, 54 | }, 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/form/select_option_by_index_into_dropdown.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testflowkit/internal/config" 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | "testflowkit/internal/utils/stringutils" 11 | ) 12 | 13 | func (steps) userSelectOptionByIndexIntoDropdown() stepbuilder.Step { 14 | formatLabel := func(label string) string { 15 | return stringutils.SuffixWithUnderscore(label, "dropdown") 16 | } 17 | 18 | return stepbuilder.NewWithTwoVariables( 19 | []string{`the user selects the option at index {number} from the {string} dropdown`}, 20 | func(ctx context.Context, index, dropdownId string) (context.Context, error) { 21 | indexInt, err := strconv.Atoi(index) 22 | if err != nil { 23 | return ctx, fmt.Errorf("invalid index: %s", index) 24 | } 25 | 26 | scenarioCtx := scenario.MustFromContext(ctx) 27 | input, err := scenarioCtx.GetHTMLElementByLabel(formatLabel(dropdownId)) 28 | if err != nil { 29 | return ctx, err 30 | } 31 | 32 | return ctx, input.SelectByIndex(indexInt) 33 | }, 34 | func(_, dropdownName string) stepbuilder.ValidationErrors { 35 | vc := stepbuilder.ValidationErrors{} 36 | label := formatLabel(dropdownName) 37 | if !config.IsElementDefined(label) { 38 | vc.AddMissingElement(label) 39 | } 40 | 41 | return vc 42 | }, 43 | stepbuilder.DocParams{ 44 | Description: "selects an option from a dropdown by its index.", 45 | Variables: []stepbuilder.DocVariable{ 46 | {Name: "index", Description: "The index of the option to select.", Type: stepbuilder.VarTypeInt}, 47 | {Name: "name", Description: "The logical name of the dropdown.", Type: stepbuilder.VarTypeString}, 48 | }, 49 | Example: `When the user selects the option at index 2 from the "Country" dropdown`, 50 | Category: stepbuilder.Form, 51 | }, 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /internal/step_definitions/frontend/navigation/wait_for_new_window.go: -------------------------------------------------------------------------------- 1 | package navigation 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testflowkit/internal/step_definitions/core/scenario" 8 | "testflowkit/internal/step_definitions/core/stepbuilder" 9 | "testflowkit/pkg/logger" 10 | "time" 11 | ) 12 | 13 | func (steps) switchToNewOpenedWindow() stepbuilder.Step { 14 | return stepbuilder.NewWithNoVariables( 15 | []string{"the user switches to the newly opened window"}, 16 | func(ctx context.Context) (context.Context, error) { 17 | scenarioCtx := scenario.MustFromContext(ctx) 18 | initialPageCount := len(scenarioCtx.GetPages()) 19 | logger.Info(fmt.Sprintf("Waiting for new window. Current window count: %d", initialPageCount)) 20 | 21 | // TODO: refactor in order to use the Wait function 22 | startTime := time.Now() 23 | const duration = 6 * time.Minute 24 | for { 25 | if time.Since(startTime) > duration { 26 | return ctx, errors.New("no new window detected") 27 | } 28 | 29 | currentPageCount := len(scenarioCtx.GetPages()) 30 | if currentPageCount > initialPageCount { 31 | logger.Info(fmt.Sprintf("New window detected! Page count increased from %d to %d", 32 | initialPageCount, currentPageCount)) 33 | 34 | pages := scenarioCtx.GetPages() 35 | // In Rod, the most recently opened page is typically the first in the pages list 36 | newPage := pages[0] 37 | if err := scenarioCtx.SetCurrentPage(newPage); err != nil { 38 | return ctx, fmt.Errorf("failed to set current page: %w", err) 39 | } 40 | 41 | return ctx, nil 42 | } 43 | 44 | const milliseconds = 100 45 | time.Sleep(milliseconds * time.Millisecond) 46 | } 47 | }, 48 | nil, 49 | stepbuilder.DocParams{ 50 | Description: "switches to the newly opened browser window.", 51 | Example: "When the user switches to the newly opened window", 52 | Category: stepbuilder.Navigation, 53 | }, 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /internal/step_definitions/backend/restapi/set_query_params.go: -------------------------------------------------------------------------------- 1 | package restapi 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "maps" 7 | 8 | "testflowkit/internal/step_definitions/core/scenario" 9 | "testflowkit/internal/step_definitions/core/stepbuilder" 10 | "testflowkit/pkg/logger" 11 | 12 | "github.com/cucumber/godog" 13 | "github.com/rdumont/assistdog" 14 | ) 15 | 16 | // setQueryParams sets URL query parameters for the REST request. 17 | func (steps) setQueryParams() stepbuilder.Step { 18 | return stepbuilder.NewWithOneVariable( 19 | []string{`I set the following query parameters:`}, 20 | func(ctx context.Context, paramsTable *godog.Table) (context.Context, error) { 21 | scenarioCtx := scenario.MustFromContext(ctx) 22 | backend := scenarioCtx.GetBackendContext() 23 | 24 | params, err := assistdog.NewDefault().ParseMap(paramsTable) 25 | if err != nil { 26 | return ctx, errors.New("failed to parse query parameters map: " + err.Error()) 27 | } 28 | 29 | // Store in endpoint enricher 30 | endpoint := backend.GetEndpoint() 31 | if endpoint == nil { 32 | return ctx, errors.New("no endpoint configured in backend context") 33 | } 34 | 35 | if endpoint.QueryParams == nil { 36 | endpoint.QueryParams = make(map[string]string) 37 | } 38 | maps.Copy(endpoint.QueryParams, params) 39 | 40 | logger.InfoFf("Query parameters set: %v", params) 41 | return ctx, nil 42 | }, 43 | nil, 44 | stepbuilder.DocParams{ 45 | Description: "Sets URL query parameters for the REST API request.", 46 | Variables: []stepbuilder.DocVariable{ 47 | { 48 | Name: "parameters", 49 | Description: "Table with parameter names and values", 50 | Type: stepbuilder.VarTypeTable, 51 | }, 52 | }, 53 | Example: `Given I set the following query parameters: 54 | | name | value | 55 | | page | 1 | 56 | | limit | 10 | 57 | | filter | {{category}} |`, 58 | Category: stepbuilder.RESTAPI, 59 | }, 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /internal/actions/common.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "strings" 5 | "testflowkit/internal/config" 6 | stepdefinitions "testflowkit/internal/step_definitions" 7 | "testflowkit/internal/step_definitions/core" 8 | "testflowkit/pkg/logger" 9 | ) 10 | 11 | func formatStep(sentence string) string { 12 | cleanedSentence := strings.TrimPrefix(sentence, "^") 13 | cleanedSentence = strings.TrimSuffix(cleanedSentence, "$") 14 | 15 | pattern := "^" + core.ConvertWildcards(cleanedSentence) + "$" 16 | return pattern 17 | } 18 | 19 | func displayConfigSummary(cfg *config.Config) { 20 | if cfg == nil { 21 | return 22 | } 23 | logger.InfoFf("Testflowkit version %s", cfg.GetVersion()) 24 | 25 | logger.Info("--- Configuration Summary ---") 26 | 27 | logger.InfoFf("Available Steps: %d", len(stepdefinitions.GetAll())) 28 | 29 | logger.InfoFf("Active Environment: %s", cfg.ActiveEnvironment) 30 | logger.InfoFf("Concurrency: %d", cfg.Settings.Concurrency) 31 | logger.InfoFf("Report Format: %s", cfg.Settings.ReportFormat) 32 | logger.InfoFf("Gherkin Location: %s", cfg.Settings.GherkinLocation) 33 | logger.InfoFf("Think Time: %v", cfg.Settings.ThinkTime) 34 | logger.InfoFf("Test Tags: %s", cfg.Settings.Tags) 35 | 36 | if cfg.IsFrontendDefined() { 37 | displayFrontSummary(cfg) 38 | } 39 | 40 | env, _ := cfg.GetCurrentEnvironment() 41 | logger.InfoFf("API Base URL: %s", env.APIBaseURL) 42 | logger.InfoFf("API Endpoints: %d endpoints", len(cfg.Backend.Endpoints)) 43 | 44 | logger.Info("--- Configuration Summary End ---\n") 45 | } 46 | 47 | func displayFrontSummary(conf *config.Config) { 48 | env, _ := conf.GetCurrentEnvironment() 49 | 50 | frontConf := conf.Frontend 51 | logger.InfoFf("Headless Mode: %t", frontConf.Headless) 52 | logger.InfoFf("Default Timeout: %dms", frontConf.DefaultTimeout) 53 | logger.InfoFf("Frontend Base URL: %s", env.FrontendBaseURL) 54 | logger.InfoFf("Screenshot on Failure: %t", frontConf.ScreenshotOnFailure) 55 | logger.InfoFf("Elements Configured: %d page groups", len(frontConf.Elements)) 56 | } 57 | --------------------------------------------------------------------------------