diff --git a/CHANGELOG.md b/CHANGELOG.md index 874efb1..7e029a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - 2026-02-13 + +### Changed +- Refactored browser configuration: `browsers` is now a map where keys are user-defined aliases and values specify the browser `name` and `args`. Scenarios reference browsers by alias, allowing multiple configurations of the same browser engine +- Added `defaultBrowsers` field to specify which browser aliases to use when a scenario doesn't define its own + ## [0.20.2] - 2026-02-12 ### Fixed @@ -146,6 +152,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release +[0.21.0]: https://github.com/gooddata/gooddata-neobackstop/compare/v0.20.2...v0.21.0 [0.20.2]: https://github.com/gooddata/gooddata-neobackstop/compare/v0.20.1...v0.20.2 [0.20.1]: https://github.com/gooddata/gooddata-neobackstop/compare/v0.20.0...v0.20.1 [0.20.0]: https://github.com/gooddata/gooddata-neobackstop/compare/v0.19.1...v0.20.0 diff --git a/README.md b/README.md index 5dee6f8..55c3b6c 100644 --- a/README.md +++ b/README.md @@ -126,15 +126,19 @@ func main() { // Convert to internal format internalScenarios := converters.ScenariosToInternal( - cfg.Browsers, cfg.Viewports, cfg.RetryCount, scenarios, + cfg.DefaultBrowsers, cfg.Viewports, cfg.RetryCount, scenarios, ) - // Install and run Playwright - browsers := make([]string, len(cfg.Browsers)) - for i, b := range cfg.Browsers { - browsers[i] = string(b) + // Grab unique browsers to install, from the browser alias map + browsers := map[string]interface{}{} + for _, b := range cfg.Browsers { + browsers[string(b.Name)] = nil } - playwright.Install(&playwright.RunOptions{Browsers: browsers}) + + // Install and run Playwright + playwright.Install(&playwright.RunOptions{ + Browsers: slices.Collect(maps.Keys(browsers)), + }) pw, _ := playwright.Run() // Set up worker pool for screenshots @@ -192,7 +196,7 @@ func main() { json.Unmarshal(scenariosBytes, &scenarios) internalScenarios := converters.ScenariosToInternal( - cfg.Browsers, cfg.Viewports, cfg.RetryCount, scenarios, + cfg.DefaultBrowsers, cfg.Viewports, cfg.RetryCount, scenarios, ) // Find a specific scenario to debug @@ -208,7 +212,7 @@ func main() { pw, _ := playwright.Run() browser, _ := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(false), // Show the browser! - Args: cfg.Args["chromium"], + Args: cfg.Browsers["chromium"].Args, }) context, _ := browser.NewContext(playwright.BrowserNewContextOptions{ @@ -226,8 +230,7 @@ func main() { }() // Run the screenshot job with debug mode enabled - screenshotter.Job("debug |", "./debug-output", debugScenario.Viewport.Label, - page, *debugScenario, results, true, "test", cfg) // true = debug mode + screenshotter.Job("debug |", "./debug-output", debugScenario.Viewport.Label, page, *debugScenario, results, true, "test", cfg) // true = debug mode browser.Close() pw.Stop() @@ -261,7 +264,54 @@ The main configuration file controls browser settings, viewports, output paths, ```json { "id": "my-visual-tests", - "browsers": ["chromium", "firefox"], + "browsers": { + "chromium": { + "name": "chromium", + "args": [ + "--disable-infobars", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-default-apps", + "--disable-dev-shm-usage", + "--disable-extensions", + "--disable-features=site-per-process", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-popup-blocking", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--disable-sync", + "--disable-translate", + "--metrics-recording-only", + "--no-first-run", + "--safebrowsing-disable-auto-update", + "--enable-automation", + "--disable-component-update", + "--disable-web-resource", + "--mute-audio", + "--no-sandbox", + "--disable-software-rasterizer", + "--disable-gpu", + "--disable-setuid-sandbox", + "--force-device-scale-factor=1" + ] + }, + "firefox": { + "name": "firefox", + "args": [ + "--disable-dev-shm-usage", + "--disable-extensions", + "--enable-automation", + "--mute-audio", + "--no-sandbox", + "--disable-gpu" + ] + } + }, + "defaultBrowsers": ["chromium", "firefox"], "viewports": [ { "label": "desktop", @@ -281,47 +331,6 @@ The main configuration file controls browser settings, viewports, output paths, "showSuccessfulTests": false }, "ciReportPath": "./output/ci-report", - "args": { - "chromium": [ - "--disable-infobars", - "--disable-background-networking", - "--disable-background-timer-throttling", - "--disable-backgrounding-occluded-windows", - "--disable-breakpad", - "--disable-client-side-phishing-detection", - "--disable-default-apps", - "--disable-dev-shm-usage", - "--disable-extensions", - "--disable-features=site-per-process", - "--disable-hang-monitor", - "--disable-ipc-flooding-protection", - "--disable-popup-blocking", - "--disable-prompt-on-repost", - "--disable-renderer-backgrounding", - "--disable-sync", - "--disable-translate", - "--metrics-recording-only", - "--no-first-run", - "--safebrowsing-disable-auto-update", - "--enable-automation", - "--disable-component-update", - "--disable-web-resource", - "--mute-audio", - "--no-sandbox", - "--disable-software-rasterizer", - "--disable-gpu", - "--disable-setuid-sandbox", - "--force-device-scale-factor=1" - ], - "firefox": [ - "--disable-dev-shm-usage", - "--disable-extensions", - "--enable-automation", - "--mute-audio", - "--no-sandbox", - "--disable-gpu" - ] - }, "asyncCaptureLimit": 2, "asyncCompareLimit": 6, "retryCount": 0 @@ -330,20 +339,51 @@ The main configuration file controls browser settings, viewports, output paths, #### Configuration Options -| Option | Type | Description | -|----------------------------------|------------|--------------------------------------------| -| `id` | string | Identifier for the test suite | -| `browsers` | string[] | Browsers to use: `"chromium"`, `"firefox"` | -| `viewports` | Viewport[] | List of viewport configurations | -| `bitmapsReferencePath` | string | Path to store reference screenshots | -| `bitmapsTestPath` | string | Path to store test screenshots | -| `htmlReport.path` | string | Path for HTML report output | -| `htmlReport.showSuccessfulTests` | boolean | Include passing tests in HTML report | -| `ciReportPath` | string | Path for CI JSON report | -| `args` | object | Browser-specific launch arguments | -| `asyncCaptureLimit` | number | Max concurrent screenshot captures | -| `asyncCompareLimit` | number | Max concurrent image comparisons | -| `retryCount` | number | Extra retries on mismatch in test mode | +| Option | Type | Description | +|----------------------------------|-----------------------------|----------------------------------------------------------------| +| `id` | string | Identifier for the test suite | +| `browsers` | map\ | Browser alias map (see [Browser Aliases](#browser-aliases)) | +| `defaultBrowsers` | string[] | Browser aliases to use when a scenario doesn't specify its own | +| `viewports` | Viewport[] | List of viewport configurations | +| `bitmapsReferencePath` | string | Path to store reference screenshots | +| `bitmapsTestPath` | string | Path to store test screenshots | +| `htmlReport.path` | string | Path for HTML report output | +| `htmlReport.showSuccessfulTests` | boolean | Include passing tests in HTML report | +| `ciReportPath` | string | Path for CI JSON report | +| `asyncCaptureLimit` | number | Max concurrent screenshot captures | +| `asyncCompareLimit` | number | Max concurrent image comparisons | +| `retryCount` | number | Extra retries on mismatch in test mode | + +#### Browser Aliases + +A browser alias is a named configuration that pairs a browser type (`"chromium"` or `"firefox"`) with a set of launch arguments. The key in the `browsers` map is the alias, and the value specifies the browser `name` and `args`. + +Aliases are used as prefixes in screenshot file names, so they **must be snake_case**. + +If you only need one configuration per browser type, the recommended convention is to use the browser name itself as the alias (e.g. `"chromium"` for a Chromium config, `"firefox"` for a Firefox config). + +When you need multiple configurations of the same browser engine (e.g. Chromium with different flags), use descriptive aliases: + +```json +{ + "browsers": { + "chromium_default": { + "name": "chromium", + "args": ["--no-sandbox", "--disable-gpu"] + }, + "chromium_hidpi": { + "name": "chromium", + "args": ["--no-sandbox", "--force-device-scale-factor=2"] + } + }, + "defaultBrowsers": ["chromium_default"] +} +``` + +| BrowserConfig Property | Type | Description | +|------------------------|----------|------------------------------------------------| +| `name` | string | Browser engine: `"chromium"` or `"firefox"` | +| `args` | string[] | Launch arguments passed to the browser | #### Viewport Configuration @@ -378,25 +418,25 @@ Defines the test scenarios - which pages to capture and how to interact with the #### Scenario Options -| Option | Type | Description | -|-----------------------|------------------|---------------------------------------------------| -| `id` | string | Unique identifier for the scenario | -| `label` | string | Human-readable label (used in reports) | -| `url` | string | URL to navigate to | -| `browsers` | string[] | Override global browsers for this scenario | -| `viewports` | Viewport[] | Override global viewports for this scenario | -| `readySelector` | string | CSS selector to wait for before capture | -| `reloadAfterReady` | boolean | Reload page after ready selector appears | -| `delay` | number \| object | Wait time after ready (see below) | -| `keyPressSelector` | object | Element to focus and key to press | -| `hoverSelector` | string | Single element to hover over | -| `hoverSelectors` | array | Multiple elements to hover in sequence | -| `clickSelector` | string | Single element to click | -| `clickSelectors` | array | Multiple elements to click in sequence | -| `postInteractionWait` | string \| number | Wait after interactions (selector or ms) | -| `scrollToSelector` | string | Element to scroll into view | -| `misMatchThreshold` | number | Allowed mismatch percentage (0-100) | -| `retryCount` | number | Extra retries for the scenario (overrides global) | +| Option | Type | Description | +|-----------------------|------------------|----------------------------------------------------------------| +| `id` | string | Unique identifier for the scenario | +| `label` | string | Human-readable label (used in reports) | +| `url` | string | URL to navigate to | +| `browsers` | string[] | Override `defaultBrowsers` for this scenario (browser aliases) | +| `viewports` | Viewport[] | Override global viewports for this scenario | +| `readySelector` | string | CSS selector to wait for before capture | +| `reloadAfterReady` | boolean | Reload page after ready selector appears | +| `delay` | number \| object | Wait time after ready (see below) | +| `keyPressSelector` | object | Element to focus and key to press | +| `hoverSelector` | string | Single element to hover over | +| `hoverSelectors` | array | Multiple elements to hover in sequence | +| `clickSelector` | string | Single element to click | +| `clickSelectors` | array | Multiple elements to click in sequence | +| `postInteractionWait` | string \| number | Wait after interactions (selector or ms) | +| `scrollToSelector` | string | Element to scroll into view | +| `misMatchThreshold` | number | Allowed mismatch percentage (0-100) | +| `retryCount` | number | Extra retries for the scenario (overrides global) | ## Scenario Examples @@ -435,7 +475,7 @@ Override global viewports for specific scenarios: ### With Browser Override -Run a scenario only on specific browsers: +Run a scenario only on specific browser aliases (must be defined in the config `browsers` map): ```json { diff --git a/VERSION b/VERSION index a158e5b..1db0ede 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.2 \ No newline at end of file +0.21.0 \ No newline at end of file diff --git a/config/types.go b/config/types.go index c394693..6ede764 100644 --- a/config/types.go +++ b/config/types.go @@ -10,16 +10,21 @@ type HtmlReportConfig struct { ShowSuccessfulTests bool `json:"showSuccessfulTests"` } +type BrowserSettings struct { + Name browser.Browser `json:"name"` + Args []string `json:"args"` +} + type Config struct { - Browsers []browser.Browser `json:"browsers"` - Viewports []viewport.Viewport `json:"viewports"` - BitmapsReferencePath string `json:"bitmapsReferencePath"` - BitmapsTestPath string `json:"bitmapsTestPath"` - HtmlReport HtmlReportConfig `json:"htmlReport"` - CiReportPath string `json:"ciReportPath"` - Args map[browser.Browser][]string `json:"args"` - AsyncCaptureLimit int `json:"asyncCaptureLimit"` - AsyncCompareLimit int `json:"asyncCompareLimit"` - RequireSameDimensions bool `json:"requireSameDimensions"` - RetryCount int `json:"retryCount"` + Browsers map[string]BrowserSettings `json:"browsers"` + DefaultBrowsers []string `json:"defaultBrowsers"` + Viewports []viewport.Viewport `json:"viewports"` + BitmapsReferencePath string `json:"bitmapsReferencePath"` + BitmapsTestPath string `json:"bitmapsTestPath"` + HtmlReport HtmlReportConfig `json:"htmlReport"` + CiReportPath string `json:"ciReportPath"` + AsyncCaptureLimit int `json:"asyncCaptureLimit"` + AsyncCompareLimit int `json:"asyncCompareLimit"` + RequireSameDimensions bool `json:"requireSameDimensions"` + RetryCount int `json:"retryCount"` } diff --git a/converters/converters.go b/converters/converters.go index 2c90d94..daaa85b 100644 --- a/converters/converters.go +++ b/converters/converters.go @@ -3,13 +3,12 @@ package converters import ( "sort" - "github.com/gooddata/gooddata-neobackstop/browser" "github.com/gooddata/gooddata-neobackstop/internals" "github.com/gooddata/gooddata-neobackstop/scenario" "github.com/gooddata/gooddata-neobackstop/viewport" ) -func scenarioToInternal(b browser.Browser, v viewport.Viewport, rc int, s scenario.Scenario) internals.Scenario { +func scenarioToInternal(b string, v viewport.Viewport, rc int, s scenario.Scenario) internals.Scenario { retryCount := rc if s.RetryCount != nil { retryCount = *s.RetryCount @@ -37,12 +36,13 @@ func scenarioToInternal(b browser.Browser, v viewport.Viewport, rc int, s scenar } } -func ScenariosToInternal(browsers []browser.Browser, viewports []viewport.Viewport, retryCount int, scenarios []scenario.Scenario) []internals.Scenario { - output := make([]internals.Scenario, 0) // we could pre-calculate this, but until we do multi-browser testing, it's not worth it +func ScenariosToInternal(defaultBrowsers []string, viewports []viewport.Viewport, retryCount int, scenarios []scenario.Scenario) []internals.Scenario { + output := make([]internals.Scenario, 0) // we could pre-calculate size of this, but until we do multi-browser testing, it's not worth it for _, s := range scenarios { + // if the scenario has a browsers config, we use that, otherwise, we use the defaultBrowsers value in the config file if s.Browsers == nil { - for _, b := range browsers { + for _, b := range defaultBrowsers { if s.Viewports == nil { for _, v := range viewports { output = append(output, scenarioToInternal(b, v, retryCount, s)) diff --git a/internals/types.go b/internals/types.go index 582a76b..6e754e3 100644 --- a/internals/types.go +++ b/internals/types.go @@ -1,7 +1,6 @@ package internals import ( - "github.com/gooddata/gooddata-neobackstop/browser" "github.com/gooddata/gooddata-neobackstop/scenario" "github.com/gooddata/gooddata-neobackstop/viewport" ) @@ -9,7 +8,7 @@ import ( // Scenario - An internal scenario type, properties in order of processing, constructed from scenario.Scenario and config.Config // this type is exposed in ci-report so needs json keys type Scenario struct { - Browser browser.Browser `json:"browser"` + Browser string `json:"browser"` Viewport viewport.Viewport `json:"viewport"` Id string `json:"id"` Label string `json:"label"` diff --git a/main.go b/main.go index de4408f..a086698 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "maps" "os" "slices" "strconv" @@ -80,21 +81,22 @@ func main() { numScenarios := len(scenarios) fmt.Println("Received", numScenarios, "scenarios") - // we use a slice of our Browser enums, which we need to convert to a slice of strings - browsers := make([]string, len(configuration.Browsers)) - for i, b := range configuration.Browsers { - browsers[i] = string(b) + // grab unique browsers to install, from the browser alias map + browsers := map[string]interface{}{} + for _, b := range configuration.Browsers { + // convert to string because playwright.Install requires a slice of strings + browsers[string(b.Name)] = nil } // download drivers if err = playwright.Install(&playwright.RunOptions{ - Browsers: browsers, + Browsers: slices.Collect(maps.Keys(browsers)), }); err != nil { log.Panicf("could not install playwright drivers: %v", err) } // build internal scenarios - internalScenarios := converters.ScenariosToInternal(configuration.Browsers, configuration.Viewports, configuration.RetryCount, scenarios) + internalScenarios := converters.ScenariosToInternal(configuration.DefaultBrowsers, configuration.Viewports, configuration.RetryCount, scenarios) numInternalScenarios := len(internalScenarios) fmt.Println("Generated", numInternalScenarios, "internal scenarios") diff --git a/scenario/types.go b/scenario/types.go index b502112..f76cdda 100644 --- a/scenario/types.go +++ b/scenario/types.go @@ -4,7 +4,6 @@ import ( "encoding/json" "time" - "github.com/gooddata/gooddata-neobackstop/browser" "github.com/gooddata/gooddata-neobackstop/state" "github.com/gooddata/gooddata-neobackstop/viewport" ) @@ -93,7 +92,7 @@ func (d *SelectorThenDelay) UnmarshalJSON(data []byte) error { // Scenario - properties in order of processing, scenarios.json must be an array of Scenario type Scenario struct { - Browsers []browser.Browser `json:"browsers"` + Browsers []string `json:"browsers"` Viewports []viewport.Viewport `json:"viewports"` Id string `json:"id"` Label string `json:"label"` diff --git a/screenshotter/worker.go b/screenshotter/worker.go index 10eb88d..9712235 100644 --- a/screenshotter/worker.go +++ b/screenshotter/worker.go @@ -17,6 +17,20 @@ type CurrentContext struct { ViewportHeight int } +type CurrentBrowser struct { + Alias string + Name string +} + +func getBrowserName(conf config.Config, browserAlias string) browser.Browser { + b, ok := conf.Browsers[browserAlias] + if !ok { + panic("Browser not found: " + browserAlias + ". This should not be possible.") + } + + return b.Name +} + func Run(saveDir string, pw *playwright.Playwright, conf config.Config, jobs chan internals.Scenario, wg *sync.WaitGroup, results chan Result, id int, mode string) { defer wg.Done() @@ -24,7 +38,7 @@ func Run(saveDir string, pw *playwright.Playwright, conf config.Config, jobs cha fmt.Println(logPrefix, "launching browser") - var currentBrowser *browser.Browser + var currentBrowser *string var b playwright.Browser var currentContext *CurrentContext @@ -50,18 +64,19 @@ func Run(saveDir string, pw *playwright.Playwright, conf config.Config, jobs cha // if no browser is launched, launch one if currentBrowser == nil { var err error // init here so we can directly assign to `b` - if job.Browser == browser.Chromium { + browserConfig := conf.Browsers[job.Browser] + if browserConfig.Name == browser.Chromium { b, err = pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(true), - Args: conf.Args[browser.Chromium], + Args: browserConfig.Args, }) if err != nil { log.Panicf("could not launch browser: %v", err) } - } else if job.Browser == browser.Firefox { + } else if browserConfig.Name == browser.Firefox { b, err = pw.Firefox.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(true), - Args: conf.Args[browser.Firefox], + Args: browserConfig.Args, }) if err != nil { log.Panicf("could not launch browser: %v", err)