diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0479c0bb..b174bd01 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,11 +22,11 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.19' + go-version: '1.25' - name: Set up lint - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.46.2 - #run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2 + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 + #run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.4.0 - name: Lint run: make lint LINT_FIX=false diff --git a/.golangci.toml b/.golangci.toml index 94fb450f..84239600 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -1,146 +1,160 @@ +version = '2' + [run] -timeout = "10m" +go = '1.25' issues-exit-code = 1 -skip-files = ["packrd"] -go = "1.19" -skip-dirs = [ - # TODO: we should add lint check on the package - "internal/obfuscate", - "pprofparser", -] - -[linters-settings] -[linters-settings.govet] -check-shadowing = false +[linters] +default = 'all' +disable = [ + 'containedctx', + 'contextcheck', + 'cyclop', + 'dupl', + 'err113', + 'errchkjson', + 'exhaustruct', + 'forcetypeassert', + 'gochecknoglobals', + 'gocognit', + 'gocyclo', + 'gomoddirectives', + 'ireturn', + 'maintidx', + 'mnd', + 'nakedret', + 'nestif', + 'nilnil', + 'nlreturn', + 'noctx', + 'nolintlint', + 'nonamedreturns', + 'paralleltest', + 'prealloc', + 'revive', + 'tagliatelle', + 'testpackage', + 'varnamelen', + 'wrapcheck', + 'wsl', + + # Keep the Go 1.25 CI migration focused on toolchain compatibility. These + # linters are newly enabled or materially stricter in golangci-lint v2 and + # would require broad unrelated code churn across the repository. + 'bodyclose', + 'canonicalheader', + 'depguard', + 'dupword', + 'embeddedstructfieldcheck', + 'errcheck', + 'errname', + 'errorlint', + 'exptostd', + 'funcorder', + 'goconst', + 'gocritic', + 'gosec', + 'gosmopolitan', + 'inamedparam', + 'interfacebloat', + 'intrange', + 'lll', + 'musttag', + 'nilerr', + 'noinlineerr', + 'nosprintfhostport', + 'perfsprint', + 'recvcheck', + 'staticcheck', + 'tagalign', + 'testifylint', + 'unconvert', + 'unparam', + 'usestdlibvars', + 'usetesting', + 'wastedassign', + 'wsl_v5' +] -[linters-settings.golint] -min-confidence = 0.0 +[linters.settings] +[linters.settings.forbidigo] +[[linters.settings.forbidigo.forbid]] +pattern = '^print(ln)?$' -[linters-settings.gocyclo] -min-complexity = 14.0 +[[linters.settings.forbidigo.forbid]] +pattern = '^spew\.Print(f|ln)?$' -[linters-settings.gocognit] -min-complexity = 14.0 +[[linters.settings.forbidigo.forbid]] +pattern = '^spew\.Dump$' -[linters-settings.cyclo] -min-complexity = 14.0 +[linters.settings.funlen] +lines = 230 +statements = 150 -[linters-settings.fieldalignment] -suggest-new = true +[linters.settings.gocognit] +min-complexity = 14 -[linters-settings.goconst] -min-len = 3.0 -min-occurrences = 4.0 +[linters.settings.goconst] +min-len = 3 +min-occurrences = 4 -[linters-settings.misspell] -locale = "US" +[linters.settings.gocyclo] +min-complexity = 14 -[linters-settings.funlen] -lines = 230 # default 60 -statements = 150 # default 40 +[linters.settings.godox] +keywords = [ + 'FIXME' +] -[linters-settings.forbidigo] -forbid = ['^print(ln)?$', '^spew\.Print(f|ln)?$', '^spew\.Dump$'] +[linters.settings.gomoddirectives] +replace-allow-list = [ + 'github.com/abbot/go-http-auth', + 'github.com/go-check/check', + 'github.com/gorilla/mux', + 'github.com/mailgun/minheap', + 'github.com/mailgun/multibuf' +] -[linters-settings.depguard] -list-type = "blacklist" -include-go-root = false -packages = ["github.com/pkg/errors"] +[linters.settings.lll] +line-length = 150 +tab-width = 2 -[linters-settings.godox] -keywords = ["FIXME"] +[linters.settings.misspell] +locale = 'US' -[linters-settings.wsl] +[linters.settings.wsl] allow-assign-and-anything = true -[linters-settings.importas] -corev1 = "k8s.io/api/core/v1" -networkingv1beta1 = "k8s.io/api/networking/v1beta1" -extensionsv1beta1 = "k8s.io/api/extensions/v1beta1" -metav1 = "k8s.io/apimachinery/pkg/apis/meta/v1" -kubeerror = "k8s.io/apimachinery/pkg/api/errors" - -[linters-settings.gomoddirectives] -replace-allow-list = [ - "github.com/abbot/go-http-auth", - "github.com/go-check/check", - "github.com/gorilla/mux", - "github.com/mailgun/minheap", - "github.com/mailgun/multibuf", +[linters.exclusions] +generated = 'lax' +paths = [ + 'packrd', + 'internal/obfuscate', + 'pprofparser', + 'third_party$', + 'builtin$', + 'examples$' ] -[linters-settings.lll] -line-length = 150 -tab-width = 2 - -[linters] -enable-all = true -disable = [ - # 权且放开他们 - "nakedret", - "testpackage", # Too strict - "wrapcheck", # 不便于错误处理 - "tagliatelle", # 跟现有 json tag 命名方式 - "paralleltest", # 可开启,改动范围较大 - "noctx", # 要求 HTTP 请求都用 context 形式,改动较大 - "nlreturn", # 要求 return 语句前有一个空行 - "gomnd", # 不放过任何一个魔术数 - "wsl", # 更好代码分段 - "prealloc", # Too many false-positive. - "nestif", # Too many false-positive. - "goerr113", # 不能 fmt.Errorf/errors.New - "gochecknoglobals", # 不能搞全局变量 - "exhaustivestruct", # 结构体初始化字段是否完整 - "golint", # Too strict - "interfacer", - "scopelint", # obsoluted: https://github.com/kyoh86/scopelint#obsoleted - - - # 代码太复杂 - "gocognit", - "gocyclo", - - "dupl", # 还不允许有相似代码 - - "cyclop", - "gomoddirectives", # used `replace' in go.mod - "nolintlint", - "revive", - "exhaustruct", # [升级 Go1.18 后加入] 要求结构体每次务必每个字段都要填 - "varnamelen", # [升级 Go1.18 后加入] 变量名长度检查 - "nonamedreturns", # [升级 Go1.18 后加入] 不允许函数有名返回 - "forcetypeassert", # [升级 Go1.18 后加入] 强制断言检查 - "gci", # [升级 Go1.18 后加入] Tab 键检查 - "maintidx", # [升级 Go1.18 后加入] 函数长度检查 - "containedctx", # [升级 Go1.18 后加入] Go 不推荐把 context 放在结构体中:https://go.dev/blog/context-and-structs - "ireturn", # [升级 Go1.18 后加入] 函数 interface 返回检查 - "contextcheck", # [升级 Go1.18 后加入] 函数参数必须传入 context - "errchkjson", # [升级 Go1.18 后加入] json 序列化/反序列化必须检查 - "nilnil", # [升级 Go1.18 后加入] 函数返回 nil, nil 检查 +[[linters.exclusions.rules]] +linters = [ + 'deadcode', + 'errcheck', + 'fieldalignment', + 'funlen', + 'goconst', + 'godot', + 'gosec', + 'lll', + 'maligned', + 'perfsprint', + 'staticcheck', + 'unparam', + 'unused', + 'varcheck', + 'wsl' ] +path = '(.+)_test.go' [issues] -exclude-use-default = false -max-per-linter = 0 max-same-issues = 0 -exclude = [] - -[[issues.exclude-rules]] -path = "(.+)_test.go" -linters = [ - "perfsprint", - "errcheck", - "goconst", - "funlen", - "godot", - "lll", - "gosec", - "stylecheck", - "wsl", - "unused", - "deadcode", - "unparam", - "fieldalignment", - "maligned", - "varcheck"] diff --git a/AGENTS.md b/AGENTS.md index 1418a9fa..f7106b72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ This document provides guidelines for AI agents working on the GuanceCloud CLI U ## Code Style Guidelines ### General -- **Go Version**: 1.19+ +- **Go Version**: 1.25+ - **License Header**: All files must include the MIT license header (see existing files) - **Line Length**: Maximum 150 characters (configured in .golangci.toml) - **Complexity**: Maximum cyclomatic complexity of 14 @@ -132,4 +132,4 @@ The project uses golangci-lint with custom configuration (.golangci.toml): - Follow the existing code patterns and conventions - Check .golangci.toml for specific linter configurations - Use the Makefile commands for standard operations -- Test coverage is important - maintain or improve coverage \ No newline at end of file +- Test coverage is important - maintain or improve coverage diff --git a/aggregate/tail-sampling.go b/aggregate/tail-sampling.go index 24ebeca4..8dbeb919 100644 --- a/aggregate/tail-sampling.go +++ b/aggregate/tail-sampling.go @@ -367,7 +367,7 @@ func (t *TailSamplingConfigs) Init() error { } if len(errs) > 0 { - return fmt.Errorf(strings.Join(errs, "; ")) + return fmt.Errorf("%s", strings.Join(errs, "; ")) } return nil diff --git a/dialtesting/README.md b/dialtesting/README.md new file mode 100644 index 00000000..db1311cc --- /dev/null +++ b/dialtesting/README.md @@ -0,0 +1,274 @@ + + +# Dialtesting Browser Tasks + +`BROWSER` tasks run browser synthetic checks through the embedded browser runner. +The `dialtesting` package keeps the same task lifecycle as other dial +types: + +```text +task JSON -> NewTask -> Run -> GetResults -> report +``` + +The embedded runner executes the browser script and returns the final +`browser_dial_testing` point through the existing reporting path. + +## Runtime Dependencies + +Browser tasks require these binaries on the dial node: + +- Chrome/Chromium when `engine=chrome` +- Lightpanda when `engine=lightpanda` + +Chrome can be configured by `Task.SetOption()["chrome_path"]`. Lightpanda can be +configured by `Task.SetOption()["lightpanda_path"]`. + +## Task Fields + +Browser tasks use the normal common task fields, including: + +- `external_id` +- `name` +- `status` +- `frequency` +- `schedule_type` +- `crontab` +- `post_url` +- `tags` +- `config_vars` +- `owner_external_id` + +The browser-specific task field is: + +```json +{ + "browser_config": "" +} +``` + +`browser_config` is a YAML string using the native `browser-dial` script format. +It can contain `name`, `target`, `timeout_ms`, `tags`, `metadata`, `auth`, +`config_vars`, and `steps`. + +Do not put these browser script fields at the outer task level: + +- `target` +- `steps` +- `auth` +- `success_when` +- `success_when_logic` +- `chrome_path` +- `lightpanda_path` + +Success rules belong in `browser_config.steps` as browser assertions such as +`assert_title`, `assert_url`, and `assert_text`. + +Browser runtime options stay at the outer task level: + +```json +{ + "browser_window": { + "viewports": [ + { + "width": 1920, + "height": 1080 + } + ] + }, + "advance_options": { + "engine": "chrome", + "screenshot_on_failure": true, + "headers": { + "User-Agent": "datakit-browser-dial" + }, + "cookies": [ + { + "name": "sid", + "value": "example" + } + ], + "ignore_https_errors": false, + "proxy_url": "" + }, + "retry_options": { + "enabled": true, + "count": 2, + "interval_sec": 5 + } +} +``` + +Current limits: + +- `browser_window.viewports` supports at most one viewport. +- Default viewport is `1920x1080`. +- `advance_options.engine` supports `chrome` and `lightpanda`; empty defaults + to `chrome`. +- `retry_options.count` must be between `0` and `3`. +- `retry_options.interval_sec` must be between `5` and `300` when retry is + enabled and count is greater than zero. +- `browser_config.timeout_ms` must not exceed `300000`. +- Each `browser_config.steps[*].timeout_ms` and + `browser_config.auth.steps[*].timeout_ms` must not exceed `60000`. + +## Browser Config Schema + +`browser_config` can be written as YAML or JSON. DataKit stores it as a string +inside the browser task JSON. + +Top-level fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | Script name. The outer task name is used when omitted. | +| `target` | string | conditional | Default URL for `goto` steps. Can be omitted when each `goto` step has `url`. | +| `post_url` | string | no | DataWay URL for standalone browser-dial usage. DataKit normally uses the outer task `post_url`. | +| `timeout_ms` | int | no | Total run timeout. DataKit normalizes missing or zero values to `300000`. | +| `headers` | map[string]string | no | Extra request headers for the browser context. | +| `cookies` | array | no | Cookies to preload into the browser context. | +| `ignore_https_errors` | bool | no | Ignore HTTPS certificate errors. | +| `proxy_url` | string | no | Browser proxy URL, for example `http://127.0.0.1:7897`. | +| `tags` | map[string]string | no | Extra result tags. | +| `metadata` | map[string]any | no | Extra result metadata. | +| `auth` | object | no | Optional authentication flow. | +| `config_vars` | array | no | Variables referenced by `value_from`. | +| `steps` | array | yes | Main browser steps. Must contain at least one step. | + +`config_vars` entries: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Variable name. Must be unique and non-empty. | +| `value` | string | no | Variable value. | +| `secure` | bool | no | When true, the value is masked in result field `browser_config_vars`. | + +`cookies` entries: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Cookie name. | +| `value` | string | no | Cookie value. | +| `value_from` | string | no | Read cookie value from a `config_vars` entry. Must not be used together with `value`. | +| `domain` | string | no | Cookie domain. | +| `path` | string | no | Cookie path. | +| `secure` | bool | no | Secure cookie flag. | +| `http_only` | bool | no | HTTP-only cookie flag. | +| `same_site` | string | no | `Lax`, `Strict`, or `None`. | + +`auth` fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `mode` | string | no | `none` or `form`. Empty means `none`. | +| `steps` | array | conditional | Required when `mode` is `form`. Uses the same step schema as `steps`. | + +Step fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | Human readable step name. | +| `action` | string | yes | One of the supported actions below. | +| `url` | string | conditional | URL for `goto`. Optional if top-level `target` is set. | +| `selector` | string | conditional | CSS selector for selector-based actions. | +| `value` | string | conditional | Literal input value, or JavaScript expression for `eval`. | +| `value_from` | string | no | Read input value from a `config_vars` entry. | +| `text` | string | conditional | Expected text for assertions, or JavaScript expression for `eval`. | +| `contains` | string | conditional | Assertion passes when actual value contains this string. | +| `equals` | string | conditional | Assertion passes when actual value equals this string. | +| `timeout_ms` | int | no | Per-step timeout. DataKit normalizes missing or zero values to `60000`. | +| `sensitive` | bool | no | For `fill`; when true, `value_from` is required and literal `value` is forbidden. | + +Supported step actions: + +| Action | Required fields | Description | +| --- | --- | --- | +| `goto` | `url` or top-level `target` | Navigate to a page. | +| `wait_for_selector` | `selector` | Wait until the selector appears. | +| `click` | `selector` | Click the selector. | +| `fill` | `selector`, plus `value` or `value_from` | Fill an input. | +| `assert_title` | `contains`, `equals`, or `text` | Assert the page title. | +| `assert_url` | `contains`, `equals`, or `text` | Assert the current URL. | +| `assert_text` | `selector`, plus `contains`, `equals`, or `text` | Assert element text. | +| `eval` | `value` or `text` | Evaluate JavaScript in the page. | + +Recorder tools should generate this schema directly. The Chrome extension code +does not need to live in this repository; only the generated `browser_config` +must match this contract. + +## Example + +```json +{ + "external_id": "bd-homepage", + "name": "homepage", + "status": "OK", + "frequency": "1m", + "schedule_type": "frequency", + "tags": { + "owner": "platform" + }, + "browser_config": "name: homepage\ntarget: https://example.com\ntimeout_ms: 30000\nsteps:\n - name: open homepage\n action: goto\n - name: check title\n action: assert_title\n contains: Example\n - name: check body\n action: assert_text\n selector: body\n contains: Example Domain\n" +} +``` + +More script-only examples live under `dialtesting/examples/`: + +- `browser-basic.yaml` +- `browser-auth-form.yaml` +- `browser-config-vars.yaml` + +## Host Validation + +`GetHostName()` returns every explicitly configured browser destination so the +caller can reject illegal dial addresses before execution. + +It collects hostnames from: + +- top-level `browser_config.target` +- `browser_config.steps` entries where `action: goto` and `url` is set + +The returned list is de-duplicated. Runtime redirects, JavaScript navigation, +third-party page assets, and `eval`-generated requests are not statically +expanded. + +## Result Mapping + +When `browser-dial` returns `run.success=true`: + +- tag `status=OK` +- field `success=1` + +When `browser-dial` succeeds after retry: + +- tag `status=RETRY_OK` +- field `success=1` +- field `retry_count` greater than 0 + +When `browser-dial` returns `run.success=false`: + +- tag `status=FAIL` +- field `success=-1` +- field `fail_reason` from `run.fail_reason` and the error message + +Common result fields include: + +- `response_time` +- `last_step` +- `browser_run_id` +- `exit_code` +- `message` +- `failure_type` +- `page_url` +- `page_title` +- `trace_id` +- `trace_ids` +- `ttfb` +- `loading_time` +- `lcp` +- `cls` +- `steps` diff --git a/dialtesting/browser.go b/dialtesting/browser.go new file mode 100644 index 00000000..8e86b6a2 --- /dev/null +++ b/dialtesting/browser.go @@ -0,0 +1,1152 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package dialtesting + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "sync" + "text/template" + "time" + + browserchrome "github.com/GuanceCloud/cliutils/dialtesting/browserdial/chrome" + browserlightpanda "github.com/GuanceCloud/cliutils/dialtesting/browserdial/lightpanda" + browserrunner "github.com/GuanceCloud/cliutils/dialtesting/browserdial/runner" + browserscript "github.com/GuanceCloud/cliutils/dialtesting/browserdial/script" + "gopkg.in/yaml.v3" +) + +var ( + _ TaskChild = (*BrowserTask)(nil) + _ ITask = (*BrowserTask)(nil) + + browserEmbeddedChromeEngineFactory = browserchrome.NewEngine + browserEmbeddedLightpandaEngineFactory = browserlightpanda.NewEngine +) + +const ( + defaultBrowserDialTimeout = 300_000 + defaultBrowserWidth = 1920 + defaultBrowserHeight = 1080 + maxBrowserTotalTimeoutMS = 300_000 + maxBrowserStepTimeoutMS = 60_000 + + optionLightpandaPath = "lightpanda_path" + optionLightpandaPathCamel = "lightpandaPath" + optionChromePath = "chrome_path" + optionChromePathCamel = "chromePath" +) + +type BrowserTask struct { + *Task + URL string `json:"url,omitempty"` + BrowserConfig string `json:"browser_config"` + + BrowserWindow *BrowserWindowOption `json:"browser_window,omitempty"` + AdvanceOptions *BrowserAdvanceOption `json:"advance_options,omitempty"` + RetryOptions *BrowserRetryOption `json:"retry_options,omitempty"` + + duration time.Duration + result browserDialRun + exitCode int + reqError string + stderr string + rawTask *BrowserTask + results []browserViewportResult + + cancelMu sync.Mutex + cancel *browserTaskCancel +} + +type browserRawTask struct { + URL string `json:"url,omitempty"` + BrowserConfig string `json:"browser_config"` + BrowserWindow *BrowserWindowOption `json:"browser_window,omitempty"` + AdvanceOptions *BrowserAdvanceOption `json:"advance_options,omitempty"` + RetryOptions *BrowserRetryOption `json:"retry_options,omitempty"` +} + +type BrowserWindowOption struct { + Viewports []BrowserViewport `json:"viewports,omitempty"` +} + +type BrowserViewport struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type BrowserAdvanceOption struct { + Engine string `json:"engine,omitempty"` + + ScreenshotOnFailure bool `json:"screenshot_on_failure,omitempty"` + + Headers map[string]string `json:"headers,omitempty"` + Cookies []BrowserCookie `json:"cookies,omitempty"` + IgnoreHTTPSErrors bool `json:"ignore_https_errors,omitempty"` + ProxyURL string `json:"proxy_url,omitempty"` +} + +type BrowserCookie struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` +} + +type BrowserRetryOption struct { + Enabled bool `json:"enabled,omitempty"` + Count int `json:"count,omitempty"` + IntervalSec int `json:"interval_sec,omitempty"` +} + +type browserTaskCancel struct { + cancel context.CancelFunc +} + +type browserViewportResult struct { + viewport BrowserViewport + duration time.Duration + startedAt string + endedAt string + result browserDialRun + exitCode int + reqError string + stderr string + attempts int + retryRecords []browserRetryRecord +} + +type browserConfig struct { + Name string `yaml:"name"` + Target string `yaml:"target"` + TimeoutMS int `yaml:"timeout_ms"` + Tags map[string]string `yaml:"tags"` + ConfigVars []ConfigVar `yaml:"config_vars"` + Auth browserConfigAuth `yaml:"auth"` + Steps []browserConfigStep `yaml:"steps"` +} + +type browserConfigAuth struct { + Mode string `yaml:"mode"` + Steps []browserConfigStep `yaml:"steps"` +} + +type browserConfigStep struct { + Action string `yaml:"action"` + URL string `yaml:"url"` + TimeoutMS int `yaml:"timeout_ms"` +} + +type browserDialRun struct { + RunID string `json:"run_id"` + Name string `json:"name"` + Target string `json:"target,omitempty"` + Status string `json:"status"` + Success bool `json:"success"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DurationUS int64 `json:"duration_us"` + Steps []browserDialStep `json:"steps"` + TraceIDs []string `json:"trace_ids,omitempty"` + Performance *browserPerformanceMetrics `json:"performance,omitempty"` + Error *browserDialError `json:"error,omitempty"` + FailReason string `json:"fail_reason,omitempty"` + FailureType string `json:"failure_type,omitempty"` + RetryRecords []browserRetryRecord `json:"retry_records,omitempty"` +} + +type browserDialStep struct { + Seq int `json:"seq"` + Name string `json:"name"` + Action string `json:"action,omitempty"` + Selector string `json:"selector,omitempty"` + InputDisplay string `json:"input_display,omitempty"` + ValueFrom string `json:"value_from,omitempty"` + Expected string `json:"expected,omitempty"` + TimeoutMS int `json:"timeout_ms,omitempty"` + Auth bool `json:"auth,omitempty"` + Status string `json:"status"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DurationUS int64 `json:"duration_us"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Performance *browserPerformanceMetrics `json:"performance,omitempty"` + Screenshot string `json:"screenshot,omitempty"` + SkipReason string `json:"skip_reason,omitempty"` + Error *browserDialError `json:"error,omitempty"` +} + +type browserPerformanceMetrics struct { + TTFBMS int64 `json:"ttfb_ms,omitempty"` + LoadingTimeMS int64 `json:"loading_time_ms,omitempty"` + LCPMS int64 `json:"lcp_ms,omitempty"` + CLS float64 `json:"cls,omitempty"` + DOMContentLoadedMS int64 `json:"dom_content_loaded_ms,omitempty"` + LoadEventEndMS int64 `json:"load_event_end_ms,omitempty"` +} + +type browserConfigResultVar struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + Secure bool `json:"secure"` +} + +type browserDialError struct { + Name string `json:"name"` + Message string `json:"message"` + Stack string `json:"stack,omitempty"` +} + +type browserRetryRecord struct { + Attempt int `json:"attempt"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DurationUS int64 `json:"duration_us,omitempty"` + Status string `json:"status"` + Success bool `json:"success"` + FailedStep int `json:"failed_step,omitempty"` + FailReason string `json:"fail_reason,omitempty"` + FailureType string `json:"failure_type,omitempty"` + Message string `json:"message,omitempty"` +} + +func browserDialRunFromEmbedded(result browserrunner.Result) browserDialRun { + var run browserDialRun + data, err := json.Marshal(result) + if err != nil { + return run + } + if err := json.Unmarshal(data, &run); err != nil { + return browserDialRun{ + RunID: result.RunID, + Name: result.Name, + Target: result.Target, + Status: string(result.Status), + Success: result.Success, + DurationUS: result.DurationUS, + FailReason: result.FailReason, + FailureType: result.FailureType, + } + } + return run +} + +func (t *BrowserTask) clear() { + t.duration = 0 + t.result = browserDialRun{} + t.exitCode = 0 + t.reqError = "" + t.stderr = "" + t.results = nil +} + +func (t *BrowserTask) stop() { + t.cancelMu.Lock() + cancel := t.cancel + t.cancelMu.Unlock() + if cancel != nil && cancel.cancel != nil { + cancel.cancel() + } +} + +func (t *BrowserTask) class() string { + return ClassHeadless +} + +func (t *BrowserTask) metricName() string { + return "browser_dial_testing" +} + +func (t *BrowserTask) run() error { + if err := t.check(); err != nil { + t.setConfigError(err) + return nil + } + + path, err := t.writeScriptFile() + if err != nil { + t.reqError = err.Error() + return nil + } + defer os.Remove(path) //nolint:errcheck + + result := t.runViewport(path, t.effectiveViewports()[0]) + t.results = append(t.results, result) + t.setLastResult(result) + + return nil +} + +func (t *BrowserTask) runViewport(path string, viewport BrowserViewport) browserViewportResult { + maxAttempts := 1 + if t.RetryOptions != nil && t.RetryOptions.Enabled { + maxAttempts += clampBrowserRetryCount(t.RetryOptions.Count) + } + + var result browserViewportResult + retryRecords := make([]browserRetryRecord, 0, maxAttempts) + for attempt := 1; attempt <= maxAttempts; attempt++ { + result = t.runBrowserDial(path, viewport) + result.attempts = attempt + if len(result.result.RetryRecords) > 0 { + retryRecords = append(retryRecords, result.result.RetryRecords...) + } else { + retryRecords = append(retryRecords, browserRetryRecordFromResult(result, attempt)) + } + if maxAttempts > 1 && (len(retryRecords) > 1 || result.reqError != "" || !result.result.Success) { + result.retryRecords = append([]browserRetryRecord(nil), retryRecords...) + } + if result.reqError == "" && result.result.Success { + return result + } + if isBrowserParseFailure(result) || attempt == maxAttempts { + return result + } + if interval := t.retryInterval(); interval > 0 { + browserRetrySleep(interval) + } + } + return result +} + +func (t *BrowserTask) runBrowserDial(path string, viewport BrowserViewport) browserViewportResult { + return t.runBrowserDialEmbedded(path, viewport) +} + +func (t *BrowserTask) runBrowserDialEmbedded(path string, viewport BrowserViewport) browserViewportResult { + start := time.Now() + timeoutMS := t.effectiveTimeoutMS() + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMS)*time.Millisecond+15*time.Second) + defer cancel() + cancelState := t.setCancel(cancel) + defer t.clearCancel(cancelState) + + result := browserViewportResult{viewport: viewport, startedAt: start.UTC().Format(time.RFC3339Nano)} + engineName, engineFactory, err := t.embeddedEngineFactory() + if err != nil { + result.reqError = err.Error() + result.duration = time.Since(start) + result.endedAt = time.Now().UTC().Format(time.RFC3339Nano) + return result + } + + runResult := browserrunner.Run(ctx, browserrunner.Options{ + ScriptPath: path, + Name: t.Name, + TimeoutMS: timeoutMS, + Tags: t.Tags, + EngineName: engineName, + LightpandaPath: t.lightpandaPath(), + ChromePath: t.chromePath(), + StartupTimeout: 5 * time.Second, + ScreenshotOnFailure: t.AdvanceOptions != nil && t.AdvanceOptions.ScreenshotOnFailure, + ViewportWidth: viewport.Width, + ViewportHeight: viewport.Height, + Headers: t.embeddedHeaders(), + Cookies: t.embeddedCookies(), + IgnoreHTTPSErrors: t.AdvanceOptions != nil && t.AdvanceOptions.IgnoreHTTPSErrors, + ProxyURL: t.embeddedProxyURL(), + EngineFactory: engineFactory, + }) + result.duration = time.Since(start) + result.endedAt = time.Now().UTC().Format(time.RFC3339Nano) + result.exitCode = 0 + if !runResult.Success { + result.exitCode = 1 + } + result.result = browserDialRunFromEmbedded(runResult) + return result +} + +func (t *BrowserTask) setLastResult(result browserViewportResult) { + t.duration = result.duration + t.result = result.result + t.exitCode = result.exitCode + t.reqError = result.reqError + t.stderr = result.stderr +} + +func browserRetryRecordFromResult(result browserViewportResult, attempt int) browserRetryRecord { + status := result.result.Status + success := result.reqError == "" && result.result.Success + if status == "" { + if success { + status = "OK" + } else { + status = "FAIL" + } + } + record := browserRetryRecord{ + Attempt: attempt, + StartedAt: firstNonEmpty(result.result.StartedAt, result.startedAt), + EndedAt: firstNonEmpty(result.result.EndedAt, result.endedAt), + DurationUS: result.result.DurationUS, + Status: status, + Success: success, + FailReason: result.result.FailReason, + FailureType: result.result.FailureType, + } + if record.DurationUS == 0 && result.duration > 0 { + record.DurationUS = int64(result.duration) / 1000 + } + if result.reqError != "" { + record.Message = result.reqError + if record.FailureType == "" { + record.FailureType = "runner_error" + } + return record + } + if result.result.Error != nil { + record.Message = result.result.Error.Message + } + for _, step := range result.result.Steps { + if !strings.EqualFold(step.Status, "FAIL") { + continue + } + record.FailedStep = step.Seq + if record.Message == "" && step.Error != nil { + record.Message = step.Error.Message + } + break + } + return record +} + +func (t *BrowserTask) setCancel(cancel context.CancelFunc) *browserTaskCancel { + cancelState := &browserTaskCancel{cancel: cancel} + t.cancelMu.Lock() + t.cancel = cancelState + t.cancelMu.Unlock() + return cancelState +} + +func (t *BrowserTask) clearCancel(cancelState *browserTaskCancel) { + t.cancelMu.Lock() + if t.cancel == cancelState { + t.cancel = nil + } + t.cancelMu.Unlock() +} + +func (t *BrowserTask) writeScriptFile() (string, error) { + file, err := os.CreateTemp("", "dialtesting-browser-*.yaml") + if err != nil { + return "", err + } + defer file.Close() //nolint:errcheck + + config, err := normalizeBrowserConfigTimeouts(t.BrowserConfig) + if err != nil { + os.Remove(file.Name()) //nolint:errcheck + return "", err + } + if _, err := file.WriteString(config); err != nil { + os.Remove(file.Name()) //nolint:errcheck + return "", err + } + return file.Name(), nil +} + +func (t *BrowserTask) embeddedEngineFactory() (string, browserrunner.EngineFactory, error) { + switch strings.TrimSpace(strings.ToLower(t.effectiveEngine())) { + case "", "chrome", "chromium": + return "chrome", browserEmbeddedChromeEngineFactory, nil + case "lightpanda": + return "lightpanda", browserEmbeddedLightpandaEngineFactory, nil + default: + return "", nil, fmt.Errorf("browser engine must be lightpanda or chrome") + } +} + +func (t *BrowserTask) embeddedHeaders() map[string]string { + if t.AdvanceOptions == nil { + return nil + } + return t.AdvanceOptions.Headers +} + +func (t *BrowserTask) embeddedCookies() []browserscript.Cookie { + if t.AdvanceOptions == nil || len(t.AdvanceOptions.Cookies) == 0 { + return nil + } + cookies := make([]browserscript.Cookie, 0, len(t.AdvanceOptions.Cookies)) + for _, cookie := range t.AdvanceOptions.Cookies { + cookies = append(cookies, browserscript.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + }) + } + return cookies +} + +func (t *BrowserTask) embeddedProxyURL() string { + if t.AdvanceOptions == nil { + return "" + } + return t.AdvanceOptions.ProxyURL +} + +func (t *BrowserTask) lightpandaPath() string { + if value := t.GetOption()[optionLightpandaPath]; value != "" { + return value + } + if value := t.GetOption()[optionLightpandaPathCamel]; value != "" { + return value + } + return "" +} + +func (t *BrowserTask) chromePath() string { + if value := t.GetOption()[optionChromePath]; value != "" { + return value + } + if value := t.GetOption()[optionChromePathCamel]; value != "" { + return value + } + return "" +} + +func (t *BrowserTask) effectiveTimeoutMS() int { + cfg, err := t.parseBrowserConfig() + if err == nil && cfg.TimeoutMS > 0 { + return cfg.TimeoutMS + } + return defaultBrowserDialTimeout +} + +func (t *BrowserTask) checkResult() (reasons []string, succFlag bool) { + if t.reqError != "" { + return []string{t.reqError}, false + } + if t.result.Success { + return nil, true + } + if t.result.FailReason != "" { + reasons = append(reasons, t.result.FailReason) + } + if t.result.Error != nil && t.result.Error.Message != "" { + reasons = append(reasons, t.result.Error.Message) + } + if len(reasons) == 0 && t.stderr != "" { + reasons = append(reasons, t.stderr) + } + if len(reasons) == 0 { + reasons = append(reasons, "browser dial failed") + } + return reasons, false +} + +func (t *BrowserTask) getResults() (tags map[string]string, fields map[string]interface{}) { + cfg, _ := t.parseBrowserConfig() + result := t.lastViewportResult() + name := firstNonEmpty(t.result.Name, cfg.Name, t.Name) + target := firstNonEmpty(t.URL, t.result.Target, cfg.Target) + tags = map[string]string{ + "name": name, + "url": target, + "status": "FAIL", + "browser_engine": t.effectiveEngine(), + } + for k, v := range cfg.Tags { + tags[k] = v + } + for k, v := range t.Tags { + tags[k] = v + } + if result.viewport.Width > 0 && result.viewport.Height > 0 { + tags["viewport"] = fmt.Sprintf("%dx%d", result.viewport.Width, result.viewport.Height) + } else if viewports := t.effectiveViewports(); len(viewports) > 0 { + result.viewport = viewports[0] + tags["viewport"] = fmt.Sprintf("%dx%d", result.viewport.Width, result.viewport.Height) + } + + responseTime := t.result.DurationUS + if responseTime == 0 { + responseTime = int64(t.duration) / 1000 + } + fields = map[string]interface{}{ + "response_time": responseTime, + "success": int64(-1), + "last_step": int64(lastBrowserStep(t.result.Steps)), + "browser_run_id": t.result.RunID, + } + if result.viewport.Width > 0 && result.viewport.Height > 0 { + fields["viewport_width"] = int64(result.viewport.Width) + fields["viewport_height"] = int64(result.viewport.Height) + } + if result.attempts > 0 { + fields["retry_count"] = int64(result.attempts - 1) + } else { + fields["retry_count"] = int64(0) + } + + if t.reqError == "" && t.result.Success { + if result.attempts > 1 { + tags["status"] = "RETRY_OK" + } else { + tags["status"] = "OK" + } + fields["success"] = int64(1) + fields["message"] = "success" + } else { + reasons, _ := t.checkResult() + fields["fail_reason"] = strings.Join(reasons, ";") + fields["message"] = strings.Join(reasons, ";") + fields["failure_type"] = t.result.FailureType + if fields["failure_type"] == "" && t.reqError != "" { + fields["failure_type"] = "config_error" + } + } + if len(t.result.TraceIDs) > 0 { + fields["trace_id"] = t.result.TraceIDs[0] + } + if steps, err := json.Marshal(compactBrowserSteps(t.result.Steps)); err == nil { + fields["steps"] = string(steps) + } + if len(result.retryRecords) > 0 { + if data, err := json.Marshal(result.retryRecords); err == nil { + fields["retry_records"] = string(data) + } + } else if len(t.result.RetryRecords) > 0 { + if data, err := json.Marshal(t.result.RetryRecords); err == nil { + fields["retry_records"] = string(data) + } + } + if vars := browserConfigResultVars(cfg.ConfigVars); len(vars) > 0 { + if data, err := json.Marshal(vars); err == nil { + fields["browser_config_vars"] = string(data) + } + } + + return tags, fields +} + +func browserConfigResultVars(configVars []ConfigVar) []browserConfigResultVar { + vars := make([]browserConfigResultVar, 0, len(configVars)) + for _, v := range configVars { + result := browserConfigResultVar{ + Name: v.Name, + Type: v.Type, + Secure: v.Secure, + } + if result.Type == "" { + if v.Secure { + result.Type = "secret" + } else { + result.Type = "text" + } + } + if !v.Secure { + result.Value = v.Value + } + vars = append(vars, result) + } + return vars +} + +func lastBrowserStep(steps []browserDialStep) int { + if last, ok := lastExecutedBrowserStep(steps); ok { + return last.Seq + } + return 0 +} + +func lastExecutedBrowserStep(steps []browserDialStep) (browserDialStep, bool) { + for i := len(steps) - 1; i >= 0; i-- { + if !strings.EqualFold(steps[i].Status, "SKIP") { + return steps[i], true + } + } + return browserDialStep{}, false +} + +func compactBrowserSteps(steps []browserDialStep) []browserDialStep { + if len(steps) == 0 { + return nil + } + compact := make([]browserDialStep, len(steps)) + copy(compact, steps) + for i := range compact { + if compact[i].Error != nil { + errInfo := *compact[i].Error + errInfo.Stack = "" + compact[i].Error = &errInfo + } + } + return compact +} + +func (t *BrowserTask) check() error { + if strings.TrimSpace(t.BrowserConfig) == "" { + return errors.New("browser_config should not be empty") + } + cfg, err := t.parseBrowserConfig() + if err != nil { + return fmt.Errorf("parse browser_config failed: %w", err) + } + if len(cfg.Steps) == 0 { + return errors.New("browser_config steps should not be empty") + } + if err := checkBrowserConfigTimeouts(cfg); err != nil { + return err + } + t.applyDefaultBrowserWindow() + if err := t.checkBrowserWindow(); err != nil { + return err + } + if err := t.checkBrowserAdvanceOptions(); err != nil { + return err + } + if err := t.checkBrowserRetryOptions(); err != nil { + return err + } + return nil +} + +func (t *BrowserTask) init() error { + return nil +} + +func (t *BrowserTask) getHostName() ([]string, error) { + cfg, err := t.parseBrowserConfig() + if err != nil { + return nil, err + } + hosts := make([]string, 0, 1) + if strings.TrimSpace(cfg.Target) != "" { + host, err := getHostName(cfg.Target) + if err != nil { + return nil, err + } + hosts = append(hosts, host) + } + if strings.EqualFold(cfg.Auth.Mode, "form") { + for _, step := range cfg.Auth.Steps { + if step.Action != "goto" || strings.TrimSpace(step.URL) == "" { + continue + } + host, err := getHostName(step.URL) + if err != nil { + return nil, err + } + hosts = append(hosts, host) + } + } + for _, step := range cfg.Steps { + if step.Action != "goto" || strings.TrimSpace(step.URL) == "" { + continue + } + host, err := getHostName(step.URL) + if err != nil { + return nil, err + } + hosts = append(hosts, host) + } + if len(hosts) == 0 { + return nil, errors.New("browser_config target or goto url should not be empty") + } + return dedupBrowserHostNames(hosts), nil +} + +func (t *BrowserTask) getVariableValue(variable Variable) (string, error) { + return "", errors.New("not support") +} + +func (t *BrowserTask) getRawTask(taskString string) (string, error) { + task := BrowserTask{} + if err := json.Unmarshal([]byte(taskString), &task); err != nil { + return "", fmt.Errorf("unmarshal browser task failed: %w", err) + } + rawTask := browserRawTask{ + URL: task.URL, + BrowserConfig: sanitizeBrowserConfig(task.BrowserConfig), + BrowserWindow: task.BrowserWindow, + AdvanceOptions: task.AdvanceOptions, + RetryOptions: task.RetryOptions, + } + bytes, _ := json.Marshal(rawTask) + return string(bytes), nil +} + +func (t *BrowserTask) renderTemplate(fm template.FuncMap) error { + if t.rawTask == nil { + task := &BrowserTask{} + if err := t.NewRawTask(task); err != nil { + return fmt.Errorf("new raw task failed: %w", err) + } + t.rawTask = task + } + if t.rawTask == nil { + return errors.New("raw task is nil") + } + + browserConfig, err := t.GetParsedString(t.rawTask.BrowserConfig, fm) + if err != nil { + return fmt.Errorf("render browser_config failed: %w", err) + } + t.BrowserConfig = browserConfig + + url, err := t.GetParsedString(t.rawTask.URL, fm) + if err != nil { + return fmt.Errorf("render url failed: %w", err) + } + t.URL = url + return nil +} + +func (t *BrowserTask) initTask() { + if t.Task == nil { + t.Task = &Task{} + } + t.applyDefaultBrowserWindow() +} + +func (t *BrowserTask) setReqError(err string) { + t.reqError = err +} + +func (t *BrowserTask) setConfigError(err error) { + if err == nil { + return + } + t.reqError = err.Error() + t.result.FailureType = "config_error" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func dedupBrowserHostNames(hosts []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(hosts)) + for _, host := range hosts { + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + out = append(out, host) + } + return out +} + +func (t *BrowserTask) parseBrowserConfig() (browserConfig, error) { + var cfg browserConfig + if strings.TrimSpace(t.BrowserConfig) == "" { + return cfg, errors.New("browser_config is empty") + } + if err := yaml.Unmarshal([]byte(t.BrowserConfig), &cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +func checkBrowserConfigTimeouts(cfg browserConfig) error { + if cfg.TimeoutMS > maxBrowserTotalTimeoutMS { + return fmt.Errorf("browser_config timeout_ms should not exceed %d", maxBrowserTotalTimeoutMS) + } + for index, step := range cfg.Auth.Steps { + if step.TimeoutMS > maxBrowserStepTimeoutMS { + return fmt.Errorf("browser_config auth.steps %d timeout_ms should not exceed %d", index+1, maxBrowserStepTimeoutMS) + } + } + for index, step := range cfg.Steps { + if step.TimeoutMS > maxBrowserStepTimeoutMS { + return fmt.Errorf("browser_config steps %d timeout_ms should not exceed %d", index+1, maxBrowserStepTimeoutMS) + } + } + return nil +} + +func normalizeBrowserConfigTimeouts(config string) (string, error) { + var node yaml.Node + if err := yaml.Unmarshal([]byte(config), &node); err != nil { + return "", err + } + if len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode { + return config, nil + } + root := node.Content[0] + if timeout, ok := getYAMLMapInt(root, "timeout_ms"); ok && timeout > maxBrowserTotalTimeoutMS { + return "", fmt.Errorf("browser_config timeout_ms should not exceed %d", maxBrowserTotalTimeoutMS) + } + setYAMLMapIntIfMissingOrZero(root, "timeout_ms", maxBrowserTotalTimeoutMS) + if err := normalizeBrowserStepTimeouts(root, "steps", "browser_config steps"); err != nil { + return "", err + } + if auth := yamlMapValue(root, "auth"); auth != nil && auth.Kind == yaml.MappingNode { + if err := normalizeBrowserStepTimeouts(auth, "steps", "browser_config auth.steps"); err != nil { + return "", err + } + } + data, err := yaml.Marshal(&node) + if err != nil { + return "", err + } + return string(data), nil +} + +func normalizeBrowserStepTimeouts(parent *yaml.Node, key string, label string) error { + steps := yamlMapValue(parent, key) + if steps == nil || steps.Kind != yaml.SequenceNode { + return nil + } + for index, step := range steps.Content { + if step == nil || step.Kind != yaml.MappingNode { + continue + } + if timeout, ok := getYAMLMapInt(step, "timeout_ms"); ok && timeout > maxBrowserStepTimeoutMS { + return fmt.Errorf("%s %d timeout_ms should not exceed %d", label, index+1, maxBrowserStepTimeoutMS) + } + setYAMLMapIntIfMissingOrZero(step, "timeout_ms", maxBrowserStepTimeoutMS) + } + return nil +} + +func (t *BrowserTask) applyDefaultBrowserWindow() { + if t.BrowserWindow == nil { + t.BrowserWindow = &BrowserWindowOption{} + } + if len(t.BrowserWindow.Viewports) == 0 { + t.BrowserWindow.Viewports = []BrowserViewport{{Width: defaultBrowserWidth, Height: defaultBrowserHeight}} + } +} + +func (t *BrowserTask) effectiveViewports() []BrowserViewport { + t.applyDefaultBrowserWindow() + return t.BrowserWindow.Viewports +} + +func (t *BrowserTask) checkBrowserWindow() error { + viewports := t.effectiveViewports() + if len(viewports) > 1 { + return fmt.Errorf("browser_window.viewports currently supports at most one viewport") + } + for _, viewport := range viewports { + if viewport.Width <= 0 || viewport.Height <= 0 { + return fmt.Errorf("browser_window viewport width and height should be greater than 0") + } + } + return nil +} + +func (t *BrowserTask) checkBrowserAdvanceOptions() error { + if t.AdvanceOptions == nil { + return nil + } + switch t.AdvanceOptions.Engine { + case "", "chrome", "lightpanda": + default: + return fmt.Errorf("advance_options engine should be chrome or lightpanda") + } + for key := range t.AdvanceOptions.Headers { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("advance_options headers key should not be empty") + } + } + for _, cookie := range t.AdvanceOptions.Cookies { + if strings.TrimSpace(cookie.Name) == "" { + return fmt.Errorf("advance_options cookie name should not be empty") + } + } + return nil +} + +func (t *BrowserTask) effectiveEngine() string { + if t.AdvanceOptions != nil && t.AdvanceOptions.Engine != "" { + return t.AdvanceOptions.Engine + } + return "chrome" +} + +func (t *BrowserTask) checkBrowserRetryOptions() error { + if t.RetryOptions == nil { + return nil + } + if t.RetryOptions.Count < 0 || t.RetryOptions.Count > 3 { + return fmt.Errorf("retry_options count should be between 0 and 3") + } + if t.RetryOptions.Enabled && t.RetryOptions.Count > 0 && + (t.RetryOptions.IntervalSec < 5 || t.RetryOptions.IntervalSec > 300) { + return fmt.Errorf("retry_options interval_sec should be between 5 and 300") + } + return nil +} + +func (t *BrowserTask) retryInterval() time.Duration { + if t.RetryOptions == nil || !t.RetryOptions.Enabled || t.RetryOptions.Count <= 0 { + return 0 + } + interval := t.RetryOptions.IntervalSec + if interval < 5 { + interval = 5 + } + if interval > 300 { + interval = 300 + } + return time.Duration(interval) * time.Second +} + +func (t *BrowserTask) lastViewportResult() browserViewportResult { + if len(t.results) == 0 { + return browserViewportResult{} + } + return t.results[len(t.results)-1] +} + +func clampBrowserRetryCount(count int) int { + if count < 0 { + return 0 + } + if count > 3 { + return 3 + } + return count +} + +func isBrowserParseFailure(result browserViewportResult) bool { + text := strings.ToLower(strings.Join([]string{ + result.reqError, + result.stderr, + result.result.FailReason, + }, " ")) + if result.result.Error != nil { + text += " " + strings.ToLower(result.result.Error.Name+" "+result.result.Error.Message) + } + return strings.Contains(text, "parse") +} + +var browserRetrySleep = time.Sleep + +func sanitizeBrowserConfig(config string) string { + if strings.TrimSpace(config) == "" { + return config + } + var node yaml.Node + if err := yaml.Unmarshal([]byte(config), &node); err != nil { + return config + } + sanitizeYAMLConfigVars(&node) + data, err := yaml.Marshal(&node) + if err != nil { + return config + } + return string(data) +} + +func sanitizeYAMLConfigVars(node *yaml.Node) { + if node == nil { + return + } + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + value := node.Content[i+1] + if key.Value == "config_vars" { + sanitizeConfigVarSequence(value) + continue + } + sanitizeYAMLConfigVars(value) + } + return + } + for _, child := range node.Content { + sanitizeYAMLConfigVars(child) + } +} + +func sanitizeConfigVarSequence(node *yaml.Node) { + if node == nil || node.Kind != yaml.SequenceNode { + return + } + for _, item := range node.Content { + if item == nil || item.Kind != yaml.MappingNode { + continue + } + if yamlMapBoolValue(item, "secure") { + setYAMLMapValue(item, "value", "") + } + } +} + +func yamlMapBoolValue(node *yaml.Node, key string) bool { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return strings.EqualFold(node.Content[i+1].Value, "true") + } + } + return false +} + +func yamlMapValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func getYAMLMapInt(node *yaml.Node, key string) (int, bool) { + value := yamlMapValue(node, key) + if value == nil { + return 0, false + } + var out int + if err := value.Decode(&out); err != nil { + return 0, false + } + return out, true +} + +func setYAMLMapIntIfMissingOrZero(node *yaml.Node, key string, value int) { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + current, ok := getYAMLMapInt(node, key) + if ok && current > 0 { + return + } + node.Content[i+1].Kind = yaml.ScalarNode + node.Content[i+1].Tag = "!!int" + node.Content[i+1].Value = fmt.Sprintf("%d", value) + return + } + } + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("%d", value)}, + ) +} + +func setYAMLMapValue(node *yaml.Node, key string, value string) { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + node.Content[i+1].Kind = yaml.ScalarNode + node.Content[i+1].Tag = "!!str" + node.Content[i+1].Value = value + return + } + } + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, + ) +} diff --git a/dialtesting/browser_test.go b/dialtesting/browser_test.go new file mode 100644 index 00000000..ba983356 --- /dev/null +++ b/dialtesting/browser_test.go @@ -0,0 +1,963 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package dialtesting + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + browserevidence "github.com/GuanceCloud/cliutils/dialtesting/browserdial/evidence" + browserrunner "github.com/GuanceCloud/cliutils/dialtesting/browserdial/runner" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateBrowserTaskChild(t *testing.T) { + ct, err := CreateTaskChild(ClassHeadless) + require.NoError(t, err) + require.IsType(t, &BrowserTask{}, ct) +} + +func TestBrowserTaskRunEmbeddedByDefault(t *testing.T) { + browserTask := newBrowserTaskForTest() + task, err := NewTask("", browserTask) + require.NoError(t, err) + stubBrowserEngine(t, nil, nil) + + err = task.Run() + require.NoError(t, err) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, ClassHeadless, task.Class()) + assert.Equal(t, "browser_dial_testing", task.MetricName()) + assert.Equal(t, int64(1), fields["success"]) + assert.Greater(t, fields["response_time"], int64(0)) + assert.Equal(t, int64(2), fields["last_step"]) + assert.Contains(t, fields["steps"], "goto") + assert.Contains(t, fields["steps"], "assert_title") + assert.Contains(t, fields["steps"], "Example Domain") + assert.Equal(t, "https://example.com", tags["url"]) + assert.Equal(t, "platform", tags["owner"]) + assert.Equal(t, "1920x1080", tags["viewport"]) + assert.Equal(t, int64(1920), fields["viewport_width"]) + assert.Equal(t, int64(1080), fields["viewport_height"]) + assert.Equal(t, int64(0), fields["retry_count"]) +} + +func TestBrowserTaskRunSetsLightpandaPath(t *testing.T) { + browserTask := newBrowserTaskForTest() + task, err := NewTask("", browserTask) + require.NoError(t, err) + task.SetOption(map[string]string{ + optionLightpandaPath: "/opt/datakit/lightpanda", + }) + + var gotOptions browserrunner.EngineOptions + browserTask.AdvanceOptions = &BrowserAdvanceOption{Engine: "lightpanda"} + stubBrowserEngine(t, &gotOptions, nil) + require.NoError(t, task.Run()) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, int64(1), fields["success"]) + assert.Equal(t, "/opt/datakit/lightpanda", gotOptions.LightpandaPath) +} + +func TestBrowserTaskRunSetsChromePath(t *testing.T) { + browserTask := newBrowserTaskForTest() + task, err := NewTask("", browserTask) + require.NoError(t, err) + task.SetOption(map[string]string{ + optionChromePath: "/opt/datakit-browser/chrome/chrome", + }) + + var gotOptions browserrunner.EngineOptions + stubBrowserEngine(t, &gotOptions, nil) + require.NoError(t, task.Run()) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, int64(1), fields["success"]) + assert.Equal(t, "/opt/datakit-browser/chrome/chrome", gotOptions.ChromePath) +} + +func TestBrowserTaskRunEmbeddedFailure(t *testing.T) { + browserTask := newBrowserTaskForTest() + task, err := NewTask("", browserTask) + require.NoError(t, err) + + stubBrowserEngine(t, nil, nil, "Wrong Title") + err = task.Run() + require.NoError(t, err) + + tags, fields := task.GetResults() + assert.Equal(t, "FAIL", tags["status"]) + assert.Equal(t, int64(-1), fields["success"]) + assert.Contains(t, fields["fail_reason"], "step_error") + assert.Equal(t, "assertion_failed", fields["failure_type"]) + assert.Contains(t, fields["message"], "title assertion failed") + assert.Equal(t, int64(2), fields["last_step"]) + assert.Contains(t, fields["steps"], "title") +} + +func TestBrowserTaskRunEmbeddedEngineError(t *testing.T) { + browserTask := newBrowserTaskForTest() + task, err := NewTask("", browserTask) + require.NoError(t, err) + + oldChromeFactory := browserEmbeddedChromeEngineFactory + browserEmbeddedChromeEngineFactory = func(context.Context, browserrunner.EngineOptions) (browserrunner.Engine, error) { + return nil, errors.New("start chrome failed") + } + t.Cleanup(func() { + browserEmbeddedChromeEngineFactory = oldChromeFactory + }) + + require.NoError(t, task.Run()) + _, fields := task.GetResults() + assert.Equal(t, int64(-1), fields["success"]) + assert.Contains(t, fields["message"], "start chrome failed") +} + +func TestBrowserTaskRenderTemplate(t *testing.T) { + task := &BrowserTask{ + Task: &Task{ + Name: "browser", + ConfigVars: []*ConfigVar{ + {Name: "host", Value: "example.com"}, + {Name: "title", Value: "Example"}, + }, + }, + URL: "https://{{host}}/display", + BrowserConfig: "name: browser\ntarget: https://{{host}}\nsteps:\n - action: goto\n - action: assert_title\n contains: {{title}}\n", + } + itask, err := NewTask("", task) + require.NoError(t, err) + require.NoError(t, itask.RenderTemplateAndInit(nil)) + + assert.Equal(t, "https://example.com/display", task.URL) + assert.Contains(t, task.BrowserConfig, "target: https://example.com") + assert.Contains(t, task.BrowserConfig, "contains: Example") +} + +func TestBrowserTaskURLIsDisplayOnlyResultTag(t *testing.T) { + task := &BrowserTask{ + Task: &Task{Name: "browser"}, + URL: "https://display.example.com", + BrowserConfig: strings.Join([]string{ + "name: browser", + "target: https://runtime.example.com", + "steps:", + " - action: goto", + "", + }, "\n"), + } + itask, err := NewTask("", task) + require.NoError(t, err) + + tags, _ := itask.GetResults() + assert.Equal(t, "https://display.example.com", tags["url"]) +} + +func TestBrowserTaskResultIncludesBrowserConfigVars(t *testing.T) { + task := &BrowserTask{ + Task: &Task{Name: "browser"}, + BrowserConfig: `name: browser +config_vars: + - name: username + value: admin@example.com + - name: password + value: secret-value + secure: true +steps: + - action: goto +`, + } + itask, err := NewTask("", task) + require.NoError(t, err) + + _, fields := itask.GetResults() + raw, ok := fields["browser_config_vars"].(string) + require.True(t, ok) + + var vars []browserConfigResultVar + require.NoError(t, json.Unmarshal([]byte(raw), &vars)) + require.Len(t, vars, 2) + + assert.Equal(t, "username", vars[0].Name) + assert.Equal(t, "text", vars[0].Type) + assert.Equal(t, "admin@example.com", vars[0].Value) + assert.False(t, vars[0].Secure) + + assert.Equal(t, "password", vars[1].Name) + assert.Equal(t, "secret", vars[1].Type) + assert.Empty(t, vars[1].Value) + assert.True(t, vars[1].Secure) + assert.NotContains(t, raw, "secret-value") +} + +func TestBrowserTaskCheckBrowserConfig(t *testing.T) { + task := newBrowserTaskForTest() + require.NoError(t, task.check()) + + task.BrowserConfig = "" + assert.EqualError(t, task.check(), "browser_config should not be empty") + + task.BrowserConfig = "name: homepage\ntarget: https://example.com\n" + assert.EqualError(t, task.check(), "browser_config steps should not be empty") +} + +func TestBrowserTaskCheckBrowserConfigTimeoutLimits(t *testing.T) { + task := newBrowserTaskForTest() + task.BrowserConfig = "name: homepage\ntarget: https://example.com\ntimeout_ms: 300001\nsteps:\n - action: goto\n" + assert.EqualError(t, task.check(), "browser_config timeout_ms should not exceed 300000") + + task.BrowserConfig = "name: homepage\ntarget: https://example.com\nsteps:\n - action: goto\n timeout_ms: 60001\n" + assert.EqualError(t, task.check(), "browser_config steps 1 timeout_ms should not exceed 60000") + + task.BrowserConfig = "name: homepage\ntarget: https://example.com\nauth:\n mode: form\n steps:\n - action: goto\n timeout_ms: 60001\nsteps:\n - action: goto\n" + assert.EqualError(t, task.check(), "browser_config auth.steps 1 timeout_ms should not exceed 60000") +} + +func TestBrowserTaskNormalizeBrowserConfigTimeouts(t *testing.T) { + config := `name: homepage +target: https://example.com +auth: + mode: form + steps: + - action: goto +steps: + - action: goto + - action: assert_title + timeout_ms: 1000 +` + normalized, err := normalizeBrowserConfigTimeouts(config) + require.NoError(t, err) + assert.Contains(t, normalized, "timeout_ms: 300000") + assert.Contains(t, normalized, "timeout_ms: 60000") + assert.Contains(t, normalized, "timeout_ms: 1000") + + task := &BrowserTask{BrowserConfig: config} + path, err := task.writeScriptFile() + require.NoError(t, err) + defer os.Remove(path) //nolint:errcheck + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "timeout_ms: 300000") + assert.Contains(t, string(data), "timeout_ms: 60000") +} + +func TestBrowserTaskNormalizeBrowserConfigTimeoutErrors(t *testing.T) { + _, err := normalizeBrowserConfigTimeouts("name: [") + assert.Error(t, err) + + normalized, err := normalizeBrowserConfigTimeouts("- item") + require.NoError(t, err) + assert.Equal(t, "- item", normalized) + + _, err = normalizeBrowserConfigTimeouts("name: homepage\ntimeout_ms: 300001\nsteps:\n - action: goto\n") + assert.EqualError(t, err, "browser_config timeout_ms should not exceed 300000") + + _, err = normalizeBrowserConfigTimeouts("name: homepage\nsteps:\n - action: goto\n timeout_ms: 60001\n") + assert.EqualError(t, err, "browser_config steps 1 timeout_ms should not exceed 60000") +} + +func TestBrowserTaskParseNewFields(t *testing.T) { + taskJSON := `{ + "external_id": "bd-homepage", + "name": "homepage", + "frequency": "1m", + "browser_config": "name: homepage\ntarget: https://example.com\nsteps:\n - action: goto\n", + "browser_window": {"viewports": [{"width": 1920, "height": 1080}]}, + "advance_options": { + "engine": "chrome", + "screenshot_on_failure": true, + "headers": {"X-Test": "ok"}, + "cookies": [{"name": "sid", "value": "abc"}], + "ignore_https_errors": true, + "proxy_url": "http://127.0.0.1:7897" + }, + "retry_options": {"enabled": true, "count": 2, "interval_sec": 10} + }` + child, err := CreateTaskChild(ClassHeadless) + require.NoError(t, err) + task, err := NewTask(taskJSON, child) + require.NoError(t, err) + browserTask := task.(*BrowserTask) //nolint:forcetypeassert + require.NoError(t, task.Check()) + assert.Len(t, browserTask.BrowserWindow.Viewports, 1) + assert.Equal(t, "chrome", browserTask.AdvanceOptions.Engine) + assert.True(t, browserTask.AdvanceOptions.ScreenshotOnFailure) + assert.Equal(t, "ok", browserTask.AdvanceOptions.Headers["X-Test"]) + assert.Equal(t, "sid", browserTask.AdvanceOptions.Cookies[0].Name) + assert.True(t, browserTask.AdvanceOptions.IgnoreHTTPSErrors) + assert.Equal(t, "http://127.0.0.1:7897", browserTask.AdvanceOptions.ProxyURL) + assert.Equal(t, 2, browserTask.RetryOptions.Count) +} + +func TestBrowserTaskCheckInvalidEngine(t *testing.T) { + task := newBrowserTaskForTest() + task.AdvanceOptions = &BrowserAdvanceOption{Engine: "firefox"} + assert.EqualError(t, task.check(), "advance_options engine should be chrome or lightpanda") +} + +func TestBrowserTaskCheckInvalidViewport(t *testing.T) { + task := newBrowserTaskForTest() + task.BrowserWindow = &BrowserWindowOption{Viewports: []BrowserViewport{{Width: 0, Height: 1080}}} + assert.EqualError(t, task.check(), "browser_window viewport width and height should be greater than 0") +} + +func TestBrowserTaskCheckMultipleViewports(t *testing.T) { + task := newBrowserTaskForTest() + task.BrowserWindow = &BrowserWindowOption{Viewports: []BrowserViewport{ + {Width: 1920, Height: 1080}, + {Width: 1366, Height: 768}, + }} + assert.EqualError(t, task.check(), "browser_window.viewports currently supports at most one viewport") +} + +func TestBrowserTaskDefaultViewport(t *testing.T) { + task := newBrowserTaskForTest() + task.BrowserWindow = nil + require.NoError(t, task.check()) + require.NotNil(t, task.BrowserWindow) + require.Len(t, task.BrowserWindow.Viewports, 1) + assert.Equal(t, BrowserViewport{Width: 1920, Height: 1080}, task.BrowserWindow.Viewports[0]) + + task.BrowserWindow = &BrowserWindowOption{} + require.NoError(t, task.check()) + require.Len(t, task.BrowserWindow.Viewports, 1) + assert.Equal(t, BrowserViewport{Width: 1920, Height: 1080}, task.BrowserWindow.Viewports[0]) +} + +func TestBrowserTaskDefaultEngine(t *testing.T) { + task := newBrowserTaskForTest() + assert.Equal(t, "chrome", task.effectiveEngine()) + + task.AdvanceOptions = &BrowserAdvanceOption{Engine: "lightpanda"} + assert.Equal(t, "lightpanda", task.effectiveEngine()) +} + +func TestBrowserTaskCheckInvalidRetry(t *testing.T) { + task := newBrowserTaskForTest() + task.RetryOptions = &BrowserRetryOption{Enabled: true, Count: 4, IntervalSec: 10} + assert.EqualError(t, task.check(), "retry_options count should be between 0 and 3") + + task.RetryOptions = &BrowserRetryOption{Enabled: true, Count: 1, IntervalSec: 4} + assert.EqualError(t, task.check(), "retry_options interval_sec should be between 5 and 300") +} + +func TestBrowserTaskCheckInvalidHeaderAndCookie(t *testing.T) { + task := newBrowserTaskForTest() + task.AdvanceOptions = &BrowserAdvanceOption{Headers: map[string]string{"": "value"}} + assert.EqualError(t, task.check(), "advance_options headers key should not be empty") + + task.AdvanceOptions = &BrowserAdvanceOption{Cookies: []BrowserCookie{{Value: "value"}}} + assert.EqualError(t, task.check(), "advance_options cookie name should not be empty") +} + +func TestBrowserTaskRunSingleViewport(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.BrowserWindow = &BrowserWindowOption{Viewports: []BrowserViewport{{Width: 1366, Height: 768}}} + task, err := NewTask("", browserTask) + require.NoError(t, err) + + var gotOptions browserrunner.EngineOptions + stubBrowserEngine(t, &gotOptions, nil) + require.NoError(t, task.Run()) + + assert.Equal(t, 1366, gotOptions.ViewportWidth) + assert.Equal(t, 768, gotOptions.ViewportHeight) + + tags, fields := task.GetResults() + assert.Equal(t, "1366x768", tags["viewport"]) + assert.Equal(t, int64(1366), fields["viewport_width"]) + assert.Equal(t, int64(768), fields["viewport_height"]) +} + +func TestBrowserTaskRunRejectsInvalidRetryOptionsAtRuntime(t *testing.T) { + cases := []struct { + name string + options *BrowserRetryOption + want string + }{ + { + name: "count", + options: &BrowserRetryOption{Enabled: true, Count: 4, IntervalSec: 5}, + want: "retry_options count should be between 0 and 3", + }, + { + name: "interval", + options: &BrowserRetryOption{Enabled: true, Count: 1, IntervalSec: 4}, + want: "retry_options interval_sec should be between 5 and 300", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.RetryOptions = tc.options + task, err := NewTask("", browserTask) + require.NoError(t, err) + + var calls int + stubBrowserEngineWithFactory(t, func(_ context.Context, options browserrunner.EngineOptions) (browserrunner.Engine, error) { + calls++ + return &fakeBrowserEngine{}, nil + }) + require.NoError(t, task.Run()) + + assert.Equal(t, 0, calls) + + tags, fields := task.GetResults() + assert.Equal(t, "FAIL", tags["status"]) + assert.Equal(t, tc.want, fields["message"]) + assert.Equal(t, "config_error", fields["failure_type"]) + assert.Equal(t, int64(0), fields["retry_count"]) + assert.Equal(t, "1920x1080", tags["viewport"]) + assert.Equal(t, int64(1920), fields["viewport_width"]) + assert.Equal(t, int64(1080), fields["viewport_height"]) + }) + } +} + +func TestBrowserTaskConfigErrorsReportConsistentFields(t *testing.T) { + cases := []struct { + name string + config string + want string + }{ + { + name: "total-timeout", + config: "name: homepage\ntarget: https://example.com\ntimeout_ms: 300001\nsteps:\n - action: goto\n", + want: "browser_config timeout_ms should not exceed 300000", + }, + { + name: "step-timeout", + config: "name: homepage\ntarget: https://example.com\nsteps:\n - action: goto\n timeout_ms: 60001\n", + want: "browser_config steps 1 timeout_ms should not exceed 60000", + }, + { + name: "parse", + config: "name: [", + want: "parse browser_config failed", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.BrowserConfig = tc.config + task, err := NewTask("", browserTask) + require.NoError(t, err) + + var calls int + stubBrowserEngineWithFactory(t, func(_ context.Context, options browserrunner.EngineOptions) (browserrunner.Engine, error) { + calls++ + return &fakeBrowserEngine{}, nil + }) + require.NoError(t, task.Run()) + + assert.Equal(t, 0, calls) + + tags, fields := task.GetResults() + assert.Equal(t, "FAIL", tags["status"]) + assert.Contains(t, fields["message"], tc.want) + assert.Equal(t, "config_error", fields["failure_type"]) + assert.Equal(t, int64(-1), fields["success"]) + assert.Equal(t, int64(0), fields["retry_count"]) + assert.Equal(t, "1920x1080", tags["viewport"]) + assert.Equal(t, int64(1920), fields["viewport_width"]) + assert.Equal(t, int64(1080), fields["viewport_height"]) + }) + } +} + +func TestBrowserTaskRunAdvanceOptions(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.AdvanceOptions = &BrowserAdvanceOption{ + Engine: "chrome", + ScreenshotOnFailure: true, + Headers: map[string]string{"X-Test": "ok"}, + Cookies: []BrowserCookie{{Name: "sid", Value: "abc"}}, + IgnoreHTTPSErrors: true, + ProxyURL: "http://127.0.0.1:7897", + } + task, err := NewTask("", browserTask) + require.NoError(t, err) + + var gotOptions browserrunner.EngineOptions + var gotConfig browserrunner.BrowserConfig + stubBrowserEngine(t, &gotOptions, &gotConfig) + require.NoError(t, task.Run()) + + assert.Equal(t, "http://127.0.0.1:7897", gotOptions.ProxyURL) + assert.True(t, gotOptions.IgnoreHTTPSErrors) + assert.Equal(t, "ok", gotConfig.Headers["X-Test"]) + require.Len(t, gotConfig.Cookies, 1) + assert.Equal(t, "sid", gotConfig.Cookies[0].Name) + assert.Equal(t, "abc", gotConfig.Cookies[0].Value) +} + +func TestBrowserTaskRetryStopsAfterSuccess(t *testing.T) { + oldSleep := browserRetrySleep + browserRetrySleep = func(time.Duration) {} + defer func() { browserRetrySleep = oldSleep }() + + browserTask := newBrowserTaskForTest() + browserTask.RetryOptions = &BrowserRetryOption{Enabled: true, Count: 2, IntervalSec: 5} + task, err := NewTask("", browserTask) + require.NoError(t, err) + + var calls int + stubBrowserEngineWithFactory(t, func(_ context.Context, options browserrunner.EngineOptions) (browserrunner.Engine, error) { + calls++ + if calls == 1 { + return &fakeBrowserEngine{title: "Wrong Title"}, nil + } + return &fakeBrowserEngine{}, nil + }) + require.NoError(t, task.Run()) + + tags, fields := task.GetResults() + assert.Equal(t, "RETRY_OK", tags["status"]) + assert.Equal(t, int64(1), fields["retry_count"]) + assert.Equal(t, int64(1), fields["success"]) + rawRecords, ok := fields["retry_records"].(string) + require.True(t, ok) + var records []browserRetryRecord + require.NoError(t, json.Unmarshal([]byte(rawRecords), &records)) + require.Len(t, records, 2) + assert.Equal(t, 1, records[0].Attempt) + assert.Equal(t, "FAIL", records[0].Status) + assert.False(t, records[0].Success) + assert.Equal(t, 2, records[0].FailedStep) + assert.Equal(t, "assertion_failed", records[0].FailureType) + assert.Equal(t, 2, records[1].Attempt) + assert.Equal(t, "OK", records[1].Status) + assert.True(t, records[1].Success) + assert.Equal(t, 2, calls) +} + +func TestBrowserTaskParseFailureDoesNotRetry(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.BrowserConfig = "name: [" + browserTask.RetryOptions = &BrowserRetryOption{Enabled: true, Count: 2, IntervalSec: 5} + task, err := NewTask("", browserTask) + require.NoError(t, err) + + var calls int + stubBrowserEngineWithFactory(t, func(_ context.Context, options browserrunner.EngineOptions) (browserrunner.Engine, error) { + calls++ + return &fakeBrowserEngine{}, nil + }) + require.NoError(t, task.Run()) + + assert.Equal(t, 0, calls) + _, fields := task.GetResults() + assert.Contains(t, fields["message"], "parse browser_config failed") +} + +func TestBrowserTaskGetHostNameFromGotoURL(t *testing.T) { + task := &BrowserTask{ + BrowserConfig: `name: homepage +steps: + - action: goto + url: https://example.com + - action: goto + url: https://example.com/path + - action: goto + url: https://docs.example.com +`, + } + hosts, err := task.getHostName() + require.NoError(t, err) + assert.Equal(t, []string{"example.com", "docs.example.com"}, hosts) +} + +func TestBrowserTaskGetHostNameFromAuthGotoURL(t *testing.T) { + task := &BrowserTask{ + BrowserConfig: `name: homepage +auth: + mode: form + steps: + - action: goto + url: https://login.example.com +steps: + - action: goto + url: https://app.example.com +`, + } + hosts, err := task.getHostName() + require.NoError(t, err) + assert.Equal(t, []string{"login.example.com", "app.example.com"}, hosts) +} + +func TestBrowserTaskIgnoresOuterSuccessWhen(t *testing.T) { + taskJSON := `{ + "external_id": "bd-homepage", + "name": "homepage", + "frequency": "1m", + "success_when": [{"response_time": "1ms"}], + "success_when_logic": "and", + "browser_config": "name: homepage\ntarget: https://example.com\nsteps:\n - action: goto\n" + }` + child, err := CreateTaskChild(ClassHeadless) + require.NoError(t, err) + task, err := NewTask(taskJSON, child) + require.NoError(t, err) + + stubBrowserEngine(t, nil, nil) + require.NoError(t, task.Check()) + require.NoError(t, task.Run()) + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, int64(1), fields["success"]) +} + +func TestBrowserTaskSanitizeRawTask(t *testing.T) { + task := &BrowserTask{ + Task: &Task{Name: "homepage", Frequency: "1m"}, + BrowserConfig: `name: homepage +target: https://example.com +config_vars: + - name: LOGIN_USER + value: user@example.com + secure: false + - name: LOGIN_PASSWORD + value: secret + secure: true +steps: + - action: goto +`, + } + raw, err := task.getRawTask(mustJSON(t, task)) + require.NoError(t, err) + assert.Contains(t, raw, "LOGIN_PASSWORD") + assert.NotContains(t, raw, "secret") + assert.Contains(t, raw, "user@example.com") +} + +func TestBrowserTaskLightpandaPathOption(t *testing.T) { + task := &BrowserTask{Task: &Task{}} + task.SetOption(map[string]string{optionLightpandaPath: "/opt/lightpanda"}) + assert.Equal(t, "/opt/lightpanda", task.lightpandaPath()) + + task.SetOption(map[string]string{optionLightpandaPathCamel: "/opt/lightpanda-camel"}) + assert.Equal(t, "/opt/lightpanda-camel", task.lightpandaPath()) + + task.SetOption(map[string]string{}) + assert.Empty(t, task.lightpandaPath()) +} + +func TestBrowserTaskChromePathOption(t *testing.T) { + task := &BrowserTask{Task: &Task{}} + task.SetOption(map[string]string{optionChromePath: "/opt/chrome"}) + assert.Equal(t, "/opt/chrome", task.chromePath()) + + task.SetOption(map[string]string{optionChromePathCamel: "/opt/chrome-camel"}) + assert.Equal(t, "/opt/chrome-camel", task.chromePath()) + + task.SetOption(map[string]string{}) + assert.Empty(t, task.chromePath()) +} + +func TestBrowserTaskResultFallbacks(t *testing.T) { + task := newBrowserTaskForTest() + task.stderr = "stderr failure" + reasons, ok := task.checkResult() + assert.False(t, ok) + assert.Equal(t, []string{"stderr failure"}, reasons) + + task.stderr = "" + reasons, ok = task.checkResult() + assert.False(t, ok) + assert.Equal(t, []string{"browser dial failed"}, reasons) + + task.setReqError("before run failed") + reasons, ok = task.checkResult() + assert.False(t, ok) + assert.Equal(t, []string{"before run failed"}, reasons) +} + +func TestBrowserTaskResultUsesLastExecutedStep(t *testing.T) { + task := newBrowserTaskForTest() + task.exitCode = 1 + task.result = browserDialRun{ + RunID: "run-failed", + Name: "homepage", + Target: "https://example.com", + Status: "FAIL", + Success: false, + DurationUS: 60000000, + FailReason: "timeout", + FailureType: "timeout", + Steps: []browserDialStep{ + { + Seq: 1, + Name: "open", + Action: "goto", + Status: "OK", + DurationUS: 1000, + URL: "https://example.com", + Title: "Example Domain", + Performance: &browserPerformanceMetrics{ + TTFBMS: 12, + LoadingTimeMS: 123, + }, + }, + { + Seq: 2, + Name: "wait button", + Action: "wait_for_selector", + Status: "FAIL", + DurationUS: 60000000, + Title: "Example Domain", + Error: &browserDialError{ + Name: "errorsx.TimeoutError", + Message: "dial script timed out after 60000ms", + Stack: "goroutine 1 [running]", + }, + }, + { + Seq: 3, + Name: "click button", + Action: "click", + Status: "SKIP", + DurationUS: 0, + SkipReason: "previous_step_failed", + }, + }, + } + + _, fields := task.getResults() + assert.Equal(t, int64(2), fields["last_step"]) + assert.NotContains(t, fields, "page_url") + assert.NotContains(t, fields, "page_title") + + rawSteps, ok := fields["steps"].(string) + require.True(t, ok) + assert.Contains(t, rawSteps, "dial script timed out after 60000ms") + assert.NotContains(t, rawSteps, "goroutine 1") + assert.NotContains(t, rawSteps, `"stack"`) + + var steps []browserDialStep + require.NoError(t, json.Unmarshal([]byte(rawSteps), &steps)) + require.Len(t, steps, 3) + require.NotNil(t, steps[0].Performance) + assert.Equal(t, int64(12), steps[0].Performance.TTFBMS) + require.NotNil(t, steps[1].Error) + assert.Empty(t, steps[1].Error.Stack) + assert.Equal(t, "SKIP", steps[2].Status) +} + +func TestBrowserTaskHostNameErrors(t *testing.T) { + task := &BrowserTask{BrowserConfig: "name: homepage\nsteps:\n - action: click\n"} + _, err := task.getHostName() + assert.EqualError(t, err, "browser_config target or goto url should not be empty") + + task.BrowserConfig = "name: homepage\ntarget: http://[::1\nsteps:\n - action: goto\n" + _, err = task.getHostName() + assert.Error(t, err) +} + +func TestBrowserTaskRetryHelpers(t *testing.T) { + task := &BrowserTask{RetryOptions: &BrowserRetryOption{Enabled: true, Count: 1, IntervalSec: 1}} + assert.Equal(t, 5*time.Second, task.retryInterval()) + + task.RetryOptions.IntervalSec = 301 + assert.Equal(t, 300*time.Second, task.retryInterval()) + + task.RetryOptions.Enabled = false + assert.Equal(t, time.Duration(0), task.retryInterval()) + + assert.Equal(t, 0, clampBrowserRetryCount(-1)) + assert.Equal(t, 3, clampBrowserRetryCount(4)) +} + +func TestBrowserTaskSanitizeBrowserConfigFallbacks(t *testing.T) { + assert.Equal(t, "", sanitizeBrowserConfig("")) + + invalid := "name: [" + assert.Equal(t, invalid, sanitizeBrowserConfig(invalid)) + + sanitized := sanitizeBrowserConfig(`name: homepage +target: https://example.com +config_vars: + - name: LOGIN_PASSWORD + secure: true +steps: + - action: goto +`) + assert.Contains(t, sanitized, "LOGIN_PASSWORD") + assert.Contains(t, sanitized, "value: \"\"") +} + +func TestBrowserTaskSmallHelpers(t *testing.T) { + task := &BrowserTask{} + _, err := task.getVariableValue(Variable{}) + assert.EqualError(t, err, "not support") + + task.initTask() + require.NotNil(t, task.Task) + assert.Equal(t, []BrowserViewport{{Width: 1920, Height: 1080}}, task.BrowserWindow.Viewports) + + assert.Equal(t, []string{"example.com"}, dedupBrowserHostNames([]string{"example.com", "", "example.com"})) + assert.Equal(t, []string{}, dedupBrowserHostNames(nil)) + assert.Equal(t, []string{}, dedupBrowserHostNames([]string{"", ""})) +} + +func TestBrowserTaskRunEmbedded(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.BrowserWindow = &BrowserWindowOption{Viewports: []BrowserViewport{{Width: 1366, Height: 768}}} + browserTask.AdvanceOptions = &BrowserAdvanceOption{ + Engine: "chrome", + ScreenshotOnFailure: true, + Headers: map[string]string{"X-Test": "ok"}, + Cookies: []BrowserCookie{{Name: "sid", Value: "abc"}}, + IgnoreHTTPSErrors: true, + ProxyURL: "http://127.0.0.1:7897", + } + + var gotOptions browserrunner.EngineOptions + var gotConfig browserrunner.BrowserConfig + oldChromeFactory := browserEmbeddedChromeEngineFactory + browserEmbeddedChromeEngineFactory = func(_ context.Context, options browserrunner.EngineOptions) (browserrunner.Engine, error) { + gotOptions = options + return &fakeBrowserEngine{config: &gotConfig}, nil + } + t.Cleanup(func() { + browserEmbeddedChromeEngineFactory = oldChromeFactory + }) + + task, err := NewTask("", browserTask) + require.NoError(t, err) + task.SetOption(map[string]string{ + optionChromePath: "/opt/datakit-browser/chrome/chrome", + }) + require.NoError(t, task.Run()) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.NotContains(t, tags, "runner") + assert.Equal(t, "chrome", tags["browser_engine"]) + assert.Equal(t, "1366x768", tags["viewport"]) + assert.Equal(t, int64(1), fields["success"]) + assert.Equal(t, "success", fields["message"]) + assert.Equal(t, int64(1366), fields["viewport_width"]) + assert.Equal(t, int64(768), fields["viewport_height"]) + assert.Equal(t, "/opt/datakit-browser/chrome/chrome", gotOptions.ChromePath) + assert.Equal(t, 1366, gotOptions.ViewportWidth) + assert.Equal(t, 768, gotOptions.ViewportHeight) + assert.Equal(t, "http://127.0.0.1:7897", gotOptions.ProxyURL) + assert.True(t, gotOptions.IgnoreHTTPSErrors) + assert.Equal(t, "ok", gotConfig.Headers["X-Test"]) + require.Len(t, gotConfig.Cookies, 1) + assert.Equal(t, "sid", gotConfig.Cookies[0].Name) + assert.Equal(t, "abc", gotConfig.Cookies[0].Value) +} + +type fakeBrowserEngine struct { + config *browserrunner.BrowserConfig + title string +} + +func (e *fakeBrowserEngine) Close(context.Context) error { return nil } + +func (e *fakeBrowserEngine) Navigate(context.Context, string) error { return nil } + +func (e *fakeBrowserEngine) WaitForSelector(context.Context, string) error { return nil } + +func (e *fakeBrowserEngine) Click(context.Context, string) error { return nil } + +func (e *fakeBrowserEngine) Fill(context.Context, string, string) error { return nil } + +func (e *fakeBrowserEngine) Title(context.Context) (string, error) { + if e.title != "" { + return e.title, nil + } + return "Example Domain", nil +} + +func (e *fakeBrowserEngine) URL(context.Context) (string, error) { return "https://example.com", nil } + +func (e *fakeBrowserEngine) Text(context.Context, string) (string, error) { + return "Example Domain", nil +} + +func (e *fakeBrowserEngine) Eval(context.Context, string) (string, error) { return "", nil } + +func (e *fakeBrowserEngine) CaptureDOM(context.Context) (browserevidence.DomSnapshot, error) { + return browserevidence.DomSnapshot{}, nil +} + +func (e *fakeBrowserEngine) ConsoleEvents() []browserevidence.ConsoleEvent { return nil } + +func (e *fakeBrowserEngine) NetworkEvents() []browserevidence.NetworkEvent { return nil } + +func (e *fakeBrowserEngine) ConfigureBrowser(_ context.Context, config browserrunner.BrowserConfig) error { + if e.config != nil { + *e.config = config + } + return nil +} + +func stubBrowserEngine( + t *testing.T, + gotOptions *browserrunner.EngineOptions, + gotConfig *browserrunner.BrowserConfig, + titles ...string, +) { + t.Helper() + var calls int + stubBrowserEngineWithFactory(t, func(_ context.Context, options browserrunner.EngineOptions) (browserrunner.Engine, error) { + if gotOptions != nil { + *gotOptions = options + } + title := "" + if calls < len(titles) { + title = titles[calls] + } + calls++ + return &fakeBrowserEngine{config: gotConfig, title: title}, nil + }) +} + +func stubBrowserEngineWithFactory(t *testing.T, factory browserrunner.EngineFactory) { + t.Helper() + oldChromeFactory := browserEmbeddedChromeEngineFactory + oldLightpandaFactory := browserEmbeddedLightpandaEngineFactory + browserEmbeddedChromeEngineFactory = factory + browserEmbeddedLightpandaEngineFactory = factory + t.Cleanup(func() { + browserEmbeddedChromeEngineFactory = oldChromeFactory + browserEmbeddedLightpandaEngineFactory = oldLightpandaFactory + }) +} + +func newBrowserTaskForTest() *BrowserTask { + task := &BrowserTask{ + Task: &Task{ + Name: "homepage", + Frequency: "1m", + Tags: map[string]string{"owner": "platform"}, + }, + BrowserConfig: "name: homepage\ntarget: https://example.com\ntimeout_ms: 1000\ntags:\n owner: platform\nsteps:\n - action: goto\n - action: assert_title\n contains: Example\n", + } + task.initTask() + return task +} + +func mustJSON(t *testing.T, value interface{}) string { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return string(data) +} diff --git a/dialtesting/browserdial/chrome/chrome.go b/dialtesting/browserdial/chrome/chrome.go new file mode 100644 index 00000000..34603344 --- /dev/null +++ b/dialtesting/browserdial/chrome/chrome.go @@ -0,0 +1,538 @@ +package chrome + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "sync" + + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/evidence" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/runner" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/util" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/page" + cdpruntime "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/cdproto/security" + "github.com/chromedp/chromedp" +) + +type Engine struct { + ctx context.Context + cancel context.CancelFunc + run func(context.Context, ...chromedp.Action) error + mu sync.Mutex + console []evidence.ConsoleEvent + network []evidence.NetworkEvent + requests map[network.RequestID]requestInfo + responses map[network.RequestID]struct{} +} + +type requestInfo struct { + URL string + Method string + ResourceType string + TraceID string +} + +func NewEngine(ctx context.Context, options runner.EngineOptions) (runner.Engine, error) { + executable, err := resolveExecutable(options.ChromePath) + if err != nil { + return nil, err + } + width, height := viewportSize(options.ViewportWidth, options.ViewportHeight) + allocatorOptions := chromeAllocatorOptions(executable, width, height, options) + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, allocatorOptions...) + tabCtx, tabCancel := chromedp.NewContext(allocCtx) + engine, err := newEngineFromContext(tabCtx, chromedp.Run, func() { + tabCancel() + allocCancel() + }) + if err != nil { + tabCancel() + allocCancel() + return nil, err + } + return engine, nil +} + +func chromeAllocatorOptions(executable string, width int, height int, options runner.EngineOptions) []chromedp.ExecAllocatorOption { + allocatorOptions := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(executable), + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-first-run", true), + chromedp.Flag("no-default-browser-check", true), + chromedp.Flag("ignore-certificate-errors", options.IgnoreHTTPSErrors), + chromedp.WindowSize(width, height), + ) + if strings.TrimSpace(options.ProxyURL) != "" { + allocatorOptions = append(allocatorOptions, chromedp.ProxyServer(options.ProxyURL)) + } + return allocatorOptions +} + +func newEngineFromContext(tabCtx context.Context, run func(context.Context, ...chromedp.Action) error, cancel context.CancelFunc) (*Engine, error) { + engine := &Engine{ + ctx: tabCtx, + run: run, + requests: map[network.RequestID]requestInfo{}, + responses: map[network.RequestID]struct{}{}, + } + engine.cancel = cancel + chromedp.ListenTarget(tabCtx, engine.listen) + if err := engine.run(tabCtx, network.Enable(), cdpruntime.Enable(), chromedp.ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(performanceObserverScript).Do(ctx) + return err + })); err != nil { + engine.cancel() + return nil, err + } + return engine, nil +} + +func (e *Engine) ConfigureBrowser(ctx context.Context, config runner.BrowserConfig) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + actions := []chromedp.Action{} + if config.IgnoreHTTPSErrors { + actions = append(actions, security.SetIgnoreCertificateErrors(true)) + } + if len(config.Headers) > 0 { + headers := network.Headers{} + for key, value := range config.Headers { + if strings.TrimSpace(key) != "" { + headers[key] = value + } + } + actions = append(actions, network.SetExtraHTTPHeaders(headers)) + } + for _, cookie := range config.Cookies { + params := network.SetCookie(cookie.Name, cookie.Value). + WithPath(firstNonEmpty(cookie.Path, "/")). + WithSecure(cookie.Secure). + WithHTTPOnly(cookie.HTTPOnly) + if cookie.Domain != "" { + params = params.WithDomain(cookie.Domain) + } else if config.Target != "" { + params = params.WithURL(config.Target) + } + if sameSite, ok := sameSite(cookie.SameSite); ok { + params = params.WithSameSite(sameSite) + } + actions = append(actions, params) + } + if len(actions) == 0 { + return nil + } + return e.run(actionCtx, actions...) +} + +func viewportSize(width int, height int) (int, int) { + if width <= 0 { + width = 1920 + } + if height <= 0 { + height = 1080 + } + return width, height +} + +func (e *Engine) Close(context.Context) error { + if e.cancel != nil { + e.cancel() + } + return nil +} + +func (e *Engine) Navigate(ctx context.Context, target string) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + return e.run(actionCtx, chromedp.Navigate(target)) +} + +func (e *Engine) WaitForSelector(ctx context.Context, selector string) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + return e.run(actionCtx, chromedp.WaitReady(selector, chromedp.ByQuery)) +} + +func (e *Engine) Click(ctx context.Context, selector string) error { + var ok bool + expression := fmt.Sprintf(`(() => { +const el = document.querySelector(%s); +if (!el) throw new Error("selector not found: %s"); +el.click(); +return true; +})()`, jsString(selector), escapeJSMessage(selector)) + return e.evaluate(ctx, expression, &ok) +} + +func (e *Engine) Fill(ctx context.Context, selector string, value string) error { + var ok bool + expression := fmt.Sprintf(`(() => { +const el = document.querySelector(%s); +if (!el) throw new Error("selector not found: %s"); +if (typeof el.focus === "function") el.focus(); +el.value = %s; +el.dispatchEvent(new Event("input", { bubbles: true })); +el.dispatchEvent(new Event("change", { bubbles: true })); +return true; +})()`, jsString(selector), escapeJSMessage(selector), jsString(value)) + return e.evaluate(ctx, expression, &ok) +} + +func (e *Engine) Title(ctx context.Context) (string, error) { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + var title string + err := e.run(actionCtx, chromedp.Title(&title)) + return title, err +} + +func (e *Engine) URL(ctx context.Context) (string, error) { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + var location string + err := e.run(actionCtx, chromedp.Location(&location)) + return location, err +} + +func (e *Engine) Text(ctx context.Context, selector string) (string, error) { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + var text string + err := e.run(actionCtx, chromedp.Text(selector, &text, chromedp.ByQuery)) + return text, err +} + +func (e *Engine) Eval(ctx context.Context, expression string) (string, error) { + var result any + err := e.evaluate(ctx, expression, &result) + return util.JSONString(result, 8_000), err +} + +func (e *Engine) CaptureDOM(ctx context.Context) (evidence.DomSnapshot, error) { + snapshot := evidence.DomSnapshot{CapturedAt: util.NowISO()} + if currentURL, err := e.URL(ctx); err == nil { + snapshot.URL = currentURL + } + if title, err := e.Title(ctx); err == nil { + snapshot.Title = title + } + + var dom struct { + Text string `json:"text"` + HTML string `json:"html"` + } + expression := `(() => { +const root = document.documentElement; +return { + text: (document.body && (document.body.innerText || document.body.textContent)) || (root && root.textContent) || "", + html: (root && root.outerHTML) || "" +}; +})()` + if err := e.evaluate(ctx, expression, &dom); err != nil { + snapshot.Error = err.Error() + return snapshot, err + } + snapshot.Text = util.Truncate(dom.Text, 16_000) + snapshot.HTML = util.Truncate(dom.HTML, 32_000) + return snapshot, nil +} + +func (e *Engine) CapturePerformance(ctx context.Context) (evidence.PerformanceMetrics, error) { + var metrics evidence.PerformanceMetrics + expression := `(() => { +const nav = performance.getEntriesByType("navigation")[0] || {}; +const lcpEntries = performance.getEntriesByType("largest-contentful-paint") || []; +const cached = window.__browserDialPerf || {}; +const lcp = cached.lcp || (lcpEntries.length ? lcpEntries[lcpEntries.length - 1].startTime : 0); +const cls = cached.cls || (performance.getEntriesByType("layout-shift") || []) + .filter(entry => !entry.hadRecentInput) + .reduce((sum, entry) => sum + (entry.value || 0), 0); +const round = value => Number.isFinite(value) && value > 0 ? Math.round(value) : 0; +return { + ttfb_ms: round((nav.responseStart || 0) - (nav.requestStart || nav.startTime || 0)), + loading_time_ms: round((nav.loadEventEnd || 0) - (nav.startTime || 0)), + lcp_ms: round(lcp), + cls: Number.isFinite(cls) ? cls : 0, + dom_content_loaded_ms: round((nav.domContentLoadedEventEnd || 0) - (nav.startTime || 0)), + load_event_end_ms: round((nav.loadEventEnd || 0) - (nav.startTime || 0)) +}; +})()` + if err := e.evaluate(ctx, expression, &metrics); err != nil { + return evidence.PerformanceMetrics{}, err + } + return metrics, nil +} + +const performanceObserverScript = `(() => { +window.__browserDialPerf = window.__browserDialPerf || { lcp: 0, cls: 0 }; +try { + new PerformanceObserver(list => { + const entries = list.getEntries(); + const last = entries[entries.length - 1]; + if (last) window.__browserDialPerf.lcp = last.startTime || 0; + }).observe({ type: "largest-contentful-paint", buffered: true }); +} catch (_) {} +try { + new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) window.__browserDialPerf.cls += entry.value || 0; + } + }).observe({ type: "layout-shift", buffered: true }); +} catch (_) {} +})()` + +func (e *Engine) CaptureScreenshot(ctx context.Context, path string, fullPage bool) (string, error) { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + var image []byte + if fullPage { + if err := e.run(actionCtx, chromedp.FullScreenshot(&image, 80)); err != nil { + return "", err + } + } else { + if err := e.run(actionCtx, chromedp.CaptureScreenshot(&image)); err != nil { + return "", err + } + } + if err := os.WriteFile(path, image, 0o644); err != nil { + return "", err + } + return path, nil +} + +func (e *Engine) ConsoleEvents() []evidence.ConsoleEvent { + e.mu.Lock() + defer e.mu.Unlock() + if e.console == nil { + return []evidence.ConsoleEvent{} + } + return append([]evidence.ConsoleEvent(nil), e.console...) +} + +func (e *Engine) NetworkEvents() []evidence.NetworkEvent { + e.mu.Lock() + defer e.mu.Unlock() + if e.network == nil { + return []evidence.NetworkEvent{} + } + return append([]evidence.NetworkEvent(nil), e.network...) +} + +func (e *Engine) evaluate(ctx context.Context, expression string, out any) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + return e.run(actionCtx, chromedp.Evaluate(expression, out)) +} + +func (e *Engine) actionContext(ctx context.Context) (context.Context, context.CancelFunc) { + actionCtx, cancel := context.WithCancel(e.ctx) + if deadline, ok := ctx.Deadline(); ok { + actionCtx, cancel = context.WithDeadline(e.ctx, deadline) + } + go func() { + select { + case <-ctx.Done(): + cancel() + case <-actionCtx.Done(): + } + }() + return actionCtx, cancel +} + +func (e *Engine) listen(event any) { + e.mu.Lock() + defer e.mu.Unlock() + switch ev := event.(type) { + case *cdpruntime.EventConsoleAPICalled: + parts := make([]string, 0, len(ev.Args)) + for _, arg := range ev.Args { + if len(arg.Value) > 0 { + parts = append(parts, string(arg.Value)) + } else if arg.Description != "" { + parts = append(parts, arg.Description) + } + } + e.console = append(e.console, evidence.ConsoleEvent{ + Seq: len(e.console) + 1, + Timestamp: util.NowISO(), + Type: string(ev.Type), + Text: util.Truncate(strings.Join(parts, " "), 8_000), + }) + case *network.EventRequestWillBeSent: + if _, seen := e.requests[ev.RequestID]; seen { + return + } + info := requestInfo{ + URL: ev.Request.URL, + Method: ev.Request.Method, + ResourceType: string(ev.Type), + TraceID: extractTraceID(ev.Request.URL, ev.Request.Headers), + } + e.requests[ev.RequestID] = info + e.network = append(e.network, evidence.NetworkEvent{ + Seq: len(e.network) + 1, + Timestamp: util.NowISO(), + Event: "request", + URL: info.URL, + Method: info.Method, + ResourceType: info.ResourceType, + TraceID: info.TraceID, + }) + case *network.EventResponseReceived: + if _, seen := e.responses[ev.RequestID]; seen { + return + } + e.responses[ev.RequestID] = struct{}{} + info := e.requests[ev.RequestID] + e.network = append(e.network, evidence.NetworkEvent{ + Seq: len(e.network) + 1, + Timestamp: util.NowISO(), + Event: "response", + URL: ev.Response.URL, + Method: info.Method, + ResourceType: string(ev.Type), + TraceID: firstNonEmpty(info.TraceID, extractTraceID(ev.Response.URL, ev.Response.RequestHeaders, ev.Response.Headers)), + Status: int64(ev.Response.Status), + }) + case *network.EventLoadingFailed: + info := e.requests[ev.RequestID] + e.network = append(e.network, evidence.NetworkEvent{ + Seq: len(e.network) + 1, + Timestamp: util.NowISO(), + Event: "request_failed", + URL: info.URL, + Method: info.Method, + ResourceType: info.ResourceType, + TraceID: info.TraceID, + Failure: ev.ErrorText, + }) + } +} + +func resolveExecutable(override string) (string, error) { + candidates := []string{} + if override != "" { + candidates = append(candidates, override) + } + if env := os.Getenv("CHROME_EXECUTABLE_PATH"); env != "" { + candidates = append(candidates, env) + } + if goruntime.GOOS == "darwin" { + candidates = append(candidates, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + filepath.Join(os.Getenv("HOME"), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ) + } + for _, name := range []string{"google-chrome", "chromium", "chromium-browser", "chrome"} { + if path, err := exec.LookPath(name); err == nil { + candidates = append(candidates, path) + } + } + + seen := map[string]struct{}{} + var problems []string + for _, candidate := range candidates { + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if err := checkExecutable(candidate); err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", candidate, err)) + continue + } + return candidate, nil + } + if len(problems) > 0 { + return "", fmt.Errorf("no usable chrome executable found (%s)", strings.Join(problems, "; ")) + } + return "", fmt.Errorf("no chrome executable found; set --chrome-path or CHROME_EXECUTABLE_PATH") +} + +func checkExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("is a directory") + } + if info.Size() == 0 { + return fmt.Errorf("file is empty") + } + if goruntime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return fmt.Errorf("file is not executable") + } + return nil +} + +func extractTraceID(rawURL string, headers ...network.Headers) string { + if traceID := traceFromURL(rawURL); traceID != "" { + return traceID + } + for _, headerSet := range headers { + for key, value := range headerSet { + if strings.EqualFold(key, "traceparent") || strings.EqualFold(key, "x-datadog-trace-id") || strings.EqualFold(key, "x-trace-id") { + return fmt.Sprint(value) + } + } + } + return "" +} + +func traceFromURL(rawURL string) string { + for _, marker := range []string{"trace_id=", "traceid=", "traceId="} { + if index := strings.Index(rawURL, marker); index >= 0 { + value := rawURL[index+len(marker):] + if cut := strings.IndexAny(value, "&#"); cut >= 0 { + value = value[:cut] + } + return value + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func sameSite(value string) (network.CookieSameSite, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "strict": + return network.CookieSameSiteStrict, true + case "lax": + return network.CookieSameSiteLax, true + case "none": + return network.CookieSameSiteNone, true + default: + return "", false + } +} + +func jsString(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func escapeJSMessage(value string) string { + return strings.ReplaceAll(value, `"`, `\"`) +} diff --git a/dialtesting/browserdial/chrome/pool.go b/dialtesting/browserdial/chrome/pool.go new file mode 100644 index 00000000..375de3b4 --- /dev/null +++ b/dialtesting/browserdial/chrome/pool.go @@ -0,0 +1,170 @@ +package chrome + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/runner" + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/target" + "github.com/chromedp/chromedp" +) + +// Pool keeps Chrome browser processes warm and gives each run an isolated +// browser context. A worker runs one task at a time. +type Pool struct { + ctx context.Context + workers chan *poolWorker + done chan struct{} + + mu sync.Mutex + all []*poolWorker + closed bool +} + +type poolWorker struct { + ctx context.Context + cancel context.CancelFunc +} + +func NewPool(ctx context.Context, size int, options runner.EngineOptions) (*Pool, error) { + if size <= 0 { + size = 1 + } + pool := &Pool{ + ctx: ctx, + workers: make(chan *poolWorker, size), + done: make(chan struct{}), + } + for i := 0; i < size; i++ { + worker, err := newPoolWorker(ctx, options) + if err != nil { + pool.Close() + return nil, err + } + pool.all = append(pool.all, worker) + pool.workers <- worker + } + return pool, nil +} + +func newPoolWorker(ctx context.Context, options runner.EngineOptions) (*poolWorker, error) { + executable, err := resolveExecutable(options.ChromePath) + if err != nil { + return nil, err + } + width, height := viewportSize(options.ViewportWidth, options.ViewportHeight) + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, chromeAllocatorOptions(executable, width, height, options)...) + browserCtx, browserCancel := chromedp.NewContext(allocCtx) + if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil { + browserCancel() + allocCancel() + return nil, fmt.Errorf("start pooled chrome: %w", err) + } + return &poolWorker{ + ctx: browserCtx, + cancel: func() { + browserCancel() + allocCancel() + }, + }, nil +} + +func (p *Pool) Factory(ctx context.Context, options runner.EngineOptions) (runner.Engine, error) { + select { + case <-p.ctx.Done(): + return nil, p.ctx.Err() + case <-ctx.Done(): + return nil, ctx.Err() + case <-p.done: + return nil, fmt.Errorf("chrome pool is closed") + case worker := <-p.workers: + select { + case <-p.done: + return nil, fmt.Errorf("chrome pool is closed") + default: + } + browserContextID, targetID, err := worker.newIsolatedTarget(ctx) + if err != nil { + p.releaseWorker(worker) + return nil, err + } + taskCtx, taskCancel := chromedp.NewContext(worker.ctx, chromedp.WithTargetID(targetID)) + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + taskCancel() + worker.disposeBrowserContext(browserContextID) + p.releaseWorker(worker) + }) + } + engine, err := newEngineFromContext(taskCtx, chromedp.Run, release) + if err != nil { + release() + return nil, err + } + return engine, nil + } +} + +func (w *poolWorker) newIsolatedTarget(ctx context.Context) (cdp.BrowserContextID, target.ID, error) { + opCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + browser := chromedp.FromContext(w.ctx).Browser + if browser == nil { + return "", "", fmt.Errorf("pooled chrome browser is not initialized") + } + executor := cdp.WithExecutor(opCtx, browser) + browserContextID, err := target.CreateBrowserContext().WithDisposeOnDetach(true).Do(executor) + if err != nil { + return "", "", err + } + targetID, err := target.CreateTarget("about:blank"). + WithBrowserContextID(browserContextID). + WithNewWindow(true). + Do(executor) + if err != nil { + _ = target.DisposeBrowserContext(browserContextID).Do(executor) + return "", "", err + } + return browserContextID, targetID, nil +} + +func (w *poolWorker) disposeBrowserContext(browserContextID cdp.BrowserContextID) { + if browserContextID == "" { + return + } + browser := chromedp.FromContext(w.ctx).Browser + if browser == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = target.DisposeBrowserContext(browserContextID).Do(cdp.WithExecutor(ctx, browser)) +} + +func (p *Pool) releaseWorker(worker *poolWorker) { + select { + case p.workers <- worker: + case <-p.ctx.Done(): + case <-p.done: + } +} + +func (p *Pool) Close() { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return + } + p.closed = true + close(p.done) + workers := append([]*poolWorker(nil), p.all...) + p.mu.Unlock() + + for _, worker := range workers { + worker.cancel() + } +} diff --git a/dialtesting/browserdial/errors/errors.go b/dialtesting/browserdial/errors/errors.go new file mode 100644 index 00000000..f69fdd0a --- /dev/null +++ b/dialtesting/browserdial/errors/errors.go @@ -0,0 +1,69 @@ +package errorsx + +import ( + "fmt" + "runtime/debug" + + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/evidence" +) + +type TimeoutError struct { + TimeoutMS int +} + +func (e TimeoutError) Error() string { + return fmt.Sprintf("dial script timed out after %dms", e.TimeoutMS) +} + +type ScriptLoadError struct { + Message string +} + +func (e ScriptLoadError) Error() string { + return e.Message +} + +type AuthError struct { + Err error +} + +func (e AuthError) Error() string { + if e.Err == nil { + return "authentication failed" + } + return e.Err.Error() +} + +func (e AuthError) Unwrap() error { + return e.Err +} + +func ErrorInfo(err error) *evidence.ErrorInfo { + if err == nil { + return nil + } + + return &evidence.ErrorInfo{ + Name: fmt.Sprintf("%T", err), + Message: err.Error(), + Stack: string(debug.Stack()), + } +} + +func FailureReason(err error, hasFailedStep bool) string { + if err == nil { + return "" + } + switch err.(type) { + case AuthError: + return "auth_error" + case TimeoutError: + return "timeout" + case ScriptLoadError: + return "script_load_error" + } + if hasFailedStep { + return "step_error" + } + return "script_error" +} diff --git a/dialtesting/browserdial/errors/errors_test.go b/dialtesting/browserdial/errors/errors_test.go new file mode 100644 index 00000000..7c799ca5 --- /dev/null +++ b/dialtesting/browserdial/errors/errors_test.go @@ -0,0 +1,51 @@ +package errorsx + +import ( + "errors" + "strings" + "testing" +) + +func TestErrorTypesAndFailureReason(t *testing.T) { + timeout := TimeoutError{TimeoutMS: 123} + if !strings.Contains(timeout.Error(), "123ms") { + t.Fatalf("unexpected timeout error: %s", timeout.Error()) + } + load := ScriptLoadError{Message: "bad script"} + if load.Error() != "bad script" { + t.Fatalf("unexpected script load error: %s", load.Error()) + } + if got := (AuthError{}).Error(); got != "authentication failed" { + t.Fatalf("unexpected empty auth error: %s", got) + } + root := errors.New("no login") + auth := AuthError{Err: root} + if auth.Error() != "no login" || !errors.Is(auth.Unwrap(), root) { + t.Fatalf("unexpected auth unwrap: %s", auth.Error()) + } + + cases := []struct { + err error + hasFailedStep bool + want string + }{ + {nil, false, ""}, + {auth, false, "auth_error"}, + {timeout, false, "timeout"}, + {load, false, "script_load_error"}, + {errors.New("step"), true, "step_error"}, + {errors.New("script"), false, "script_error"}, + } + for _, tc := range cases { + if got := FailureReason(tc.err, tc.hasFailedStep); got != tc.want { + t.Fatalf("FailureReason(%T, %v) = %q, want %q", tc.err, tc.hasFailedStep, got, tc.want) + } + } + info := ErrorInfo(root) + if info == nil || !strings.Contains(info.Name, "errorString") || info.Message != "no login" || info.Stack == "" { + t.Fatalf("unexpected error info: %#v", info) + } + if ErrorInfo(nil) != nil { + t.Fatal("nil error should return nil info") + } +} diff --git a/dialtesting/browserdial/evidence/types.go b/dialtesting/browserdial/evidence/types.go new file mode 100644 index 00000000..f79f2c90 --- /dev/null +++ b/dialtesting/browserdial/evidence/types.go @@ -0,0 +1,88 @@ +package evidence + +type RunStatus string + +const ( + StatusOK RunStatus = "OK" + StatusFail RunStatus = "FAIL" + StatusSkip RunStatus = "SKIP" +) + +type ErrorInfo struct { + Name string `json:"name"` + Message string `json:"message"` + Stack string `json:"stack,omitempty"` +} + +type StepResult struct { + Seq int `json:"seq"` + Name string `json:"name"` + Action string `json:"action,omitempty"` + Selector string `json:"selector,omitempty"` + InputDisplay string `json:"input_display,omitempty"` + ValueFrom string `json:"value_from,omitempty"` + Expected string `json:"expected,omitempty"` + TimeoutMS int `json:"timeout_ms,omitempty"` + Auth bool `json:"auth,omitempty"` + Status RunStatus `json:"status"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DurationUS int64 `json:"duration_us"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Performance *PerformanceMetrics `json:"performance,omitempty"` + Screenshot string `json:"screenshot,omitempty"` + SkipReason string `json:"skip_reason,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` +} + +type RetryRecord struct { + Attempt int `json:"attempt"` + StartedAt string `json:"started_at,omitempty"` + EndedAt string `json:"ended_at,omitempty"` + DurationUS int64 `json:"duration_us,omitempty"` + Status RunStatus `json:"status"` + Success bool `json:"success"` + FailedStep int `json:"failed_step,omitempty"` + FailReason string `json:"fail_reason,omitempty"` + FailureType string `json:"failure_type,omitempty"` + Message string `json:"message,omitempty"` +} + +type ConsoleEvent struct { + Seq int `json:"seq"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Text string `json:"text"` + Location any `json:"location,omitempty"` +} + +type NetworkEvent struct { + Seq int `json:"seq"` + Timestamp string `json:"timestamp"` + Event string `json:"event"` + URL string `json:"url"` + Method string `json:"method,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + TraceID string `json:"trace_id,omitempty"` + Status int64 `json:"status,omitempty"` + Failure string `json:"failure,omitempty"` +} + +type DomSnapshot struct { + CapturedAt string `json:"captured_at"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Text string `json:"text,omitempty"` + HTML string `json:"html,omitempty"` + Error string `json:"error,omitempty"` +} + +type PerformanceMetrics struct { + TTFBMS int64 `json:"ttfb_ms,omitempty"` + LoadingTimeMS int64 `json:"loading_time_ms,omitempty"` + LCPMS int64 `json:"lcp_ms,omitempty"` + CLS float64 `json:"cls,omitempty"` + DOMContentLoadedMS int64 `json:"dom_content_loaded_ms,omitempty"` + LoadEventEndMS int64 `json:"load_event_end_ms,omitempty"` +} diff --git a/dialtesting/browserdial/lightpanda/lightpanda.go b/dialtesting/browserdial/lightpanda/lightpanda.go new file mode 100644 index 00000000..cd3ba8a4 --- /dev/null +++ b/dialtesting/browserdial/lightpanda/lightpanda.go @@ -0,0 +1,587 @@ +package lightpanda + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/evidence" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/runner" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/util" + "github.com/chromedp/cdproto/network" + cdpruntime "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/cdproto/security" + "github.com/chromedp/chromedp" +) + +const defaultHost = "127.0.0.1" + +type Engine struct { + ctx context.Context + cancel context.CancelFunc + run func(context.Context, ...chromedp.Action) error + session *session + mu sync.Mutex + console []evidence.ConsoleEvent + network []evidence.NetworkEvent + requests map[network.RequestID]requestInfo + responses map[network.RequestID]struct{} +} + +type requestInfo struct { + URL string + Method string + ResourceType string + TraceID string +} + +func NewEngine(ctx context.Context, options runner.EngineOptions) (runner.Engine, error) { + executable, err := resolveExecutable(options.LightpandaPath) + if err != nil { + return nil, err + } + activeSession, err := start(ctx, executable, options.StartupTimeout) + if err != nil { + return nil, err + } + cdpURL := activeSession.endpoint + + websocketURL, err := resolveWebsocketURL(ctx, cdpURL) + if err != nil { + activeSession.Close() + return nil, err + } + + allocCtx, allocCancel := chromedp.NewRemoteAllocator(ctx, websocketURL) + tabCtx, tabCancel := chromedp.NewContext(allocCtx) + engine := &Engine{ + ctx: tabCtx, + run: chromedp.Run, + session: activeSession, + requests: map[network.RequestID]requestInfo{}, + responses: map[network.RequestID]struct{}{}, + } + engine.cancel = func() { + tabCancel() + allocCancel() + if engine.session != nil { + engine.session.Close() + } + } + + chromedp.ListenTarget(tabCtx, engine.listen) + if err := engine.run(tabCtx, network.Enable(), cdpruntime.Enable()); err != nil { + engine.cancel() + return nil, err + } + return engine, nil +} + +func (e *Engine) ConfigureBrowser(ctx context.Context, config runner.BrowserConfig) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + actions := []chromedp.Action{} + if config.IgnoreHTTPSErrors { + actions = append(actions, security.SetIgnoreCertificateErrors(true)) + } + if len(config.Headers) > 0 { + headers := network.Headers{} + for key, value := range config.Headers { + if strings.TrimSpace(key) != "" { + headers[key] = value + } + } + actions = append(actions, network.SetExtraHTTPHeaders(headers)) + } + for _, cookie := range config.Cookies { + params := network.SetCookie(cookie.Name, cookie.Value). + WithPath(firstNonEmpty(cookie.Path, "/")). + WithSecure(cookie.Secure). + WithHTTPOnly(cookie.HTTPOnly) + if cookie.Domain != "" { + params = params.WithDomain(cookie.Domain) + } else if config.Target != "" { + params = params.WithURL(config.Target) + } + if sameSite, ok := sameSite(cookie.SameSite); ok { + params = params.WithSameSite(sameSite) + } + actions = append(actions, params) + } + if len(actions) == 0 { + return nil + } + return e.run(actionCtx, actions...) +} + +func (e *Engine) Close(context.Context) error { + if e.cancel != nil { + e.cancel() + } + return nil +} + +func (e *Engine) Navigate(ctx context.Context, target string) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + return e.run(actionCtx, chromedp.Navigate(target)) +} + +func (e *Engine) WaitForSelector(ctx context.Context, selector string) error { + for { + var exists bool + expression := fmt.Sprintf(`document.querySelector(%s) !== null`, jsString(selector)) + if err := e.evaluate(ctx, expression, &exists); err != nil { + return err + } + if exists { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +func (e *Engine) Click(ctx context.Context, selector string) error { + var ok bool + expression := fmt.Sprintf(`(() => { +const el = document.querySelector(%s); +if (!el) throw new Error("selector not found: %s"); +el.click(); +return true; +})()`, jsString(selector), escapeJSMessage(selector)) + return e.evaluate(ctx, expression, &ok) +} + +func (e *Engine) Fill(ctx context.Context, selector string, value string) error { + var ok bool + expression := fmt.Sprintf(`(() => { +const el = document.querySelector(%s); +if (!el) throw new Error("selector not found: %s"); +if (typeof el.focus === "function") el.focus(); +el.value = %s; +el.dispatchEvent(new Event("input", { bubbles: true })); +el.dispatchEvent(new Event("change", { bubbles: true })); +return true; +})()`, jsString(selector), escapeJSMessage(selector), jsString(value)) + return e.evaluate(ctx, expression, &ok) +} + +func (e *Engine) Title(ctx context.Context) (string, error) { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + var title string + err := e.run(actionCtx, chromedp.Title(&title)) + return title, err +} + +func (e *Engine) URL(ctx context.Context) (string, error) { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + var location string + err := e.run(actionCtx, chromedp.Location(&location)) + return location, err +} + +func (e *Engine) Text(ctx context.Context, selector string) (string, error) { + var text string + expression := fmt.Sprintf(`(() => { +const el = document.querySelector(%s); +if (!el) throw new Error("selector not found: %s"); +return el.innerText || el.textContent || ""; +})()`, jsString(selector), escapeJSMessage(selector)) + err := e.evaluate(ctx, expression, &text) + return text, err +} + +func (e *Engine) Eval(ctx context.Context, expression string) (string, error) { + var result any + err := e.evaluate(ctx, expression, &result) + return util.JSONString(result, 8_000), err +} + +func (e *Engine) CaptureDOM(ctx context.Context) (evidence.DomSnapshot, error) { + snapshot := evidence.DomSnapshot{CapturedAt: util.NowISO()} + if currentURL, err := e.URL(ctx); err == nil { + snapshot.URL = currentURL + } + if title, err := e.Title(ctx); err == nil { + snapshot.Title = title + } + + var dom struct { + Text string `json:"text"` + HTML string `json:"html"` + } + expression := `(() => { +const root = document.documentElement; +return { + text: (document.body && (document.body.innerText || document.body.textContent)) || (root && root.textContent) || "", + html: (root && root.outerHTML) || "" +}; +})()` + if err := e.evaluate(ctx, expression, &dom); err != nil { + snapshot.Error = err.Error() + return snapshot, err + } + snapshot.Text = util.Truncate(dom.Text, 16_000) + snapshot.HTML = util.Truncate(dom.HTML, 32_000) + return snapshot, nil +} + +func (e *Engine) ConsoleEvents() []evidence.ConsoleEvent { + e.mu.Lock() + defer e.mu.Unlock() + if e.console == nil { + return []evidence.ConsoleEvent{} + } + return append([]evidence.ConsoleEvent(nil), e.console...) +} + +func (e *Engine) NetworkEvents() []evidence.NetworkEvent { + e.mu.Lock() + defer e.mu.Unlock() + if e.network == nil { + return []evidence.NetworkEvent{} + } + return append([]evidence.NetworkEvent(nil), e.network...) +} + +func (e *Engine) evaluate(ctx context.Context, expression string, out any) error { + actionCtx, cancel := e.actionContext(ctx) + defer cancel() + return e.run(actionCtx, chromedp.Evaluate(expression, out)) +} + +func (e *Engine) actionContext(ctx context.Context) (context.Context, context.CancelFunc) { + actionCtx, cancel := context.WithCancel(e.ctx) + if deadline, ok := ctx.Deadline(); ok { + actionCtx, cancel = context.WithDeadline(e.ctx, deadline) + } + go func() { + select { + case <-ctx.Done(): + cancel() + case <-actionCtx.Done(): + } + }() + return actionCtx, cancel +} + +func (e *Engine) listen(event any) { + e.mu.Lock() + defer e.mu.Unlock() + switch ev := event.(type) { + case *cdpruntime.EventConsoleAPICalled: + parts := make([]string, 0, len(ev.Args)) + for _, arg := range ev.Args { + if len(arg.Value) > 0 { + parts = append(parts, string(arg.Value)) + } else if arg.Description != "" { + parts = append(parts, arg.Description) + } + } + e.console = append(e.console, evidence.ConsoleEvent{ + Seq: len(e.console) + 1, + Timestamp: util.NowISO(), + Type: string(ev.Type), + Text: util.Truncate(strings.Join(parts, " "), 8_000), + }) + case *network.EventRequestWillBeSent: + if _, seen := e.requests[ev.RequestID]; seen { + return + } + info := requestInfo{ + URL: ev.Request.URL, + Method: ev.Request.Method, + ResourceType: string(ev.Type), + TraceID: extractTraceID(ev.Request.URL, ev.Request.Headers), + } + e.requests[ev.RequestID] = info + e.network = append(e.network, evidence.NetworkEvent{ + Seq: len(e.network) + 1, + Timestamp: util.NowISO(), + Event: "request", + URL: info.URL, + Method: info.Method, + ResourceType: info.ResourceType, + TraceID: info.TraceID, + }) + case *network.EventResponseReceived: + if _, seen := e.responses[ev.RequestID]; seen { + return + } + e.responses[ev.RequestID] = struct{}{} + info := e.requests[ev.RequestID] + status := int64(ev.Response.Status) + e.network = append(e.network, evidence.NetworkEvent{ + Seq: len(e.network) + 1, + Timestamp: util.NowISO(), + Event: "response", + URL: ev.Response.URL, + Method: info.Method, + ResourceType: string(ev.Type), + TraceID: firstNonEmpty(info.TraceID, extractTraceID(ev.Response.URL, ev.Response.RequestHeaders, ev.Response.Headers)), + Status: status, + }) + case *network.EventLoadingFailed: + info := e.requests[ev.RequestID] + e.network = append(e.network, evidence.NetworkEvent{ + Seq: len(e.network) + 1, + Timestamp: util.NowISO(), + Event: "request_failed", + URL: info.URL, + Method: info.Method, + ResourceType: info.ResourceType, + TraceID: info.TraceID, + Failure: ev.ErrorText, + }) + } +} + +type session struct { + endpoint string + cmd *exec.Cmd + cancel context.CancelFunc + done chan error + logs *limitedBuffer +} + +func start(parent context.Context, executable string, startupTimeout time.Duration) (*session, error) { + if startupTimeout <= 0 { + startupTimeout = 5 * time.Second + } + port, err := freePort(defaultHost) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("http://%s:%d", defaultHost, port) + procCtx, cancel := context.WithCancel(parent) + logs := &limitedBuffer{limit: 8_000} + cmd := exec.CommandContext(procCtx, executable, "serve", "--host", defaultHost, "--port", strconv.Itoa(port)) + cmd.Stdout = logs + cmd.Stderr = logs + if err := cmd.Start(); err != nil { + cancel() + return nil, err + } + s := &session{ + endpoint: endpoint, + cmd: cmd, + cancel: cancel, + done: make(chan error, 1), + logs: logs, + } + go func() { + s.done <- cmd.Wait() + }() + if err := waitReady(parent, endpoint, startupTimeout, s); err != nil { + s.Close() + return nil, err + } + return s, nil +} + +func (s *session) Close() { + if s == nil { + return + } + s.cancel() + select { + case <-s.done: + case <-time.After(2 * time.Second): + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + } +} + +func waitReady(parent context.Context, endpoint string, timeout time.Duration, s *session) error { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + if _, err := resolveWebsocketURL(parent, endpoint); err == nil { + return nil + } + select { + case err := <-s.done: + return fmt.Errorf("lightpanda exited before CDP was ready: %v\n%s", err, s.logs.String()) + case <-deadline.C: + return fmt.Errorf("lightpanda CDP server did not become ready at %s\n%s", endpoint, s.logs.String()) + case <-ticker.C: + case <-parent.Done(): + return parent.Err() + } + } +} + +func resolveExecutable(override string) (string, error) { + candidates := []string{} + if override != "" { + candidates = append(candidates, override) + } + if env := os.Getenv("LIGHTPANDA_EXECUTABLE_PATH"); env != "" { + candidates = append(candidates, env) + } + if path, err := exec.LookPath("lightpanda"); err == nil { + candidates = append(candidates, path) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cache", "lightpanda-node", "lightpanda")) + } + + seen := map[string]struct{}{} + var problems []string + for _, candidate := range candidates { + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if err := checkExecutable(candidate); err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", candidate, err)) + continue + } + return candidate, nil + } + + if len(problems) > 0 { + return "", fmt.Errorf("no usable lightpanda executable found (%s)", strings.Join(problems, "; ")) + } + return "", fmt.Errorf("no lightpanda executable found; set LIGHTPANDA_EXECUTABLE_PATH or install lightpanda in PATH") +} + +func checkExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("is a directory") + } + if info.Size() == 0 { + return fmt.Errorf("file is empty") + } + if goruntime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return fmt.Errorf("file is not executable") + } + return nil +} + +func resolveWebsocketURL(ctx context.Context, raw string) (string, error) { + if strings.HasPrefix(raw, "ws://") || strings.HasPrefix(raw, "wss://") { + return raw, nil + } + parsed, err := url.Parse(raw) + if err != nil { + return "", err + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/json/version" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s returned %s", parsed.String(), resp.Status) + } + var payload struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + if payload.WebSocketDebuggerURL == "" { + return "", fmt.Errorf("%s did not return webSocketDebuggerUrl", parsed.String()) + } + return payload.WebSocketDebuggerURL, nil +} + +func freePort(host string) (int, error) { + listener, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + return 0, err + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func jsString(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func escapeJSMessage(value string) string { + return strings.ReplaceAll(value, `"`, `\"`) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func sameSite(value string) (network.CookieSameSite, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "strict": + return network.CookieSameSiteStrict, true + case "lax": + return network.CookieSameSiteLax, true + case "none": + return network.CookieSameSiteNone, true + default: + return "", false + } +} + +type limitedBuffer struct { + mu sync.Mutex + limit int + buf bytes.Buffer +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + n, err := b.buf.Write(p) + if b.buf.Len() > b.limit { + content := b.buf.Bytes() + keep := append([]byte(nil), content[len(content)-b.limit:]...) + b.buf.Reset() + _, _ = b.buf.Write(keep) + } + return n, err +} + +func (b *limitedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} diff --git a/dialtesting/browserdial/lightpanda/trace.go b/dialtesting/browserdial/lightpanda/trace.go new file mode 100644 index 00000000..017805c3 --- /dev/null +++ b/dialtesting/browserdial/lightpanda/trace.go @@ -0,0 +1,123 @@ +package lightpanda + +import ( + "fmt" + "net/url" + "strings" + + "github.com/chromedp/cdproto/network" +) + +var traceIDKeys = map[string]struct{}{ + "traceid": {}, + "trace-id": {}, + "traceparent": {}, + "x-trace-id": {}, + "x-traceid": {}, + "x-b3-traceid": {}, + "x-datadog-trace-id": {}, + "dd-trace-id": {}, + "uber-trace-id": {}, +} + +func extractTraceID(rawURL string, headerSets ...network.Headers) string { + for _, headers := range headerSets { + if traceID := extractTraceIDFromHeaders(headers); traceID != "" { + return traceID + } + } + return extractTraceIDFromURL(rawURL) +} + +func extractTraceIDFromHeaders(headers network.Headers) string { + for key, value := range headers { + if _, ok := traceIDKeys[normalizeTraceKey(key)]; !ok { + continue + } + if traceID := normalizeTraceValue(key, headerString(value)); traceID != "" { + return traceID + } + } + return "" +} + +func extractTraceIDFromURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + for key, values := range parsed.Query() { + if _, ok := traceIDKeys[normalizeTraceKey(key)]; !ok { + continue + } + for _, value := range values { + if traceID := normalizeTraceValue(key, value); traceID != "" { + return traceID + } + } + } + return "" +} + +func normalizeTraceKey(key string) string { + key = strings.ToLower(strings.TrimSpace(key)) + key = strings.ReplaceAll(key, "_", "-") + return key +} + +func normalizeTraceValue(key string, value string) string { + value = strings.TrimSpace(value) + value = strings.Trim(value, `"`) + if value == "" { + return "" + } + if index := strings.Index(value, ","); index > 0 { + value = strings.TrimSpace(value[:index]) + } + switch normalizeTraceKey(key) { + case "traceparent": + return traceIDFromTraceparent(value) + case "uber-trace-id": + parts := strings.Split(value, ":") + if len(parts) > 0 { + return cleanTraceID(parts[0]) + } + return "" + default: + return cleanTraceID(value) + } +} + +func traceIDFromTraceparent(value string) string { + parts := strings.Split(value, "-") + if len(parts) >= 2 { + return cleanTraceID(parts[1]) + } + return cleanTraceID(value) +} + +func cleanTraceID(value string) string { + value = strings.TrimSpace(value) + value = strings.Trim(value, `"`) + value = strings.Trim(value, "'") + return value +} + +func headerString(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case []string: + return strings.Join(typed, ",") + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, fmt.Sprint(item)) + } + return strings.Join(parts, ",") + default: + return fmt.Sprint(value) + } +} diff --git a/dialtesting/browserdial/lightpanda/trace_test.go b/dialtesting/browserdial/lightpanda/trace_test.go new file mode 100644 index 00000000..6a667694 --- /dev/null +++ b/dialtesting/browserdial/lightpanda/trace_test.go @@ -0,0 +1,65 @@ +package lightpanda + +import ( + "testing" + + "github.com/chromedp/cdproto/network" +) + +func TestExtractTraceIDFromTraceparentHeader(t *testing.T) { + got := extractTraceID("https://example.com", network.Headers{ + "Traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + }) + if got != "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Fatalf("trace id = %q", got) + } +} + +func TestExtractTraceIDFromQuery(t *testing.T) { + got := extractTraceID("https://example.com/api?foo=bar&trace_id=query-trace-1") + if got != "query-trace-1" { + t.Fatalf("trace id = %q", got) + } +} + +func TestExtractTraceIDPrefersHeaders(t *testing.T) { + got := extractTraceID("https://example.com/api?traceid=query-trace", network.Headers{ + "x-b3-traceid": "header-trace", + }) + if got != "header-trace" { + t.Fatalf("trace id = %q", got) + } +} + +func TestExtractTraceIDFromUberTraceID(t *testing.T) { + got := extractTraceID("https://example.com", network.Headers{ + "uber-trace-id": "abc123:def456:0:1", + }) + if got != "abc123" { + t.Fatalf("trace id = %q", got) + } +} + +func TestTraceHelpersCoverValueShapes(t *testing.T) { + if got := extractTraceID("://bad-url", network.Headers{}); got != "" { + t.Fatalf("bad URL trace id = %q", got) + } + if got := extractTraceID("https://example.com?x_trace_id=%27quoted%27"); got != "quoted" { + t.Fatalf("quoted query trace id = %q", got) + } + if got := extractTraceIDFromHeaders(network.Headers{"x-trace-id": []string{"a", "b"}}); got != "a" { + t.Fatalf("slice header trace id = %q", got) + } + if got := extractTraceIDFromHeaders(network.Headers{"dd-trace-id": []any{"c", "d"}}); got != "c" { + t.Fatalf("any slice header trace id = %q", got) + } + if got := extractTraceIDFromHeaders(network.Headers{"x-traceid": nil}); got != "" { + t.Fatalf("nil header trace id = %q", got) + } + if got := normalizeTraceValue("traceparent", "4bf92f3577b34da6a3ce929d0e0e4736"); got != "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Fatalf("fallback traceparent trace id = %q", got) + } + if got := headerString(123); got != "123" { + t.Fatalf("default header string = %q", got) + } +} diff --git a/dialtesting/browserdial/runner/runner.go b/dialtesting/browserdial/runner/runner.go new file mode 100644 index 00000000..a082ebd4 --- /dev/null +++ b/dialtesting/browserdial/runner/runner.go @@ -0,0 +1,850 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + errorsx "github.com/GuanceCloud/cliutils/dialtesting/browserdial/errors" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/evidence" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/script" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/util" +) + +type Engine interface { + Close(context.Context) error + Navigate(context.Context, string) error + WaitForSelector(context.Context, string) error + Click(context.Context, string) error + Fill(context.Context, string, string) error + Title(context.Context) (string, error) + URL(context.Context) (string, error) + Text(context.Context, string) (string, error) + Eval(context.Context, string) (string, error) + CaptureDOM(context.Context) (evidence.DomSnapshot, error) + ConsoleEvents() []evidence.ConsoleEvent + NetworkEvents() []evidence.NetworkEvent +} + +type BrowserConfigurator interface { + ConfigureBrowser(context.Context, BrowserConfig) error +} + +type Screenshotter interface { + CaptureScreenshot(context.Context, string, bool) (string, error) +} + +type PerformanceCollector interface { + CapturePerformance(context.Context) (evidence.PerformanceMetrics, error) +} + +type BrowserConfig struct { + Target string + Headers map[string]string + Cookies []BrowserCookie + IgnoreHTTPSErrors bool +} + +type BrowserCookie struct { + Name string + Value string + Domain string + Path string + Secure bool + HTTPOnly bool + SameSite string +} + +type EngineFactory func(context.Context, EngineOptions) (Engine, error) + +type EngineOptions struct { + LightpandaPath string + ChromePath string + ScreenshotDir string + RunID string + ViewportWidth int + ViewportHeight int + ProxyURL string + IgnoreHTTPSErrors bool + StartupTimeout time.Duration +} + +type Options struct { + ScriptPath string + Name string + TimeoutMS int + Tags map[string]string + EngineName string + LightpandaPath string + ChromePath string + StartupTimeout time.Duration + ScreenshotOnFailure bool + ScreenshotPerStep bool + ScreenshotDir string + ViewportWidth int + ViewportHeight int + Headers map[string]string + Cookies []script.Cookie + IgnoreHTTPSErrors bool + ProxyURL string + RetryCount int + EngineFactory EngineFactory + IgnoredOptionLogger func(option string, reason string) +} + +type Result struct { + RunID string `json:"run_id"` + Name string `json:"name"` + Engine string `json:"engine,omitempty"` + Target string `json:"target,omitempty"` + PostURL string `json:"post_url,omitempty"` + ScriptPath string `json:"script_path"` + Status evidence.RunStatus `json:"status"` + Success bool `json:"success"` + StartedAt time.Time `json:"-"` + StartedAtText string `json:"started_at"` + EndedAtText string `json:"ended_at"` + DurationUS int64 `json:"duration_us"` + TimeoutMS int `json:"timeout_ms"` + ViewportWidth int `json:"viewport_width,omitempty"` + ViewportHeight int `json:"viewport_height,omitempty"` + Steps []evidence.StepResult `json:"steps"` + Tags map[string]string `json:"tags"` + Metadata map[string]any `json:"metadata"` + ConsoleEvents []evidence.ConsoleEvent `json:"console_events"` + NetworkEvents []evidence.NetworkEvent `json:"network_events"` + TraceIDs []string `json:"trace_ids,omitempty"` + DomSnapshot *evidence.DomSnapshot `json:"dom_snapshot,omitempty"` + Performance *evidence.PerformanceMetrics `json:"performance,omitempty"` + Error *evidence.ErrorInfo `json:"error,omitempty"` + FailReason string `json:"fail_reason,omitempty"` + FailureType string `json:"failure_type,omitempty"` + Attempt int `json:"attempt,omitempty"` + MaxAttempts int `json:"max_attempts,omitempty"` + RetryRecords []evidence.RetryRecord `json:"retry_records,omitempty"` +} + +func Run(ctx context.Context, options Options) Result { + start := time.Now().UTC() + runID := util.NewRunID() + resolvedPath := options.ScriptPath + if options.ScriptPath != "" { + resolvedPath, _ = filepath.Abs(options.ScriptPath) + } + name := firstNonEmpty(options.Name, filepath.Base(options.ScriptPath)) + timeoutMS := options.TimeoutMS + if timeoutMS <= 0 { + timeoutMS = 30_000 + } + baseTags := cloneStringMap(options.Tags) + + loaded, err := script.Load(resolvedPath) + if err != nil { + return withViewport(failureResult(runID, name, resolvedPath, timeoutMS, start, baseTags, nil, err, "script_load_error"), options) + } + + return runLoaded(ctx, loaded, options, runID, resolvedPath, start, baseTags) +} + +func RunScript(ctx context.Context, loaded script.Script, options Options) Result { + start := time.Now().UTC() + runID := util.NewRunID() + baseTags := cloneStringMap(options.Tags) + if err := loaded.Validate(); err != nil { + name := firstNonEmpty(options.Name, loaded.Name, options.ScriptPath, "browser-task") + timeoutMS := options.TimeoutMS + if timeoutMS <= 0 { + timeoutMS = 30_000 + } + return withViewport(failureResult(runID, name, options.ScriptPath, timeoutMS, start, baseTags, &loaded, err, "script_load_error"), options) + } + return runLoaded(ctx, loaded, options, runID, options.ScriptPath, start, baseTags) +} + +func runLoaded(ctx context.Context, loaded script.Script, options Options, runID string, resolvedPath string, start time.Time, baseTags map[string]string) Result { + nameFallback := "browser-task" + if options.ScriptPath != "" { + nameFallback = filepath.Base(options.ScriptPath) + } + name := firstNonEmpty(options.Name, firstNonEmpty(loaded.Name, nameFallback)) + timeoutMS := options.TimeoutMS + if timeoutMS <= 0 { + timeoutMS = 30_000 + } + if loaded.TimeoutMS > 0 && options.TimeoutMS <= 0 { + timeoutMS = loaded.TimeoutMS + } + tags := mergeTags(baseTags, loaded.Tags) + vars := configVarMap(loaded.ConfigVars) + browserConfig, err := browserConfig(loaded, options, vars) + if err != nil { + return withViewport(failureResult(runID, name, resolvedPath, timeoutMS, start, tags, &loaded, err, "script_load_error"), options) + } + factory := options.EngineFactory + if factory == nil { + return withViewport(failureResult(runID, name, resolvedPath, timeoutMS, start, tags, &loaded, fmt.Errorf("runner engine factory is not configured"), "runner_error"), options) + } + engineName := normalizedEngineName(options.EngineName) + if engineName == "lightpanda" && strings.TrimSpace(loaded.ProxyURL) != "" { + logIgnoredOption(options, "proxy_url", "lightpanda does not support proxy_url") + } + maxAttempts := options.RetryCount + 1 + if maxAttempts < 1 { + maxAttempts = 1 + } + + var last Result + retryRecords := make([]evidence.RetryRecord, 0, maxAttempts) + for attempt := 1; attempt <= maxAttempts; attempt++ { + attemptStart := start + if attempt > 1 { + attemptStart = time.Now().UTC() + } + last = runAttempt(ctx, loaded, options, runID, resolvedPath, attemptStart, tags, vars, browserConfig, name, timeoutMS, engineName, attempt, maxAttempts) + if maxAttempts > 1 { + retryRecords = append(retryRecords, retryRecordFromResult(last)) + if len(retryRecords) > 1 || !last.Success { + last.RetryRecords = retryRecords + } + } + if last.Success { + return last + } + } + return last +} + +func retryRecordFromResult(result Result) evidence.RetryRecord { + record := evidence.RetryRecord{ + Attempt: result.Attempt, + StartedAt: result.StartedAtText, + EndedAt: result.EndedAtText, + DurationUS: result.DurationUS, + Status: result.Status, + Success: result.Success, + FailReason: result.FailReason, + FailureType: result.FailureType, + } + if result.Error != nil { + record.Message = result.Error.Message + } + for _, step := range result.Steps { + if step.Status != evidence.StatusFail { + continue + } + record.FailedStep = step.Seq + if record.Message == "" && step.Error != nil { + record.Message = step.Error.Message + } + break + } + return record +} + +func runAttempt(ctx context.Context, loaded script.Script, options Options, runID string, resolvedPath string, start time.Time, tags map[string]string, vars map[string]string, browserConfig BrowserConfig, name string, timeoutMS int, engineName string, attempt int, maxAttempts int) Result { + proxyURL := engineProxyURL(engineName, options.ProxyURL, loaded.ProxyURL) + engineCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond) + defer cancel() + + engine, err := options.EngineFactory(engineCtx, EngineOptions{ + LightpandaPath: options.LightpandaPath, + ChromePath: options.ChromePath, + ScreenshotDir: options.ScreenshotDir, + RunID: runID, + ViewportWidth: options.ViewportWidth, + ViewportHeight: options.ViewportHeight, + ProxyURL: proxyURL, + IgnoreHTTPSErrors: browserConfig.IgnoreHTTPSErrors, + StartupTimeout: options.StartupTimeout, + }) + if err != nil { + reason := "runner_error" + if errors.Is(err, context.DeadlineExceeded) || errors.Is(engineCtx.Err(), context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: timeoutMS} + reason = "timeout" + } + result := failureResult(runID, name, resolvedPath, timeoutMS, start, tags, &loaded, err, reason) + result.Engine = engineName + result.Attempt = attempt + result.MaxAttempts = maxAttempts + result.ViewportWidth = options.ViewportWidth + result.ViewportHeight = options.ViewportHeight + return result + } + defer engine.Close(context.Background()) + if configurator, ok := engine.(BrowserConfigurator); ok { + if err := configurator.ConfigureBrowser(engineCtx, browserConfig); err != nil { + reason := "runner_error" + if errors.Is(err, context.DeadlineExceeded) || errors.Is(engineCtx.Err(), context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: timeoutMS} + reason = "timeout" + } + result := failureResult(runID, name, resolvedPath, timeoutMS, start, tags, &loaded, err, reason) + result.Engine = engineName + result.Attempt = attempt + result.MaxAttempts = maxAttempts + result.ViewportWidth = options.ViewportWidth + result.ViewportHeight = options.ViewportHeight + return result + } + } + + steps, runErr := executeSteps(engineCtx, engine, loaded, timeoutMS, vars, engineScreenshotOptions(engineName, screenshotOptions{ + OnFailure: options.ScreenshotOnFailure, + PerStep: options.ScreenshotPerStep, + Dir: options.ScreenshotDir, + RunID: runID, + })) + var dom *evidence.DomSnapshot + if runErr != nil { + if snapshot, err := engine.CaptureDOM(context.Background()); err == nil { + dom = &snapshot + } else { + dom = &evidence.DomSnapshot{CapturedAt: util.NowISO(), Error: err.Error()} + } + } + consoleEvents := engine.ConsoleEvents() + if consoleEvents == nil { + consoleEvents = []evidence.ConsoleEvent{} + } + networkEvents := engine.NetworkEvents() + if networkEvents == nil { + networkEvents = []evidence.NetworkEvent{} + } + var performance *evidence.PerformanceMetrics + if collector, ok := engine.(PerformanceCollector); ok { + perfCtx, perfCancel := context.WithTimeout(context.Background(), 2*time.Second) + if metrics, err := collector.CapturePerformance(perfCtx); err == nil && !isZeroPerformance(metrics) { + performance = &metrics + } + perfCancel() + } + + result := Result{ + RunID: runID, + Name: name, + Engine: engineName, + Target: loaded.Target, + PostURL: loaded.PostURL, + ScriptPath: resolvedPath, + Status: evidence.StatusOK, + Success: true, + StartedAt: start, + StartedAtText: start.Format(time.RFC3339Nano), + EndedAtText: time.Now().UTC().Format(time.RFC3339Nano), + DurationUS: time.Since(start).Microseconds(), + TimeoutMS: timeoutMS, + ViewportWidth: options.ViewportWidth, + ViewportHeight: options.ViewportHeight, + Steps: steps, + Tags: withNodeName(tags), + Metadata: loaded.Metadata, + ConsoleEvents: consoleEvents, + NetworkEvents: networkEvents, + TraceIDs: collectTraceIDs(networkEvents), + DomSnapshot: dom, + Performance: performance, + Attempt: attempt, + MaxAttempts: maxAttempts, + } + + if runErr != nil { + hasFailedStep := false + for _, step := range steps { + if step.Status == evidence.StatusFail { + hasFailedStep = true + break + } + } + result.Status = evidence.StatusFail + result.Success = false + result.Error = errorsx.ErrorInfo(runErr) + result.FailReason = errorsx.FailureReason(runErr, hasFailedStep) + result.FailureType = classifyFailureType(runErr, steps, result.FailReason) + } + return result +} + +func withViewport(result Result, options Options) Result { + result.ViewportWidth = options.ViewportWidth + result.ViewportHeight = options.ViewportHeight + return result +} + +func isZeroPerformance(metrics evidence.PerformanceMetrics) bool { + return metrics.TTFBMS == 0 && + metrics.LoadingTimeMS == 0 && + metrics.LCPMS == 0 && + metrics.CLS == 0 && + metrics.DOMContentLoadedMS == 0 && + metrics.LoadEventEndMS == 0 +} + +func browserConfig(loaded script.Script, options Options, vars map[string]string) (BrowserConfig, error) { + headers := cloneStringMap(loaded.Headers) + for key, value := range options.Headers { + headers[key] = value + } + cookies := make([]BrowserCookie, 0, len(loaded.Cookies)+len(options.Cookies)) + for _, cookie := range append(append([]script.Cookie{}, loaded.Cookies...), options.Cookies...) { + value := cookie.Value + if strings.TrimSpace(cookie.ValueFrom) != "" { + var ok bool + value, ok = vars[strings.TrimSpace(cookie.ValueFrom)] + if !ok { + return BrowserConfig{}, fmt.Errorf("cookie variable %q is required", cookie.ValueFrom) + } + } + cookies = append(cookies, BrowserCookie{ + Name: strings.TrimSpace(cookie.Name), + Value: value, + Domain: strings.TrimSpace(cookie.Domain), + Path: firstNonEmpty(strings.TrimSpace(cookie.Path), "/"), + Secure: cookie.Secure, + HTTPOnly: cookie.HTTPOnly, + SameSite: strings.TrimSpace(cookie.SameSite), + }) + } + return BrowserConfig{ + Target: cookieTarget(loaded), + Headers: headers, + Cookies: cookies, + IgnoreHTTPSErrors: loaded.IgnoreHTTPSErrors || options.IgnoreHTTPSErrors, + }, nil +} + +func cookieTarget(loaded script.Script) string { + if strings.TrimSpace(loaded.Target) != "" { + return loaded.Target + } + for _, step := range stepPlans(loaded) { + if step.Step.Action == "goto" && strings.TrimSpace(step.Step.URL) != "" { + return step.Step.URL + } + } + return "" +} + +type screenshotOptions struct { + OnFailure bool + PerStep bool + Dir string + RunID string +} + +func engineProxyURL(engineName string, values ...string) string { + if engineName == "lightpanda" { + return "" + } + return firstNonEmpty(values...) +} + +func logIgnoredOption(options Options, option string, reason string) { + if options.IgnoredOptionLogger == nil { + return + } + options.IgnoredOptionLogger(option, reason) +} + +func engineScreenshotOptions(engineName string, options screenshotOptions) screenshotOptions { + if engineName == "lightpanda" { + return screenshotOptions{Dir: options.Dir, RunID: options.RunID} + } + return options +} + +func collectTraceIDs(events []evidence.NetworkEvent) []string { + traceIDs := []string{} + seen := map[string]struct{}{} + for _, event := range events { + if event.TraceID == "" { + continue + } + if _, ok := seen[event.TraceID]; ok { + continue + } + seen[event.TraceID] = struct{}{} + traceIDs = append(traceIDs, event.TraceID) + } + return traceIDs +} + +func executeSteps(ctx context.Context, engine Engine, s script.Script, timeoutMS int, vars map[string]string, screenshots screenshotOptions) ([]evidence.StepResult, error) { + plans := stepPlans(s) + steps := make([]evidence.StepResult, 0, len(plans)) + if strings.EqualFold(s.Auth.Mode, "form") { + // Auth steps are already included in the flattened plan. + } + for index, plan := range plans { + step := plan.Step + stepStart := time.Now().UTC() + stepCtx := ctx + cancel := func() {} + if step.TimeoutMS > 0 { + stepCtx, cancel = context.WithTimeout(ctx, time.Duration(step.TimeoutMS)*time.Millisecond) + } + err := executeStep(stepCtx, engine, s, step, vars) + runErr := ctx.Err() + stepErr := stepCtx.Err() + cancel() + + currentURL, _ := engine.URL(context.Background()) + title, _ := engine.Title(context.Background()) + seq := index + 1 + record := stepRecord(seq, step, plan.Auth, evidence.StatusOK) + record.StartedAt = stepStart.Format(time.RFC3339Nano) + record.EndedAt = time.Now().UTC().Format(time.RFC3339Nano) + record.DurationUS = time.Since(stepStart).Microseconds() + record.URL = currentURL + record.Title = title + if err == nil && step.Action == "goto" { + record.Performance = captureStepPerformance(engine) + } + if err == nil && screenshots.PerStep { + captureStepScreenshot(context.Background(), engine, &record, screenshots, false) + } + if err != nil { + if errors.Is(runErr, context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: timeoutMS} + } else if errors.Is(stepErr, context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: deadlineTimeoutMS(ctx, stepCtx, timeoutMS, step.TimeoutMS)} + } else if errors.Is(err, context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: deadlineTimeoutMS(ctx, stepCtx, timeoutMS, step.TimeoutMS)} + } + if plan.Auth { + err = errorsx.AuthError{Err: err} + } + record.Status = evidence.StatusFail + record.Error = errorsx.ErrorInfo(err) + if screenshots.OnFailure || screenshots.PerStep { + captureStepScreenshot(context.Background(), engine, &record, screenshots, false) + } + if record.Screenshot == "" && (screenshots.OnFailure || screenshots.PerStep) && record.Error != nil { + record.Error.Message = record.Error.Message + "; screenshot capture unavailable" + } + steps = append(steps, record) + steps = appendSkippedSteps(steps, plans[index+1:], seq+1) + return steps, err + } + steps = append(steps, record) + } + return steps, nil +} + +func captureStepPerformance(engine Engine) *evidence.PerformanceMetrics { + collector, ok := engine.(PerformanceCollector) + if !ok { + return nil + } + perfCtx, perfCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer perfCancel() + metrics, err := collector.CapturePerformance(perfCtx) + if err != nil || isZeroPerformance(metrics) { + return nil + } + return &metrics +} + +func deadlineTimeoutMS(runCtx context.Context, stepCtx context.Context, runTimeoutMS int, stepTimeoutMS int) int { + if stepTimeoutMS <= 0 { + return runTimeoutMS + } + runDeadline, hasRunDeadline := runCtx.Deadline() + stepDeadline, hasStepDeadline := stepCtx.Deadline() + if hasStepDeadline && (!hasRunDeadline || stepDeadline.Before(runDeadline)) { + return stepTimeoutMS + } + return runTimeoutMS +} + +type stepPlan struct { + Step script.Step + Auth bool +} + +func stepPlans(s script.Script) []stepPlan { + plans := make([]stepPlan, 0, len(s.Auth.Steps)+len(s.Steps)) + if strings.EqualFold(s.Auth.Mode, "form") { + for _, step := range s.Auth.Steps { + plans = append(plans, stepPlan{Step: step, Auth: true}) + } + } + for _, step := range s.Steps { + plans = append(plans, stepPlan{Step: step}) + } + return plans +} + +func appendSkippedSteps(results []evidence.StepResult, plans []stepPlan, startSeq int) []evidence.StepResult { + for index, plan := range plans { + record := stepRecord(startSeq+index, plan.Step, plan.Auth, evidence.StatusSkip) + record.SkipReason = "previous_step_failed" + results = append(results, record) + } + return results +} + +func stepRecord(seq int, step script.Step, auth bool, status evidence.RunStatus) evidence.StepResult { + mode, expected := script.Expected(step) + if expected != "" { + expected = mode + ":" + expected + } + record := evidence.StepResult{ + Seq: seq, + Name: step.StepName(seq), + Action: step.Action, + Selector: step.Selector, + ValueFrom: step.ValueFrom, + Expected: expected, + TimeoutMS: step.TimeoutMS, + Auth: auth, + Status: status, + InputDisplay: inputDisplay(step), + } + return record +} + +func inputDisplay(step script.Step) string { + if step.Action != "fill" { + return "" + } + if step.Sensitive { + return "***" + } + if strings.TrimSpace(step.ValueFrom) != "" { + return fmt.Sprintf("${%s}", strings.TrimSpace(step.ValueFrom)) + } + return step.Value +} + +func captureStepScreenshot(ctx context.Context, engine Engine, record *evidence.StepResult, options screenshotOptions, fullPage bool) { + screenshotter, ok := engine.(Screenshotter) + if !ok { + return + } + extension := ".png" + if fullPage { + extension = ".jpg" + } + path := filepath.Join(options.Dir, options.RunID, fmt.Sprintf("step-%d%s", record.Seq, extension)) + if options.Dir == "" { + path = filepath.Join(os.TempDir(), "browser-dial-evidence", options.RunID, fmt.Sprintf("step-%d%s", record.Seq, extension)) + } + saved, err := screenshotter.CaptureScreenshot(ctx, path, fullPage) + if err == nil { + record.Screenshot = saved + } +} + +func normalizedEngineName(name string) string { + switch strings.TrimSpace(strings.ToLower(name)) { + case "": + return "chrome" + case "lightpanda": + return "lightpanda" + default: + return strings.TrimSpace(strings.ToLower(name)) + } +} + +func executeStep(ctx context.Context, engine Engine, s script.Script, step script.Step, vars map[string]string) error { + switch step.Action { + case "goto": + target := step.URL + if target == "" { + target = s.Target + } + return engine.Navigate(ctx, target) + case "wait_for_selector": + return engine.WaitForSelector(ctx, step.Selector) + case "click": + return engine.Click(ctx, step.Selector) + case "fill": + value, err := stepValue(step, vars) + if err != nil { + return err + } + return engine.Fill(ctx, step.Selector, value) + case "assert_title": + title, err := engine.Title(ctx) + if err != nil { + return err + } + return compare("title", title, step) + case "assert_url": + currentURL, err := engine.URL(ctx) + if err != nil { + return err + } + return compare("url", currentURL, step) + case "assert_text": + text, err := engine.Text(ctx, step.Selector) + if err != nil { + return err + } + return compare("text", text, step) + case "eval": + expression := step.Value + if expression == "" { + expression = step.Text + } + _, err := engine.Eval(ctx, expression) + return err + default: + return fmt.Errorf("unsupported action %q", step.Action) + } +} + +func stepValue(step script.Step, vars map[string]string) (string, error) { + name := strings.TrimSpace(step.ValueFrom) + if name == "" { + return step.Value, nil + } + value, ok := vars[name] + if !ok { + return "", fmt.Errorf("config variable %q is required", name) + } + return value, nil +} + +func configVarMap(vars []script.ConfigVar) map[string]string { + out := map[string]string{} + for _, variable := range vars { + out[strings.TrimSpace(variable.Name)] = variable.Value + } + return out +} + +func compare(label string, actual string, step script.Step) error { + mode, expected := script.Expected(step) + switch mode { + case "equals": + if actual != expected { + return fmt.Errorf("%s assertion failed: expected %q, got %q", label, expected, actual) + } + default: + if !strings.Contains(actual, expected) { + return fmt.Errorf("%s assertion failed: expected %q to contain %q", label, actual, expected) + } + } + return nil +} + +func failureResult(runID string, name string, scriptPath string, timeoutMS int, start time.Time, tags map[string]string, loaded *script.Script, err error, reason string) Result { + target := "" + metadata := map[string]any{} + if loaded != nil { + target = loaded.Target + metadata = loaded.Metadata + } + postURL := "" + if loaded != nil { + postURL = loaded.PostURL + } + return Result{ + RunID: runID, + Name: name, + Target: target, + PostURL: postURL, + ScriptPath: scriptPath, + Status: evidence.StatusFail, + Success: false, + StartedAt: start, + StartedAtText: start.Format(time.RFC3339Nano), + EndedAtText: time.Now().UTC().Format(time.RFC3339Nano), + DurationUS: time.Since(start).Microseconds(), + TimeoutMS: timeoutMS, + Steps: []evidence.StepResult{}, + Tags: withNodeName(tags), + Metadata: metadata, + ConsoleEvents: []evidence.ConsoleEvent{}, + NetworkEvents: []evidence.NetworkEvent{}, + Error: errorsx.ErrorInfo(err), + FailReason: reason, + FailureType: classifyFailureType(err, nil, reason), + } +} + +func classifyFailureType(err error, steps []evidence.StepResult, failReason string) string { + if err == nil { + return "" + } + var authErr errorsx.AuthError + if errors.As(err, &authErr) { + return "auth_failed" + } + var timeoutErr errorsx.TimeoutError + if errors.As(err, &timeoutErr) { + return "timeout" + } + message := strings.ToLower(err.Error()) + if strings.Contains(message, "config variable") || strings.Contains(message, "cookie variable") { + return "config_error" + } + for _, step := range steps { + if step.Status != evidence.StatusFail { + continue + } + action := strings.TrimSpace(step.Action) + if strings.Contains(message, "selector not found") { + return "selector_not_found" + } + switch action { + case "assert_title", "assert_url", "assert_text": + return "assertion_failed" + case "goto": + return "navigation_failed" + case "wait_for_selector", "click", "fill": + return "selector_not_found" + case "eval": + return "script_error" + } + } + switch failReason { + case "script_load_error": + return "config_error" + case "runner_error": + return "browser_error" + case "script_error": + return "script_error" + } + return "browser_error" +} + +func mergeTags(first map[string]string, second map[string]string) map[string]string { + out := cloneStringMap(first) + for key, value := range second { + out[key] = value + } + return out +} + +func cloneStringMap(input map[string]string) map[string]string { + out := map[string]string{} + for key, value := range input { + out[key] = value + } + return out +} + +func withNodeName(tags map[string]string) map[string]string { + out := cloneStringMap(tags) + if out["node_name"] == "" { + host, _ := os.Hostname() + out["node_name"] = host + } + return util.SanitizeTags(out) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/dialtesting/browserdial/runner/runner_test.go b/dialtesting/browserdial/runner/runner_test.go new file mode 100644 index 00000000..e31ddc58 --- /dev/null +++ b/dialtesting/browserdial/runner/runner_test.go @@ -0,0 +1,844 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/evidence" + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/script" +) + +func TestRunSuccess(t *testing.T) { + path := writeScript(t, ` +name: homepage +target: https://example.com +post_url: https://openway.example.com?token=tkn_x +tags: + owner: platform +metadata: + suite: smoke +steps: + - name: open + action: goto + - name: title + action: assert_title + contains: Example + - name: body + action: assert_text + selector: body + contains: Example Domain +`) + result := Run(context.Background(), Options{ + ScriptPath: path, + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &performanceEngine{fakeEngine: fakeEngine{}}, nil + }, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if len(result.Steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(result.Steps)) + } + if result.Tags["owner"] != "platform" { + t.Fatalf("unexpected tags: %#v", result.Tags) + } + if result.Metadata["suite"] != "smoke" { + t.Fatalf("unexpected metadata: %#v", result.Metadata) + } + if result.PostURL != "https://openway.example.com?token=tkn_x" { + t.Fatalf("unexpected post_url: %q", result.PostURL) + } + if result.Performance == nil || result.Performance.TTFBMS != 12 || result.Performance.CLS != 0.03 { + t.Fatalf("unexpected performance metrics: %#v", result.Performance) + } + if result.Steps[0].Performance == nil || result.Steps[0].Performance.LoadingTimeMS != 123 { + t.Fatalf("expected goto step performance metrics: %#v", result.Steps[0].Performance) + } + if result.Steps[1].Performance != nil || result.Steps[2].Performance != nil { + t.Fatalf("non-navigation steps should not include performance metrics: %#v %#v", result.Steps[1].Performance, result.Steps[2].Performance) + } +} + +func TestRunFailureCapturesDOM(t *testing.T) { + path := writeScript(t, ` +name: homepage +target: https://example.com +steps: + - action: goto + - name: wrong title + action: assert_title + contains: Nope +`) + result := Run(context.Background(), Options{ + ScriptPath: path, + TimeoutMS: 1_000, + EngineFactory: newFakeEngine, + }) + if result.Success { + t.Fatal("expected failure") + } + if result.FailReason != "step_error" { + t.Fatalf("FailReason = %s", result.FailReason) + } + if result.FailureType != "assertion_failed" { + t.Fatalf("FailureType = %s", result.FailureType) + } + if result.DomSnapshot == nil || result.DomSnapshot.Text != "Example Domain" { + t.Fatalf("expected DOM snapshot, got %#v", result.DomSnapshot) + } +} + +func TestRunStepTimeoutDoesNotMaskAssertionFailure(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "homepage", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "assert_title", Contains: "Nope", TimeoutMS: 1_000}, + }, + }, Options{ + ScriptPath: "task:assertion-timeout", + TimeoutMS: 30_000, + EngineFactory: newFakeEngine, + }) + if result.Success { + t.Fatal("expected failure") + } + if result.FailReason != "step_error" || result.FailureType != "assertion_failed" { + t.Fatalf("unexpected failure classification: %#v", result) + } +} + +func TestRunStepTimeoutDoesNotMaskConfigVariableFailure(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "missing-var", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "fill", Selector: "input", ValueFrom: "MISSING", TimeoutMS: 1_000}, + }, + }, Options{ + ScriptPath: "task:missing-var-timeout", + TimeoutMS: 30_000, + EngineFactory: newFakeEngine, + }) + if result.Success { + t.Fatal("expected failure") + } + if result.FailReason != "step_error" || result.FailureType != "config_error" { + t.Fatalf("unexpected failure classification: %#v", result) + } +} + +func TestRunStepTimeoutClassifiesDeadlineExceeded(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "step-timeout", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "wait_for_selector", Selector: "#never", TimeoutMS: 5}, + }, + }, Options{ + ScriptPath: "task:step-timeout", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &fakeEngine{blockWait: true}, nil + }, + }) + if result.Success { + t.Fatal("expected timeout failure") + } + if result.FailReason != "timeout" || result.FailureType != "timeout" { + t.Fatalf("unexpected failure classification: %#v", result) + } + if result.Error == nil || !strings.Contains(result.Error.Message, "5ms") { + t.Fatalf("expected step timeout in error, got %#v", result.Error) + } +} + +func TestRunTotalTimeoutClassifiesDirectDeadlineExceeded(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "total-timeout-direct", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "goto", TimeoutMS: 60_000}, + }, + }, Options{ + ScriptPath: "task:total-timeout-direct", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &deadlineEngine{}, nil + }, + }) + if result.Success { + t.Fatal("expected timeout failure") + } + if result.FailReason != "timeout" || result.FailureType != "timeout" { + t.Fatalf("unexpected failure classification: %#v", result) + } + if result.Error == nil || !strings.Contains(result.Error.Message, "1000ms") { + t.Fatalf("expected total timeout in error, got %#v", result.Error) + } +} + +func TestRunEngineFactoryDeadlineClassifiesTimeout(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "factory-timeout", + Target: "https://example.com", + Steps: []script.Step{{Action: "goto"}}, + }, Options{ + ScriptPath: "task:factory-timeout", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return nil, context.DeadlineExceeded + }, + }) + if result.Success { + t.Fatal("expected timeout failure") + } + if result.FailReason != "timeout" || result.FailureType != "timeout" { + t.Fatalf("unexpected failure classification: %#v", result) + } +} + +func TestRunFailureAddsStepFieldsAndSkipsRemainingSteps(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "homepage", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "goto"}, + {Action: "fill", Selector: `input[name="password"]`, ValueFrom: "LOGIN_PASSWORD", Sensitive: true}, + {Action: "assert_title", Contains: "Nope"}, + {Action: "click", Selector: "#next"}, + }, + ConfigVars: []script.ConfigVar{ + {Name: "LOGIN_PASSWORD", Value: "secret", Secure: true}, + }, + }, Options{ + ScriptPath: "task:fields", + TimeoutMS: 1_000, + EngineFactory: newFakeEngine, + }) + if result.Success { + t.Fatal("expected failure") + } + if len(result.Steps) != 4 { + t.Fatalf("steps = %d, want 4: %#v", len(result.Steps), result.Steps) + } + fill := result.Steps[1] + if fill.Action != "fill" || fill.Selector == "" || fill.InputDisplay != "***" || fill.ValueFrom != "LOGIN_PASSWORD" { + t.Fatalf("unexpected fill evidence: %#v", fill) + } + failed := result.Steps[2] + if failed.Status != evidence.StatusFail || failed.Expected != "contains:Nope" { + t.Fatalf("unexpected failed step: %#v", failed) + } + skipped := result.Steps[3] + if skipped.Status != evidence.StatusSkip || skipped.SkipReason != "previous_step_failed" || skipped.Action != "click" { + t.Fatalf("unexpected skipped step: %#v", skipped) + } +} + +func TestRunRetriesUntilSuccess(t *testing.T) { + calls := 0 + result := RunScript(context.Background(), scriptForTest(), Options{ + ScriptPath: "task:retry", + TimeoutMS: 1_000, + RetryCount: 1, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + calls++ + if calls == 1 { + return &fakeEngine{title: "Wrong"}, nil + } + return &fakeEngine{}, nil + }, + }) + if !result.Success { + t.Fatalf("expected retry success: %#v", result.Error) + } + if calls != 2 || result.Attempt != 2 || result.MaxAttempts != 2 { + t.Fatalf("calls=%d attempt=%d max=%d", calls, result.Attempt, result.MaxAttempts) + } + if len(result.RetryRecords) != 2 { + t.Fatalf("expected 2 retry records, got %#v", result.RetryRecords) + } + if result.RetryRecords[0].Attempt != 1 || result.RetryRecords[0].Status != evidence.StatusFail || result.RetryRecords[0].FailedStep != 2 { + t.Fatalf("unexpected first retry record: %#v", result.RetryRecords[0]) + } + if result.RetryRecords[1].Attempt != 2 || result.RetryRecords[1].Status != evidence.StatusOK || !result.RetryRecords[1].Success { + t.Fatalf("unexpected second retry record: %#v", result.RetryRecords[1]) + } +} + +func TestRunFailureCapturesScreenshot(t *testing.T) { + dir := t.TempDir() + result := RunScript(context.Background(), script.Script{ + Name: "homepage", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "assert_title", Contains: "Nope"}, + }, + }, Options{ + ScriptPath: "task:screenshot", + TimeoutMS: 1_000, + EngineName: "fake", + ScreenshotOnFailure: true, + ScreenshotDir: dir, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &screenshotEngine{fakeEngine: fakeEngine{}}, nil + }, + }) + if result.Success { + t.Fatal("expected failure") + } + if result.Steps[0].Screenshot == "" { + t.Fatalf("expected screenshot path: %#v", result.Steps[0]) + } + if _, err := os.Stat(result.Steps[0].Screenshot); err != nil { + t.Fatalf("expected screenshot file: %v", err) + } +} + +func TestRunCapturesScreenshotPerStep(t *testing.T) { + dir := t.TempDir() + result := RunScript(context.Background(), scriptForTest(), Options{ + ScriptPath: "task:screenshot-per-step", + TimeoutMS: 1_000, + EngineName: "fake", + ScreenshotPerStep: true, + ScreenshotDir: dir, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &screenshotEngine{fakeEngine: fakeEngine{}}, nil + }, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if len(result.Steps) != 2 { + t.Fatalf("steps = %d", len(result.Steps)) + } + for _, step := range result.Steps { + if step.Screenshot == "" { + t.Fatalf("missing screenshot on step %#v", step) + } + if _, err := os.Stat(step.Screenshot); err != nil { + t.Fatalf("expected screenshot file: %v", err) + } + } +} + +func TestRunConfiguresBrowserHeadersCookiesAndTLS(t *testing.T) { + engine := &fakeEngine{} + result := RunScript(context.Background(), script.Script{ + Name: "configured", + Target: "https://example.com", + Headers: map[string]string{"X-Env": "prod"}, + IgnoreHTTPSErrors: true, + ConfigVars: []script.ConfigVar{ + {Name: "SESSION_ID", Value: "session-secret", Secure: true}, + }, + Cookies: []script.Cookie{ + {Name: "sid", ValueFrom: "SESSION_ID", Domain: "example.com", Path: "/", Secure: true, HTTPOnly: true, SameSite: "Lax"}, + }, + Steps: []script.Step{{Action: "goto"}}, + }, Options{ + ScriptPath: "task:configured", + TimeoutMS: 1_000, + ViewportWidth: 1366, + ViewportHeight: 768, + Headers: map[string]string{"X-Node": "node-a"}, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return engine, nil + }, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if !engine.config.IgnoreHTTPSErrors || engine.config.Headers["X-Env"] != "prod" || engine.config.Headers["X-Node"] != "node-a" { + t.Fatalf("unexpected browser config: %#v", engine.config) + } + if len(engine.config.Cookies) != 1 || engine.config.Cookies[0].Value != "session-secret" { + t.Fatalf("unexpected cookies: %#v", engine.config.Cookies) + } + if result.ViewportWidth != 1366 || result.ViewportHeight != 768 { + t.Fatalf("unexpected viewport on result: %#v", result) + } +} + +func TestRunInfersCookieTargetFromFirstGotoURL(t *testing.T) { + engine := &fakeEngine{} + result := RunScript(context.Background(), script.Script{ + Name: "step-url-cookie", + Cookies: []script.Cookie{ + {Name: "sid", Value: "session"}, + }, + Auth: script.Auth{ + Mode: "form", + Steps: []script.Step{ + {Action: "goto", URL: "https://login.example.com"}, + }, + }, + Steps: []script.Step{ + {Action: "goto", URL: "https://app.example.com"}, + }, + }, Options{ + ScriptPath: "task:step-url-cookie", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return engine, nil + }, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if engine.config.Target != "https://login.example.com" { + t.Fatalf("cookie target = %q", engine.config.Target) + } + + engine = &fakeEngine{} + result = RunScript(context.Background(), script.Script{ + Name: "normal-step-url-cookie", + Cookies: []script.Cookie{ + {Name: "sid", Value: "session"}, + }, + Steps: []script.Step{ + {Action: "goto", URL: "https://app.example.com"}, + }, + }, Options{ + ScriptPath: "task:normal-step-url-cookie", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return engine, nil + }, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if engine.config.Target != "https://app.example.com" { + t.Fatalf("cookie target = %q", engine.config.Target) + } +} + +func TestRunMissingCookieVariableFailsBeforeExecution(t *testing.T) { + called := false + result := RunScript(context.Background(), script.Script{ + Name: "configured", + Target: "https://example.com", + Cookies: []script.Cookie{{Name: "sid", ValueFrom: "SESSION_ID"}}, + Steps: []script.Step{{Action: "goto"}}, + }, Options{ + ScriptPath: "task:configured", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + called = true + return &fakeEngine{}, nil + }, + }) + if result.Success || result.FailReason != "script_load_error" { + t.Fatalf("expected script load failure: %#v", result) + } + if called { + t.Fatal("engine factory should not be called") + } +} + +func TestRunTimeout(t *testing.T) { + path := writeScript(t, ` +name: homepage +steps: + - action: wait_for_selector + selector: "#never" +`) + result := Run(context.Background(), Options{ + ScriptPath: path, + TimeoutMS: 5, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &fakeEngine{blockWait: true}, nil + }, + }) + if result.Success { + t.Fatal("expected timeout failure") + } + if result.FailReason != "timeout" { + t.Fatalf("FailReason = %s", result.FailReason) + } + if result.FailureType != "timeout" { + t.Fatalf("FailureType = %s", result.FailureType) + } +} + +func TestRunAuthUsesConfigVariables(t *testing.T) { + engine := &fakeEngine{} + result := RunScript(context.Background(), script.Script{ + Name: "dashboard", + Target: "https://example.com/dashboard", + Auth: script.Auth{ + Mode: "form", + Steps: []script.Step{ + {Action: "goto", URL: "https://example.com/login"}, + {Action: "fill", Selector: `input[name="email"]`, ValueFrom: "LOGIN_USER"}, + {Action: "fill", Selector: `input[name="password"]`, ValueFrom: "LOGIN_PASSWORD", Sensitive: true}, + {Action: "assert_url", Contains: "/login"}, + }, + }, + ConfigVars: []script.ConfigVar{ + {Name: "LOGIN_USER", Value: "monitor@example.com"}, + {Name: "LOGIN_PASSWORD", Value: "secret", Secure: true}, + }, + Steps: []script.Step{ + {Action: "goto"}, + {Action: "assert_title", Contains: "Example"}, + }, + }, Options{ + ScriptPath: "task:auth", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return engine, nil + }, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if got := engine.fills[`input[name="password"]`]; got != "secret" { + t.Fatalf("password fill = %q", got) + } + if len(result.Steps) != 6 || result.Steps[0].Seq != 1 || result.Steps[5].Seq != 6 { + t.Fatalf("unexpected steps: %#v", result.Steps) + } +} + +func TestRunAuthMissingVariableIsAuthError(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "dashboard", + Target: "https://example.com/dashboard", + Auth: script.Auth{ + Mode: "form", + Steps: []script.Step{ + {Action: "fill", Selector: `input[name="password"]`, ValueFrom: "LOGIN_PASSWORD", Sensitive: true}, + }, + }, + Steps: []script.Step{ + {Action: "goto"}, + }, + }, Options{ + ScriptPath: "task:auth", + TimeoutMS: 1_000, + EngineFactory: newFakeEngine, + }) + if result.Success { + t.Fatal("expected auth failure") + } + if result.FailReason != "auth_error" { + t.Fatalf("FailReason = %s", result.FailReason) + } +} + +func TestRunScriptUsesInMemoryScript(t *testing.T) { + result := RunScript(context.Background(), scriptForTest(), Options{ + ScriptPath: "task:bd-homepage", + TimeoutMS: 1_000, + EngineFactory: newFakeEngine, + }) + if !result.Success { + t.Fatalf("expected success: %#v", result.Error) + } + if result.ScriptPath != "task:bd-homepage" { + t.Fatalf("ScriptPath = %q", result.ScriptPath) + } + if result.Name != "homepage" { + t.Fatalf("Name = %q", result.Name) + } +} + +func TestCollectTraceIDs(t *testing.T) { + got := collectTraceIDs([]evidence.NetworkEvent{ + {Seq: 1, Event: "request", TraceID: "trace-a"}, + {Seq: 2, Event: "response", TraceID: "trace-a"}, + {Seq: 3, Event: "request", TraceID: "trace-b"}, + {Seq: 4, Event: "request"}, + }) + want := []string{"trace-a", "trace-b"} + if len(got) != len(want) { + t.Fatalf("trace IDs = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("trace IDs = %#v, want %#v", got, want) + } + } +} + +func TestRunAdditionalFailureAndStepBranches(t *testing.T) { + if result := RunScript(context.Background(), scriptForTest(), Options{ScriptPath: "task:no-factory"}); result.Success || result.FailReason != "runner_error" { + t.Fatalf("expected missing factory runner error: %#v", result) + } + if result := RunScript(context.Background(), scriptForTest(), Options{ + ScriptPath: "task:factory-error", + TimeoutMS: 1_000, + EngineName: "chrome", + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return nil, errors.New("factory boom") + }, + }); result.Success || result.Engine != "chrome" || result.FailReason != "runner_error" { + t.Fatalf("expected factory runner error: %#v", result) + } + if result := RunScript(context.Background(), scriptForTest(), Options{ + ScriptPath: "task:lightpanda-shot", + TimeoutMS: 1_000, + EngineName: "lightpanda", + ScreenshotPerStep: true, + ScreenshotOnFailure: true, + EngineFactory: newFakeEngine, + }); !result.Success || result.Steps[0].Screenshot != "" { + t.Fatalf("expected lightpanda screenshots to be ignored: %#v", result) + } + var gotOptions EngineOptions + if result := RunScript(context.Background(), scriptForTest(), Options{ + ScriptPath: "task:lightpanda-proxy", + TimeoutMS: 1_000, + EngineName: "lightpanda", + ProxyURL: "http://127.0.0.1:7897", + EngineFactory: func(_ context.Context, options EngineOptions) (Engine, error) { + gotOptions = options + return &fakeEngine{}, nil + }, + }); !result.Success || gotOptions.ProxyURL != "" { + t.Fatalf("expected lightpanda proxy to be ignored, result=%#v options=%#v", result, gotOptions) + } + scriptWithProxy := scriptForTest() + scriptWithProxy.ProxyURL = "http://127.0.0.1:7897" + var ignored []string + if result := RunScript(context.Background(), scriptWithProxy, Options{ + ScriptPath: "task:lightpanda-script-proxy", + TimeoutMS: 1_000, + EngineName: "lightpanda", + EngineFactory: func(_ context.Context, options EngineOptions) (Engine, error) { + gotOptions = options + return &fakeEngine{}, nil + }, + IgnoredOptionLogger: func(option string, reason string) { + ignored = append(ignored, option+" "+reason) + }, + }); !result.Success || gotOptions.ProxyURL != "" || len(ignored) != 1 || !strings.Contains(ignored[0], "proxy_url") { + t.Fatalf("expected script proxy ignore warning, result=%#v options=%#v ignored=%#v", result, gotOptions, ignored) + } + + result := RunScript(context.Background(), script.Script{ + Name: "branches", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "goto"}, + {Action: "wait_for_selector", Selector: "body"}, + {Action: "click", Selector: "a"}, + {Action: "fill", Selector: "input", Value: "value"}, + {Action: "assert_title", Equals: "Example Domain"}, + {Action: "assert_url", Contains: "example.com"}, + {Action: "assert_text", Selector: "body", Equals: "Example Domain"}, + {Action: "eval", Text: "true"}, + }, + }, Options{ + ScriptPath: "task:branches", + TimeoutMS: 1_000, + EngineFactory: newFakeEngine, + }) + if !result.Success { + t.Fatalf("expected branch run success: %#v", result.Error) + } + + result = RunScript(context.Background(), script.Script{ + Name: "missing-var", + Target: "https://example.com", + Steps: []script.Step{{Action: "fill", Selector: "input", ValueFrom: "MISSING"}}, + }, Options{ScriptPath: "task:missing-var", TimeoutMS: 1_000, EngineFactory: newFakeEngine}) + if result.Success || result.FailReason != "step_error" { + t.Fatalf("expected missing variable step error: %#v", result) + } +} + +func TestRunConfigureAndScreenshotUnavailableBranches(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + Name: "configure-fails", + Target: "https://example.com", + Headers: map[string]string{"X-Test": "1"}, + Steps: []script.Step{{Action: "goto"}}, + }, Options{ + ScriptPath: "task:configure-fails", + TimeoutMS: 1_000, + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &configureFailEngine{fakeEngine: fakeEngine{}}, nil + }, + }) + if result.Success || result.FailReason != "runner_error" { + t.Fatalf("expected configure failure: %#v", result) + } + + result = RunScript(context.Background(), script.Script{ + Name: "no-shot", + Target: "https://example.com", + Steps: []script.Step{{Action: "assert_title", Contains: "Nope"}}, + }, Options{ + ScriptPath: "task:no-shot", + TimeoutMS: 1_000, + EngineName: "fake", + ScreenshotOnFailure: true, + EngineFactory: newFakeEngine, + }) + if result.Success || !strings.Contains(result.Steps[0].Error.Message, "screenshot capture unavailable") { + t.Fatalf("expected screenshot unavailable note: %#v", result.Steps) + } +} + +func writeScript(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "script.yaml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func newFakeEngine(context.Context, EngineOptions) (Engine, error) { + return &fakeEngine{}, nil +} + +func scriptForTest() script.Script { + return script.Script{ + Name: "homepage", + Target: "https://example.com", + Steps: []script.Step{ + {Action: "goto"}, + {Action: "assert_title", Contains: "Example"}, + }, + } +} + +type fakeEngine struct { + url string + title string + blockWait bool + fills map[string]string + config BrowserConfig +} + +func (f *fakeEngine) Close(context.Context) error { + return nil +} + +func (f *fakeEngine) ConfigureBrowser(_ context.Context, config BrowserConfig) error { + f.config = config + return nil +} + +func (f *fakeEngine) Navigate(_ context.Context, target string) error { + f.url = target + return nil +} + +func (f *fakeEngine) WaitForSelector(ctx context.Context, _ string) error { + if !f.blockWait { + return nil + } + <-ctx.Done() + return ctx.Err() +} + +func (f *fakeEngine) Click(context.Context, string) error { + return nil +} + +func (f *fakeEngine) Fill(_ context.Context, selector string, value string) error { + if f.fills == nil { + f.fills = map[string]string{} + } + f.fills[selector] = value + return nil +} + +func (f *fakeEngine) Title(context.Context) (string, error) { + if f.title != "" { + return f.title, nil + } + return "Example Domain", nil +} + +func (f *fakeEngine) URL(context.Context) (string, error) { + if f.url == "" { + return "about:blank", nil + } + return f.url, nil +} + +func (f *fakeEngine) Text(context.Context, string) (string, error) { + return "Example Domain", nil +} + +func (f *fakeEngine) Eval(context.Context, string) (string, error) { + return "true", nil +} + +func (f *fakeEngine) CaptureDOM(context.Context) (evidence.DomSnapshot, error) { + return evidence.DomSnapshot{ + CapturedAt: time.Now().UTC().Format(time.RFC3339Nano), + Text: "Example Domain", + HTML: "Example Domain", + }, nil +} + +type deadlineEngine struct { + fakeEngine +} + +func (d *deadlineEngine) Navigate(context.Context, string) error { + return context.DeadlineExceeded +} + +func (f *fakeEngine) ConsoleEvents() []evidence.ConsoleEvent { + return nil +} + +func (f *fakeEngine) NetworkEvents() []evidence.NetworkEvent { + return nil +} + +type screenshotEngine struct { + fakeEngine +} + +type performanceEngine struct { + fakeEngine +} + +func (f *performanceEngine) CapturePerformance(context.Context) (evidence.PerformanceMetrics, error) { + return evidence.PerformanceMetrics{ + TTFBMS: 12, + LoadingTimeMS: 123, + LCPMS: 98, + CLS: 0.03, + DOMContentLoadedMS: 87, + LoadEventEndMS: 123, + }, nil +} + +func (f *screenshotEngine) CaptureScreenshot(_ context.Context, path string, _ bool) (string, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte("png"), 0o644); err != nil { + return "", fmt.Errorf("write screenshot: %w", err) + } + return path, nil +} + +type configureFailEngine struct { + fakeEngine +} + +func (f *configureFailEngine) ConfigureBrowser(context.Context, BrowserConfig) error { + return errors.New("configure boom") +} diff --git a/dialtesting/browserdial/script/script.go b/dialtesting/browserdial/script/script.go new file mode 100644 index 00000000..90ddd4bd --- /dev/null +++ b/dialtesting/browserdial/script/script.go @@ -0,0 +1,248 @@ +package script + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + errorsx "github.com/GuanceCloud/cliutils/dialtesting/browserdial/errors" + "gopkg.in/yaml.v3" +) + +type Script struct { + Name string `json:"name" yaml:"name"` + Target string `json:"target" yaml:"target"` + PostURL string `json:"post_url" yaml:"post_url"` + TimeoutMS int `json:"timeout_ms" yaml:"timeout_ms"` + Headers map[string]string `json:"headers" yaml:"headers"` + Cookies []Cookie `json:"cookies" yaml:"cookies"` + IgnoreHTTPSErrors bool `json:"ignore_https_errors" yaml:"ignore_https_errors"` + ProxyURL string `json:"proxy_url" yaml:"proxy_url"` + Tags map[string]string `json:"tags" yaml:"tags"` + Metadata map[string]any `json:"metadata" yaml:"metadata"` + Auth Auth `json:"auth" yaml:"auth"` + ConfigVars []ConfigVar `json:"config_vars" yaml:"config_vars"` + Steps []Step `json:"steps" yaml:"steps"` +} + +type Auth struct { + Mode string `json:"mode" yaml:"mode"` + Steps []Step `json:"steps" yaml:"steps"` +} + +type ConfigVar struct { + Name string `json:"name" yaml:"name"` + Value string `json:"value" yaml:"value"` + Secure bool `json:"secure" yaml:"secure"` +} + +type Cookie struct { + Name string `json:"name" yaml:"name"` + Value string `json:"value" yaml:"value"` + ValueFrom string `json:"value_from" yaml:"value_from"` + Domain string `json:"domain" yaml:"domain"` + Path string `json:"path" yaml:"path"` + Secure bool `json:"secure" yaml:"secure"` + HTTPOnly bool `json:"http_only" yaml:"http_only"` + SameSite string `json:"same_site" yaml:"same_site"` +} + +type Step struct { + Name string `json:"name" yaml:"name"` + Action string `json:"action" yaml:"action"` + URL string `json:"url" yaml:"url"` + Selector string `json:"selector" yaml:"selector"` + Value string `json:"value" yaml:"value"` + ValueFrom string `json:"value_from" yaml:"value_from"` + Text string `json:"text" yaml:"text"` + Contains string `json:"contains" yaml:"contains"` + Equals string `json:"equals" yaml:"equals"` + TimeoutMS int `json:"timeout_ms" yaml:"timeout_ms"` + Sensitive bool `json:"sensitive" yaml:"sensitive"` +} + +var supportedActions = map[string]struct{}{ + "goto": {}, + "wait_for_selector": {}, + "click": {}, + "fill": {}, + "assert_title": {}, + "assert_url": {}, + "assert_text": {}, + "eval": {}, +} + +func Load(path string) (Script, error) { + content, err := os.ReadFile(path) + if err != nil { + return Script{}, errorsx.ScriptLoadError{Message: err.Error()} + } + + var out Script + switch strings.ToLower(filepath.Ext(path)) { + case ".json": + if err := json.Unmarshal(content, &out); err != nil { + return Script{}, errorsx.ScriptLoadError{Message: fmt.Sprintf("parse %s: %v", path, err)} + } + default: + if err := yaml.Unmarshal(content, &out); err != nil { + return Script{}, errorsx.ScriptLoadError{Message: fmt.Sprintf("parse %s: %v", path, err)} + } + } + + if err := out.Validate(); err != nil { + return Script{}, errorsx.ScriptLoadError{Message: err.Error()} + } + return out, nil +} + +func (s Script) Validate() error { + if err := s.validateConfigVars(); err != nil { + return err + } + if err := s.validateHeadersAndCookies(); err != nil { + return err + } + if err := s.validateAuth(); err != nil { + return err + } + if len(s.Steps) == 0 { + return fmt.Errorf("script must define at least one step") + } + for index, step := range s.Steps { + if err := s.validateStep("step", index, step); err != nil { + return err + } + } + return nil +} + +func (s Script) validateHeadersAndCookies() error { + for key := range s.Headers { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("headers must not contain an empty key") + } + } + for index, cookie := range s.Cookies { + if strings.TrimSpace(cookie.Name) == "" { + return fmt.Errorf("cookies %d name is required", index+1) + } + if strings.TrimSpace(cookie.ValueFrom) != "" && cookie.Value != "" { + return fmt.Errorf("cookies %q must not define both value and value_from", cookie.Name) + } + switch strings.ToLower(strings.TrimSpace(cookie.SameSite)) { + case "", "lax", "strict", "none": + default: + return fmt.Errorf("cookies %q same_site must be Lax, Strict, or None", cookie.Name) + } + } + return nil +} + +func (s Script) validateConfigVars() error { + seen := map[string]struct{}{} + for index, variable := range s.ConfigVars { + name := strings.TrimSpace(variable.Name) + if name == "" { + return fmt.Errorf("config_vars %d name is required", index+1) + } + if _, ok := seen[name]; ok { + return fmt.Errorf("config_vars %q is duplicated", name) + } + seen[name] = struct{}{} + } + return nil +} + +func (s Script) validateAuth() error { + mode := strings.TrimSpace(strings.ToLower(s.Auth.Mode)) + if mode == "" { + mode = "none" + } + switch mode { + case "none": + if len(s.Auth.Steps) > 0 { + return fmt.Errorf("auth.steps requires auth.mode form") + } + case "form": + if len(s.Auth.Steps) == 0 { + return fmt.Errorf("auth.steps must define at least one step when auth.mode is form") + } + default: + return fmt.Errorf("auth.mode %q is not supported", s.Auth.Mode) + } + for index, step := range s.Auth.Steps { + if err := s.validateStep("auth step", index, step); err != nil { + return err + } + } + return nil +} + +func (s Script) validateStep(label string, index int, step Step) error { + action := strings.TrimSpace(step.Action) + if action == "" { + return fmt.Errorf("%s %d must define action", label, index+1) + } + if _, ok := supportedActions[action]; !ok { + return fmt.Errorf("%s %d action %q is not supported", label, index+1, action) + } + switch action { + case "goto": + if step.URL == "" && s.Target == "" { + return fmt.Errorf("%s %d goto requires url or top-level target", label, index+1) + } + case "wait_for_selector", "click", "fill": + if step.Selector == "" { + return fmt.Errorf("%s %d %s requires selector", label, index+1, action) + } + if action == "fill" && step.Sensitive { + if strings.TrimSpace(step.ValueFrom) == "" { + return fmt.Errorf("%s %d sensitive fill requires value_from", label, index+1) + } + if step.Value != "" { + return fmt.Errorf("%s %d sensitive fill must not define value", label, index+1) + } + } + case "assert_text": + if step.Selector == "" { + return fmt.Errorf("%s %d assert_text requires selector", label, index+1) + } + if expectedText(step) == "" { + return fmt.Errorf("%s %d assert_text requires contains, equals, or text", label, index+1) + } + case "assert_title", "assert_url": + if expectedText(step) == "" { + return fmt.Errorf("%s %d %s requires contains, equals, or text", label, index+1, action) + } + case "eval": + if step.Value == "" && step.Text == "" { + return fmt.Errorf("%s %d eval requires value or text", label, index+1) + } + } + return nil +} + +func (s Step) StepName(index int) string { + if strings.TrimSpace(s.Name) != "" { + return s.Name + } + return fmt.Sprintf("%02d %s", index, s.Action) +} + +func Expected(step Step) (mode string, expected string) { + if step.Equals != "" { + return "equals", step.Equals + } + if step.Contains != "" { + return "contains", step.Contains + } + return "contains", step.Text +} + +func expectedText(step Step) string { + _, expected := Expected(step) + return expected +} diff --git a/dialtesting/browserdial/script/script_test.go b/dialtesting/browserdial/script/script_test.go new file mode 100644 index 00000000..93d11387 --- /dev/null +++ b/dialtesting/browserdial/script/script_test.go @@ -0,0 +1,213 @@ +package script + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script.yaml") + content := ` +name: homepage +target: https://example.com +post_url: https://openway.example.com?token=tkn_x +tags: + owner: platform +metadata: + suite: smoke +steps: + - name: open + action: goto + - action: assert_title + contains: Example +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.Name != "homepage" || got.Target != "https://example.com" { + t.Fatalf("unexpected script: %#v", got) + } + if got.PostURL != "https://openway.example.com?token=tkn_x" { + t.Fatalf("unexpected post_url: %q", got.PostURL) + } + if got.Tags["owner"] != "platform" { + t.Fatalf("unexpected tags: %#v", got.Tags) + } + if len(got.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(got.Steps)) + } +} + +func TestValidateRejectsMissingSelector(t *testing.T) { + err := Script{Steps: []Step{{Action: "click"}}}.Validate() + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestLoadAuthAndConfigVars(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script.yaml") + content := ` +name: auth-homepage +target: https://example.com/dashboard +auth: + mode: form + steps: + - action: goto + url: https://example.com/login + - action: fill + selector: input[name="password"] + value_from: LOGIN_PASSWORD + sensitive: true +config_vars: + - name: LOGIN_PASSWORD + value: secret + secure: true +steps: + - action: goto + - action: assert_title + contains: Example +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.Auth.Mode != "form" || len(got.Auth.Steps) != 2 { + t.Fatalf("unexpected auth: %#v", got.Auth) + } + if len(got.ConfigVars) != 1 || !got.ConfigVars[0].Secure { + t.Fatalf("unexpected config vars: %#v", got.ConfigVars) + } +} + +func TestValidateRejectsSensitiveInlineValue(t *testing.T) { + err := Script{ + Steps: []Step{ + {Action: "fill", Selector: `input[name="password"]`, Value: "secret", Sensitive: true}, + }, + }.Validate() + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestLoadRequestEnvironment(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script.yaml") + content := ` +name: request-env +target: https://example.com +headers: + X-Env: prod +cookies: + - name: sid + value_from: SESSION_ID + domain: example.com + path: / + secure: true + http_only: true + same_site: Lax +ignore_https_errors: true +proxy_url: http://127.0.0.1:7897 +config_vars: + - name: SESSION_ID + value: secret + secure: true +steps: + - action: goto +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.Headers["X-Env"] != "prod" || len(got.Cookies) != 1 || !got.IgnoreHTTPSErrors || got.ProxyURL == "" { + t.Fatalf("unexpected request environment: %#v", got) + } + if got.Cookies[0].ValueFrom != "SESSION_ID" || got.Cookies[0].SameSite != "Lax" { + t.Fatalf("unexpected cookie: %#v", got.Cookies[0]) + } +} + +func TestValidateRejectsInvalidCookie(t *testing.T) { + err := Script{ + Cookies: []Cookie{{Name: "sid", Value: "inline", ValueFrom: "SESSION_ID"}}, + Steps: []Step{{Action: "goto", URL: "https://example.com"}}, + }.Validate() + if err == nil { + t.Fatal("expected validation error") + } + err = Script{ + Cookies: []Cookie{{Name: "sid", SameSite: "Maybe"}}, + Steps: []Step{{Action: "goto", URL: "https://example.com"}}, + }.Validate() + if err == nil { + t.Fatal("expected same_site validation error") + } +} + +func TestValidateCoversErrorBranchesAndStepHelpers(t *testing.T) { + if got := (Step{Name: "Custom", Action: "goto"}).StepName(3); got != "Custom" { + t.Fatalf("unexpected step name %q", got) + } + if got := (Step{Action: "click"}).StepName(3); got != "03 click" { + t.Fatalf("unexpected fallback step name %q", got) + } + if mode, expected := Expected(Step{Equals: "A", Contains: "B"}); mode != "equals" || expected != "A" { + t.Fatalf("unexpected expected equals: %s %s", mode, expected) + } + + cases := []Script{ + {ConfigVars: []ConfigVar{{Name: ""}}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {ConfigVars: []ConfigVar{{Name: "A"}, {Name: " A "}}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {Headers: map[string]string{" ": "bad"}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {Cookies: []Cookie{{Name: ""}}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {Auth: Auth{Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {Auth: Auth{Mode: "form"}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {Auth: Auth{Mode: "oauth"}, Steps: []Step{{Action: "goto", URL: "https://example.com"}}}, + {Steps: nil}, + {Steps: []Step{{}}}, + {Steps: []Step{{Action: "unknown"}}}, + {Steps: []Step{{Action: "goto"}}}, + {Steps: []Step{{Action: "wait_for_selector"}}}, + {Steps: []Step{{Action: "assert_text", Selector: "body"}}}, + {Steps: []Step{{Action: "assert_title"}}}, + {Steps: []Step{{Action: "assert_url"}}}, + {Steps: []Step{{Action: "eval"}}}, + } + for _, tc := range cases { + if err := tc.Validate(); err == nil { + t.Fatalf("expected validation error for %#v", tc) + } + } + + jsonPath := filepath.Join(t.TempDir(), "script.json") + if err := os.WriteFile(jsonPath, []byte(`{"steps":[{"action":"goto","url":"https://example.com"}]}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(jsonPath); err != nil { + t.Fatal(err) + } + if _, err := Load(filepath.Join(t.TempDir(), "missing.yaml")); err == nil { + t.Fatal("expected missing file load error") + } + badJSON := filepath.Join(t.TempDir(), "bad.json") + if err := os.WriteFile(badJSON, []byte(`{`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(badJSON); err == nil { + t.Fatal("expected JSON parse error") + } +} diff --git a/dialtesting/browserdial/util/sanitize.go b/dialtesting/browserdial/util/sanitize.go new file mode 100644 index 00000000..eac03d7f --- /dev/null +++ b/dialtesting/browserdial/util/sanitize.go @@ -0,0 +1,78 @@ +package util + +import ( + "encoding/json" + "regexp" + "strings" + "unicode" +) + +var nonKeyChar = regexp.MustCompile(`[^A-Za-z0-9_]+`) +var repeatedUnderscore = regexp.MustCompile(`_+`) + +func SanitizeKey(input string, fallback string) string { + cleaned := strings.TrimSpace(input) + cleaned = camelToSnake(cleaned) + cleaned = nonKeyChar.ReplaceAllString(cleaned, "_") + cleaned = repeatedUnderscore.ReplaceAllString(cleaned, "_") + cleaned = strings.Trim(cleaned, "_") + cleaned = strings.ToLower(cleaned) + if cleaned == "" { + cleaned = fallback + } + if cleaned != "" && cleaned[0] >= '0' && cleaned[0] <= '9' { + return "k_" + cleaned + } + return cleaned +} + +func SanitizeTags(tags map[string]string) map[string]string { + out := make(map[string]string, len(tags)) + for key, value := range tags { + out[SanitizeKey(key, "tag")] = value + } + return out +} + +func SanitizeFields(fields map[string]any) map[string]any { + out := make(map[string]any, len(fields)) + for key, value := range fields { + if value == nil { + continue + } + out[SanitizeKey(key, "field")] = value + } + return out +} + +func Truncate(value string, maxLen int) string { + if maxLen <= 0 || len(value) <= maxLen { + return value + } + suffix := "...[truncated]" + if maxLen <= len(suffix) { + return value[:maxLen] + } + return value[:maxLen-len(suffix)] + suffix +} + +func JSONString(value any, maxLen int) string { + bytes, err := json.Marshal(value) + if err != nil { + return Truncate(`"`+err.Error()+`"`, maxLen) + } + return Truncate(string(bytes), maxLen) +} + +func camelToSnake(input string) string { + var out strings.Builder + var prev rune + for i, current := range input { + if i > 0 && unicode.IsUpper(current) && (unicode.IsLower(prev) || unicode.IsDigit(prev)) { + out.WriteRune('_') + } + out.WriteRune(current) + prev = current + } + return out.String() +} diff --git a/dialtesting/browserdial/util/sanitize_test.go b/dialtesting/browserdial/util/sanitize_test.go new file mode 100644 index 00000000..d8911fe7 --- /dev/null +++ b/dialtesting/browserdial/util/sanitize_test.go @@ -0,0 +1,66 @@ +package util + +import ( + "strings" + "testing" + "time" +) + +func TestSanitizeKey(t *testing.T) { + tests := map[string]string{ + "flow.name": "flow_name", + "bad key": "bad_key", + "9bad": "k_9bad", + "": "key", + } + for input, want := range tests { + if got := SanitizeKey(input, "key"); got != want { + t.Fatalf("SanitizeKey(%q) = %q, want %q", input, got, want) + } + } +} + +func TestSanitizeFields(t *testing.T) { + got := SanitizeFields(map[string]any{ + "response.time": 12, + "skip": nil, + }) + if got["response_time"] != 12 { + t.Fatalf("expected response_time field, got %#v", got) + } + if _, ok := got["skip"]; ok { + t.Fatalf("nil field should be skipped: %#v", got) + } +} + +func TestJSONString(t *testing.T) { + got := JSONString(map[string]string{"hello": "world"}, 100) + if got != `{"hello":"world"}` { + t.Fatalf("JSONString = %s", got) + } +} + +func TestSanitizeTagsTruncateJSONStringAndTiming(t *testing.T) { + tags := SanitizeTags(map[string]string{"Node Name": "shanghai", "1bad": "value"}) + if tags["node_name"] != "shanghai" || tags["k_1bad"] != "value" { + t.Fatalf("unexpected tags: %#v", tags) + } + if got := Truncate("abcdef", 3); got != "abc" { + t.Fatalf("unexpected short truncate: %q", got) + } + if got := Truncate("abcdefghijklmnopqrstuvwxyz", 20); got != "abcdef...[truncated]" { + t.Fatalf("unexpected truncate suffix: %q", got) + } + if got := JSONString(func() {}, 100); !strings.Contains(got, "unsupported type") { + t.Fatalf("unexpected JSON error string: %q", got) + } + if NowISO() == "" { + t.Fatal("NowISO should not be empty") + } + if DurationUS(time.Now().Add(-time.Millisecond)) <= 0 { + t.Fatal("DurationUS should be positive") + } + if id := NewRunID(); len(id) < 30 { + t.Fatalf("unexpected run id %q", id) + } +} diff --git a/dialtesting/browserdial/util/time.go b/dialtesting/browserdial/util/time.go new file mode 100644 index 00000000..8733eb0f --- /dev/null +++ b/dialtesting/browserdial/util/time.go @@ -0,0 +1,11 @@ +package util + +import "time" + +func NowISO() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} + +func DurationUS(start time.Time) int64 { + return time.Since(start).Microseconds() +} diff --git a/dialtesting/browserdial/util/uuid.go b/dialtesting/browserdial/util/uuid.go new file mode 100644 index 00000000..85297452 --- /dev/null +++ b/dialtesting/browserdial/util/uuid.go @@ -0,0 +1,17 @@ +package util + +import ( + "crypto/rand" + "fmt" + "time" +) + +func NewRunID() string { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return fmt.Sprintf("run-%d", time.Now().UnixNano()) + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]) +} diff --git a/dialtesting/examples/browser-auth-form.yaml b/dialtesting/examples/browser-auth-form.yaml new file mode 100644 index 00000000..ea15f9ad --- /dev/null +++ b/dialtesting/examples/browser-auth-form.yaml @@ -0,0 +1,37 @@ +name: browser-auth-form +target: https://example.com/dashboard +timeout_ms: 60000 +config_vars: + - name: LOGIN_USER + value: monitor@example.com + - name: LOGIN_PASSWORD + value: replace-with-secret + secure: true +auth: + mode: form + steps: + - name: open login + action: goto + url: https://example.com/login + - name: fill user + action: fill + selector: input[name="email"] + value_from: LOGIN_USER + - name: fill password + action: fill + selector: input[name="password"] + value_from: LOGIN_PASSWORD + sensitive: true + - name: submit login + action: click + selector: button[type="submit"] + - name: wait dashboard url + action: assert_url + contains: /dashboard +steps: + - name: open dashboard + action: goto + - name: check dashboard + action: assert_text + selector: body + contains: Dashboard diff --git a/dialtesting/examples/browser-basic.yaml b/dialtesting/examples/browser-basic.yaml new file mode 100644 index 00000000..2671c799 --- /dev/null +++ b/dialtesting/examples/browser-basic.yaml @@ -0,0 +1,17 @@ +name: browser-basic +target: https://example.com +timeout_ms: 60000 +tags: + scenario: basic +metadata: + generated_by: recorder +steps: + - name: open homepage + action: goto + - name: check title + action: assert_title + contains: Example + - name: check body text + action: assert_text + selector: body + contains: Example Domain diff --git a/dialtesting/examples/browser-config-vars.yaml b/dialtesting/examples/browser-config-vars.yaml new file mode 100644 index 00000000..9226022e --- /dev/null +++ b/dialtesting/examples/browser-config-vars.yaml @@ -0,0 +1,30 @@ +name: browser-config-vars +target: https://example.com +timeout_ms: 60000 +config_vars: + - name: SEARCH_TEXT + value: Example Domain + - name: SESSION_ID + value: replace-with-secret + secure: true +cookies: + - name: sid + value_from: SESSION_ID + domain: example.com + path: / + secure: true + http_only: true + same_site: Lax +steps: + - name: open target + action: goto + - name: fill local input + action: eval + value: document.body.insertAdjacentHTML("beforeend", ""); + - name: input search text + action: fill + selector: "#search-box" + value_from: SEARCH_TEXT + - name: check title + action: assert_title + contains: Example Domain diff --git a/dialtesting/http.go b/dialtesting/http.go index b0419b78..217e7044 100644 --- a/dialtesting/http.go +++ b/dialtesting/http.go @@ -815,9 +815,9 @@ func (t *HTTPTask) closeHTTP3Transport() { } func (t *HTTPTask) newHTTP3RoundTripper(tlsConfig *tls.Config, httpTimeout time.Duration) http.RoundTripper { - return &http3.RoundTripper{ + return &http3.Transport{ TLSClientConfig: tlsConfig, - Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) { + Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err diff --git a/dialtesting/task.go b/dialtesting/task.go index b73dcb39..5f25c1d6 100644 --- a/dialtesting/task.go +++ b/dialtesting/task.go @@ -230,7 +230,7 @@ func CreateTaskChild(taskType string) (TaskChild, error) { ct = &MultiTask{} case "headless", "browser", ClassHeadless: - return nil, errors.New("headless task deprecated") + ct = &BrowserTask{} case "tcp", ClassTCP: ct = &TCPTask{} diff --git a/go.mod b/go.mod index 554a93e9..57396385 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/GuanceCloud/cliutils -go 1.19 +go 1.25 require ( github.com/BurntSushi/toml v1.2.1 @@ -10,9 +10,11 @@ require ( github.com/aliyun/aliyun-oss-go-sdk v2.1.2+incompatible github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/cespare/xxhash/v2 v2.2.0 + github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b + github.com/chromedp/chromedp v0.13.7 github.com/didip/tollbooth/v6 v6.1.2 github.com/gin-gonic/gin v1.9.0 - github.com/gobwas/ws v1.1.0 + github.com/gobwas/ws v1.4.0 github.com/gogo/protobuf v1.3.2 github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 github.com/gorilla/websocket v1.5.0 @@ -26,20 +28,21 @@ require ( github.com/prometheus/client_model v0.4.0 github.com/prometheus/common v0.44.0 github.com/prometheus/prometheus v0.39.1 - github.com/quic-go/quic-go v0.36.0 + github.com/quic-go/quic-go v0.59.1 github.com/robfig/cron/v3 v3.0.1 github.com/rs/xid v1.2.1 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.24.0 golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 - golang.org/x/net v0.16.0 - golang.org/x/sys v0.13.0 + golang.org/x/net v0.43.0 + golang.org/x/sys v0.42.0 golang.org/x/time v0.5.0 google.golang.org/grpc v1.51.0 google.golang.org/protobuf v1.31.0 gopkg.in/CodapeWild/dd-trace-go.v1 v1.35.17 gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -58,19 +61,19 @@ require ( github.com/bufbuild/protocompile v0.4.0 // indirect github.com/bytedance/sonic v1.8.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 // indirect github.com/go-pkgz/expirable-cache v0.0.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.11.2 // indirect - github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.3.0 // indirect github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd // indirect @@ -86,15 +89,12 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mssola/user_agent v0.6.0 // indirect - github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/outcaste-io/ristretto v0.2.1 // indirect github.com/philhofer/fwd v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.11.0 // indirect - github.com/quic-go/qpack v0.4.0 // indirect - github.com/quic-go/qtls-go1-19 v0.3.2 // indirect - github.com/quic-go/qtls-go1-20 v0.3.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/cast v1.5.1 // indirect github.com/tidwall/gjson v1.17.0 // indirect @@ -106,14 +106,13 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.6.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/mod v0.13.0 // indirect - golang.org/x/sync v0.4.0 // indirect - golang.org/x/text v0.13.0 // indirect - golang.org/x/tools v0.14.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect diff --git a/go.sum b/go.sum index e732c40d..c4608d38 100644 --- a/go.sum +++ b/go.sum @@ -134,6 +134,7 @@ github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAm github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f h1:ZNv7On9kyUzm7fvRZumSyy/IUiSC7AzL0I1jKKtwooA= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -158,6 +159,12 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b h1:jJmiCljLNTaq/O1ju9Bzz2MPpFlmiTn0F7LwCoeDZVw= +github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.13.7 h1:vt+mslxscyvUr58eC+6DLSeeo74jpV/HI2nWetjv/W4= +github.com/chromedp/chromedp v0.13.7/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -222,6 +229,7 @@ github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQL github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= @@ -232,6 +240,7 @@ github.com/frankban/quicktest v1.11.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/garyburd/redigo v1.6.2/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= @@ -248,6 +257,8 @@ github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITL github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 h1:yE7argOs92u+sSCRgqqe6eF+cDaVhSPlioy1UkA0p/w= +github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -258,7 +269,6 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= @@ -268,6 +278,7 @@ github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6d github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= @@ -284,8 +295,6 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= @@ -316,8 +325,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= -github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gocql/gocql v0.0.0-20210817081954-bc256bbb90de/go.mod h1:3gM2c4D3AnkISwBxGnMMsS8Oy4y2lhbPRsH4xnJrHG8= @@ -345,7 +354,6 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -385,7 +393,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -446,6 +455,7 @@ github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39 github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-kms-wrapping/entropy v0.1.0/go.mod h1:d1g9WGtAunDNpek8jUIEJnBlbgKS1N2Q61QkHiZyR1g= @@ -581,6 +591,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -589,6 +600,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/echo/v4 v4.6.0/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= @@ -609,6 +622,7 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -624,6 +638,7 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -670,8 +685,6 @@ github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108 github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= -github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -679,7 +692,6 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -690,6 +702,8 @@ github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.m github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/outcaste-io/ristretto v0.2.1 h1:KCItuNIGJZcursqHr3ghO7fc5ddZLEHspL9UR0cQM64= github.com/outcaste-io/ristretto v0.2.1/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -741,14 +755,10 @@ github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuR github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/prometheus/prometheus v0.39.1 h1:abZM6A+sKAv2eKTbRIaHq4amM/nT07MuxRm0+QTaTj0= github.com/prometheus/prometheus v0.39.1/go.mod h1:GjQjgLhHMc0oo4Ko7qt/yBSJMY4hUoiAZwsYQgjaePA= -github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= -github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= -github.com/quic-go/qtls-go1-19 v0.3.2 h1:tFxjCFcTQzK+oMxG6Zcvp4Dq8dx4yD3dDiIiyc86Z5U= -github.com/quic-go/qtls-go1-19 v0.3.2/go.mod h1:ySOI96ew8lnoKPtSqx2BlI5wCpUVPT05RMAlajtnyOI= -github.com/quic-go/qtls-go1-20 v0.3.2 h1:rRgN3WfnKbyik4dBV8A6girlJVxGand/d+jVKbQq5GI= -github.com/quic-go/qtls-go1-20 v0.3.2/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k= -github.com/quic-go/quic-go v0.36.0 h1:JIrO7p7Ug6hssFcARjWDiqS2RAKJHCiwPxBAA989rbI= -github.com/quic-go/quic-go v0.36.0/go.mod h1:zPetvwDlILVxt15n3hr3Gf/I3mDf7LpLKPhR4Ez0AZQ= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -760,6 +770,7 @@ github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= @@ -799,6 +810,7 @@ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoH github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -808,8 +820,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tidwall/btree v0.6.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4= github.com/tidwall/buntdb v1.2.6/go.mod h1:zpXqlA5D2772I4cTqV3ifr2AZihDgi8FV7xAQu6edfc= @@ -883,6 +895,9 @@ go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= @@ -923,8 +938,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -963,8 +978,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= -golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1020,8 +1035,8 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= -golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1050,8 +1065,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1107,7 +1122,6 @@ golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1136,8 +1150,8 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1153,8 +1167,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1232,8 +1246,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1445,7 +1459,9 @@ modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= @@ -1459,9 +1475,11 @@ modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= +modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= +modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/point/kvs_test.go b/point/kvs_test.go index 96e909a2..604eaaa8 100644 --- a/point/kvs_test.go +++ b/point/kvs_test.go @@ -608,7 +608,7 @@ func Test_shuffle(t *T.T) { p := NewPoint(t.Name(), kvs) - t.Logf(p.Pretty()) + t.Logf("%s", p.Pretty()) } func TestShuffleGzip(t *T.T) { diff --git a/point/point_test.go b/point/point_test.go index 85bc428c..ce4396d5 100644 --- a/point/point_test.go +++ b/point/point_test.go @@ -927,7 +927,7 @@ func FuzzPBPointString(f *T.F) { assert.NoError(t, err) if pt != nil { - t.Logf(pt.Pretty()) + t.Logf("%s", pt.Pretty()) } }) } diff --git a/vendor/github.com/chromedp/cdproto/.gitignore b/vendor/github.com/chromedp/cdproto/.gitignore new file mode 100644 index 00000000..c456f532 --- /dev/null +++ b/vendor/github.com/chromedp/cdproto/.gitignore @@ -0,0 +1,2 @@ +*.json +*.pdl diff --git a/vendor/github.com/go-task/slim-sprig/LICENSE.txt b/vendor/github.com/chromedp/cdproto/LICENSE similarity index 86% rename from vendor/github.com/go-task/slim-sprig/LICENSE.txt rename to vendor/github.com/chromedp/cdproto/LICENSE index f311b1ea..b9ba4a54 100644 --- a/vendor/github.com/go-task/slim-sprig/LICENSE.txt +++ b/vendor/github.com/chromedp/cdproto/LICENSE @@ -1,4 +1,6 @@ -Copyright (C) 2013-2020 Masterminds +The MIT License (MIT) + +Copyright (c) 2016-2025 Kenneth Shaw Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -7,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/chromedp/cdproto/README.md b/vendor/github.com/chromedp/cdproto/README.md new file mode 100644 index 00000000..a21f77ca --- /dev/null +++ b/vendor/github.com/chromedp/cdproto/README.md @@ -0,0 +1,29 @@ +# About cdproto + +Package `cdproto` contains the generated commands, types, and events for the +[Chrome DevTools Protocol domains][devtools-protocol]. + +[![Unit Tests][cdproto-ci-status]][cdproto-ci] +[![Go Reference][goref-cdproto-status]][goref-cdproto] + +This package is generated by the [`cdproto-gen`][cdproto-gen] command. Please +refer to that project and to the main [`chromedp`][chromedp] project for +information on using the commands, types, and events available here. + +## API + +Please see the [Go Reference][goref-cdproto]. + +## Contributing + +If you would like to submit a change to the code in this package, please submit +your Pull Request to the [`cdproto-gen`][cdproto-gen] project. Any Issues and +Pull Requests submitted to this project will be closed without being reviewed. + +[cdproto-ci]: https://github.com/chromedp/cdproto/actions/workflows/build.yml (Build CI) +[cdproto-ci-status]: https://github.com/chromedp/cdproto/actions/workflows/build.yml/badge.svg (Build CI) +[cdproto-gen]: https://github.com/chromedp/cdproto-gen +[chromedp]: https://github.com/chromedp/chromedp +[devtools-protocol]: https://chromedevtools.github.io/devtools-protocol/ +[goref-cdproto]: https://pkg.go.dev/github.com/chromedp/cdproto +[goref-cdproto-status]: https://pkg.go.dev/badge/github.com/chromedp/chromedp.svg diff --git a/vendor/github.com/chromedp/cdproto/accessibility/accessibility.go b/vendor/github.com/chromedp/cdproto/accessibility/accessibility.go new file mode 100644 index 00000000..80d0ca82 --- /dev/null +++ b/vendor/github.com/chromedp/cdproto/accessibility/accessibility.go @@ -0,0 +1,413 @@ +// Package accessibility provides the Chrome DevTools Protocol +// commands, types, and events for the Accessibility domain. +// +// Generated by the cdproto-gen command. +package accessibility + +// Code generated by cdproto-gen. DO NOT EDIT. + +import ( + "context" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/runtime" +) + +// DisableParams disables the accessibility domain. +type DisableParams struct{} + +// Disable disables the accessibility domain. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-disable +func Disable() *DisableParams { + return &DisableParams{} +} + +// Do executes Accessibility.disable against the provided context. +func (p *DisableParams) Do(ctx context.Context) (err error) { + return cdp.Execute(ctx, CommandDisable, nil, nil) +} + +// EnableParams enables the accessibility domain which causes AXNodeIds to +// remain consistent between method calls. This turns on accessibility for the +// page, which can impact performance until accessibility is disabled. +type EnableParams struct{} + +// Enable enables the accessibility domain which causes AXNodeIds to remain +// consistent between method calls. This turns on accessibility for the page, +// which can impact performance until accessibility is disabled. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-enable +func Enable() *EnableParams { + return &EnableParams{} +} + +// Do executes Accessibility.enable against the provided context. +func (p *EnableParams) Do(ctx context.Context) (err error) { + return cdp.Execute(ctx, CommandEnable, nil, nil) +} + +// GetPartialAXTreeParams fetches the accessibility node and partial +// accessibility tree for this DOM node, if it exists. +type GetPartialAXTreeParams struct { + NodeID cdp.NodeID `json:"nodeId,omitempty,omitzero"` // Identifier of the node to get the partial accessibility tree for. + BackendNodeID cdp.BackendNodeID `json:"backendNodeId,omitempty,omitzero"` // Identifier of the backend node to get the partial accessibility tree for. + ObjectID runtime.RemoteObjectID `json:"objectId,omitempty,omitzero"` // JavaScript object id of the node wrapper to get the partial accessibility tree for. + FetchRelatives bool `json:"fetchRelatives"` // Whether to fetch this node's ancestors, siblings and children. Defaults to true. +} + +// GetPartialAXTree fetches the accessibility node and partial accessibility +// tree for this DOM node, if it exists. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-getPartialAXTree +// +// parameters: +func GetPartialAXTree() *GetPartialAXTreeParams { + return &GetPartialAXTreeParams{ + FetchRelatives: true, + } +} + +// WithNodeID identifier of the node to get the partial accessibility tree +// for. +func (p GetPartialAXTreeParams) WithNodeID(nodeID cdp.NodeID) *GetPartialAXTreeParams { + p.NodeID = nodeID + return &p +} + +// WithBackendNodeID identifier of the backend node to get the partial +// accessibility tree for. +func (p GetPartialAXTreeParams) WithBackendNodeID(backendNodeID cdp.BackendNodeID) *GetPartialAXTreeParams { + p.BackendNodeID = backendNodeID + return &p +} + +// WithObjectID JavaScript object id of the node wrapper to get the partial +// accessibility tree for. +func (p GetPartialAXTreeParams) WithObjectID(objectID runtime.RemoteObjectID) *GetPartialAXTreeParams { + p.ObjectID = objectID + return &p +} + +// WithFetchRelatives whether to fetch this node's ancestors, siblings and +// children. Defaults to true. +func (p GetPartialAXTreeParams) WithFetchRelatives(fetchRelatives bool) *GetPartialAXTreeParams { + p.FetchRelatives = fetchRelatives + return &p +} + +// GetPartialAXTreeReturns return values. +type GetPartialAXTreeReturns struct { + Nodes []*Node `json:"nodes,omitempty,omitzero"` // The Accessibility.AXNode for this DOM node, if it exists, plus its ancestors, siblings and children, if requested. +} + +// Do executes Accessibility.getPartialAXTree against the provided context. +// +// returns: +// +// nodes - The Accessibility.AXNode for this DOM node, if it exists, plus its ancestors, siblings and children, if requested. +func (p *GetPartialAXTreeParams) Do(ctx context.Context) (nodes []*Node, err error) { + // execute + var res GetPartialAXTreeReturns + err = cdp.Execute(ctx, CommandGetPartialAXTree, p, &res) + if err != nil { + return nil, err + } + + return res.Nodes, nil +} + +// GetFullAXTreeParams fetches the entire accessibility tree for the root +// Document. +type GetFullAXTreeParams struct { + Depth int64 `json:"depth,omitempty,omitzero"` // The maximum depth at which descendants of the root node should be retrieved. If omitted, the full tree is returned. + FrameID cdp.FrameID `json:"frameId,omitempty,omitzero"` // The frame for whose document the AX tree should be retrieved. If omitted, the root frame is used. +} + +// GetFullAXTree fetches the entire accessibility tree for the root Document. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-getFullAXTree +// +// parameters: +func GetFullAXTree() *GetFullAXTreeParams { + return &GetFullAXTreeParams{} +} + +// WithDepth the maximum depth at which descendants of the root node should +// be retrieved. If omitted, the full tree is returned. +func (p GetFullAXTreeParams) WithDepth(depth int64) *GetFullAXTreeParams { + p.Depth = depth + return &p +} + +// WithFrameID the frame for whose document the AX tree should be retrieved. +// If omitted, the root frame is used. +func (p GetFullAXTreeParams) WithFrameID(frameID cdp.FrameID) *GetFullAXTreeParams { + p.FrameID = frameID + return &p +} + +// GetFullAXTreeReturns return values. +type GetFullAXTreeReturns struct { + Nodes []*Node `json:"nodes,omitempty,omitzero"` +} + +// Do executes Accessibility.getFullAXTree against the provided context. +// +// returns: +// +// nodes +func (p *GetFullAXTreeParams) Do(ctx context.Context) (nodes []*Node, err error) { + // execute + var res GetFullAXTreeReturns + err = cdp.Execute(ctx, CommandGetFullAXTree, p, &res) + if err != nil { + return nil, err + } + + return res.Nodes, nil +} + +// GetRootAXNodeParams fetches the root node. Requires enable() to have been +// called previously. +type GetRootAXNodeParams struct { + FrameID cdp.FrameID `json:"frameId,omitempty,omitzero"` // The frame in whose document the node resides. If omitted, the root frame is used. +} + +// GetRootAXNode fetches the root node. Requires enable() to have been called +// previously. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-getRootAXNode +// +// parameters: +func GetRootAXNode() *GetRootAXNodeParams { + return &GetRootAXNodeParams{} +} + +// WithFrameID the frame in whose document the node resides. If omitted, the +// root frame is used. +func (p GetRootAXNodeParams) WithFrameID(frameID cdp.FrameID) *GetRootAXNodeParams { + p.FrameID = frameID + return &p +} + +// GetRootAXNodeReturns return values. +type GetRootAXNodeReturns struct { + Node *Node `json:"node,omitempty,omitzero"` +} + +// Do executes Accessibility.getRootAXNode against the provided context. +// +// returns: +// +// node +func (p *GetRootAXNodeParams) Do(ctx context.Context) (node *Node, err error) { + // execute + var res GetRootAXNodeReturns + err = cdp.Execute(ctx, CommandGetRootAXNode, p, &res) + if err != nil { + return nil, err + } + + return res.Node, nil +} + +// GetAXNodeAndAncestorsParams fetches a node and all ancestors up to and +// including the root. Requires enable() to have been called previously. +type GetAXNodeAndAncestorsParams struct { + NodeID cdp.NodeID `json:"nodeId,omitempty,omitzero"` // Identifier of the node to get. + BackendNodeID cdp.BackendNodeID `json:"backendNodeId,omitempty,omitzero"` // Identifier of the backend node to get. + ObjectID runtime.RemoteObjectID `json:"objectId,omitempty,omitzero"` // JavaScript object id of the node wrapper to get. +} + +// GetAXNodeAndAncestors fetches a node and all ancestors up to and including +// the root. Requires enable() to have been called previously. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-getAXNodeAndAncestors +// +// parameters: +func GetAXNodeAndAncestors() *GetAXNodeAndAncestorsParams { + return &GetAXNodeAndAncestorsParams{} +} + +// WithNodeID identifier of the node to get. +func (p GetAXNodeAndAncestorsParams) WithNodeID(nodeID cdp.NodeID) *GetAXNodeAndAncestorsParams { + p.NodeID = nodeID + return &p +} + +// WithBackendNodeID identifier of the backend node to get. +func (p GetAXNodeAndAncestorsParams) WithBackendNodeID(backendNodeID cdp.BackendNodeID) *GetAXNodeAndAncestorsParams { + p.BackendNodeID = backendNodeID + return &p +} + +// WithObjectID JavaScript object id of the node wrapper to get. +func (p GetAXNodeAndAncestorsParams) WithObjectID(objectID runtime.RemoteObjectID) *GetAXNodeAndAncestorsParams { + p.ObjectID = objectID + return &p +} + +// GetAXNodeAndAncestorsReturns return values. +type GetAXNodeAndAncestorsReturns struct { + Nodes []*Node `json:"nodes,omitempty,omitzero"` +} + +// Do executes Accessibility.getAXNodeAndAncestors against the provided context. +// +// returns: +// +// nodes +func (p *GetAXNodeAndAncestorsParams) Do(ctx context.Context) (nodes []*Node, err error) { + // execute + var res GetAXNodeAndAncestorsReturns + err = cdp.Execute(ctx, CommandGetAXNodeAndAncestors, p, &res) + if err != nil { + return nil, err + } + + return res.Nodes, nil +} + +// GetChildAXNodesParams fetches a particular accessibility node by AXNodeId. +// Requires enable() to have been called previously. +type GetChildAXNodesParams struct { + ID NodeID `json:"id"` + FrameID cdp.FrameID `json:"frameId,omitempty,omitzero"` // The frame in whose document the node resides. If omitted, the root frame is used. +} + +// GetChildAXNodes fetches a particular accessibility node by AXNodeId. +// Requires enable() to have been called previously. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-getChildAXNodes +// +// parameters: +// +// id +func GetChildAXNodes(id NodeID) *GetChildAXNodesParams { + return &GetChildAXNodesParams{ + ID: id, + } +} + +// WithFrameID the frame in whose document the node resides. If omitted, the +// root frame is used. +func (p GetChildAXNodesParams) WithFrameID(frameID cdp.FrameID) *GetChildAXNodesParams { + p.FrameID = frameID + return &p +} + +// GetChildAXNodesReturns return values. +type GetChildAXNodesReturns struct { + Nodes []*Node `json:"nodes,omitempty,omitzero"` +} + +// Do executes Accessibility.getChildAXNodes against the provided context. +// +// returns: +// +// nodes +func (p *GetChildAXNodesParams) Do(ctx context.Context) (nodes []*Node, err error) { + // execute + var res GetChildAXNodesReturns + err = cdp.Execute(ctx, CommandGetChildAXNodes, p, &res) + if err != nil { + return nil, err + } + + return res.Nodes, nil +} + +// QueryAXTreeParams query a DOM node's accessibility subtree for accessible +// name and role. This command computes the name and role for all nodes in the +// subtree, including those that are ignored for accessibility, and returns +// those that match the specified name and role. If no DOM node is specified, or +// the DOM node does not exist, the command returns an error. If neither +// accessibleName or role is specified, it returns all the accessibility nodes +// in the subtree. +type QueryAXTreeParams struct { + NodeID cdp.NodeID `json:"nodeId,omitempty,omitzero"` // Identifier of the node for the root to query. + BackendNodeID cdp.BackendNodeID `json:"backendNodeId,omitempty,omitzero"` // Identifier of the backend node for the root to query. + ObjectID runtime.RemoteObjectID `json:"objectId,omitempty,omitzero"` // JavaScript object id of the node wrapper for the root to query. + AccessibleName string `json:"accessibleName,omitempty,omitzero"` // Find nodes with this computed name. + Role string `json:"role,omitempty,omitzero"` // Find nodes with this computed role. +} + +// QueryAXTree query a DOM node's accessibility subtree for accessible name +// and role. This command computes the name and role for all nodes in the +// subtree, including those that are ignored for accessibility, and returns +// those that match the specified name and role. If no DOM node is specified, or +// the DOM node does not exist, the command returns an error. If neither +// accessibleName or role is specified, it returns all the accessibility nodes +// in the subtree. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#method-queryAXTree +// +// parameters: +func QueryAXTree() *QueryAXTreeParams { + return &QueryAXTreeParams{} +} + +// WithNodeID identifier of the node for the root to query. +func (p QueryAXTreeParams) WithNodeID(nodeID cdp.NodeID) *QueryAXTreeParams { + p.NodeID = nodeID + return &p +} + +// WithBackendNodeID identifier of the backend node for the root to query. +func (p QueryAXTreeParams) WithBackendNodeID(backendNodeID cdp.BackendNodeID) *QueryAXTreeParams { + p.BackendNodeID = backendNodeID + return &p +} + +// WithObjectID JavaScript object id of the node wrapper for the root to +// query. +func (p QueryAXTreeParams) WithObjectID(objectID runtime.RemoteObjectID) *QueryAXTreeParams { + p.ObjectID = objectID + return &p +} + +// WithAccessibleName find nodes with this computed name. +func (p QueryAXTreeParams) WithAccessibleName(accessibleName string) *QueryAXTreeParams { + p.AccessibleName = accessibleName + return &p +} + +// WithRole find nodes with this computed role. +func (p QueryAXTreeParams) WithRole(role string) *QueryAXTreeParams { + p.Role = role + return &p +} + +// QueryAXTreeReturns return values. +type QueryAXTreeReturns struct { + Nodes []*Node `json:"nodes,omitempty,omitzero"` // A list of Accessibility.AXNode matching the specified attributes, including nodes that are ignored for accessibility. +} + +// Do executes Accessibility.queryAXTree against the provided context. +// +// returns: +// +// nodes - A list of Accessibility.AXNode matching the specified attributes, including nodes that are ignored for accessibility. +func (p *QueryAXTreeParams) Do(ctx context.Context) (nodes []*Node, err error) { + // execute + var res QueryAXTreeReturns + err = cdp.Execute(ctx, CommandQueryAXTree, p, &res) + if err != nil { + return nil, err + } + + return res.Nodes, nil +} + +// Command names. +const ( + CommandDisable = "Accessibility.disable" + CommandEnable = "Accessibility.enable" + CommandGetPartialAXTree = "Accessibility.getPartialAXTree" + CommandGetFullAXTree = "Accessibility.getFullAXTree" + CommandGetRootAXNode = "Accessibility.getRootAXNode" + CommandGetAXNodeAndAncestors = "Accessibility.getAXNodeAndAncestors" + CommandGetChildAXNodes = "Accessibility.getChildAXNodes" + CommandQueryAXTree = "Accessibility.queryAXTree" +) diff --git a/vendor/github.com/chromedp/cdproto/accessibility/events.go b/vendor/github.com/chromedp/cdproto/accessibility/events.go new file mode 100644 index 00000000..ae3b511f --- /dev/null +++ b/vendor/github.com/chromedp/cdproto/accessibility/events.go @@ -0,0 +1,20 @@ +package accessibility + +// Code generated by cdproto-gen. DO NOT EDIT. + +// EventLoadComplete the loadComplete event mirrors the load complete event +// sent by the browser to assistive technology when the web page has finished +// loading. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#event-loadComplete +type EventLoadComplete struct { + Root *Node `json:"root"` // New document root node. +} + +// EventNodesUpdated the nodesUpdated event is sent every time a previously +// requested node has changed the in tree. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#event-nodesUpdated +type EventNodesUpdated struct { + Nodes []*Node `json:"nodes"` // Updated node data. +} diff --git a/vendor/github.com/chromedp/cdproto/accessibility/types.go b/vendor/github.com/chromedp/cdproto/accessibility/types.go new file mode 100644 index 00000000..e4af1e1b --- /dev/null +++ b/vendor/github.com/chromedp/cdproto/accessibility/types.go @@ -0,0 +1,414 @@ +package accessibility + +// Code generated by cdproto-gen. DO NOT EDIT. + +import ( + "fmt" + "strings" + + "github.com/chromedp/cdproto/cdp" + "github.com/go-json-experiment/json/jsontext" +) + +// NodeID unique accessibility node identifier. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#type-AXNodeId +type NodeID string + +// String returns the NodeID as string value. +func (t NodeID) String() string { + return string(t) +} + +// ValueType enum of possible property types. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#type-AXValueType +type ValueType string + +// String returns the ValueType as string value. +func (t ValueType) String() string { + return string(t) +} + +// ValueType values. +const ( + ValueTypeBoolean ValueType = "boolean" + ValueTypeTristate ValueType = "tristate" + ValueTypeBooleanOrUndefined ValueType = "booleanOrUndefined" + ValueTypeIdref ValueType = "idref" + ValueTypeIdrefList ValueType = "idrefList" + ValueTypeInteger ValueType = "integer" + ValueTypeNode ValueType = "node" + ValueTypeNodeList ValueType = "nodeList" + ValueTypeNumber ValueType = "number" + ValueTypeString ValueType = "string" + ValueTypeComputedString ValueType = "computedString" + ValueTypeToken ValueType = "token" + ValueTypeTokenList ValueType = "tokenList" + ValueTypeDomRelation ValueType = "domRelation" + ValueTypeRole ValueType = "role" + ValueTypeInternalRole ValueType = "internalRole" + ValueTypeValueUndefined ValueType = "valueUndefined" +) + +// UnmarshalJSON satisfies [json.Unmarshaler]. +func (t *ValueType) UnmarshalJSON(buf []byte) error { + s := string(buf) + s = strings.TrimSuffix(strings.TrimPrefix(s, `"`), `"`) + + switch ValueType(s) { + case ValueTypeBoolean: + *t = ValueTypeBoolean + case ValueTypeTristate: + *t = ValueTypeTristate + case ValueTypeBooleanOrUndefined: + *t = ValueTypeBooleanOrUndefined + case ValueTypeIdref: + *t = ValueTypeIdref + case ValueTypeIdrefList: + *t = ValueTypeIdrefList + case ValueTypeInteger: + *t = ValueTypeInteger + case ValueTypeNode: + *t = ValueTypeNode + case ValueTypeNodeList: + *t = ValueTypeNodeList + case ValueTypeNumber: + *t = ValueTypeNumber + case ValueTypeString: + *t = ValueTypeString + case ValueTypeComputedString: + *t = ValueTypeComputedString + case ValueTypeToken: + *t = ValueTypeToken + case ValueTypeTokenList: + *t = ValueTypeTokenList + case ValueTypeDomRelation: + *t = ValueTypeDomRelation + case ValueTypeRole: + *t = ValueTypeRole + case ValueTypeInternalRole: + *t = ValueTypeInternalRole + case ValueTypeValueUndefined: + *t = ValueTypeValueUndefined + default: + return fmt.Errorf("unknown ValueType value: %v", s) + } + return nil +} + +// ValueSourceType enum of possible property sources. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#type-AXValueSourceType +type ValueSourceType string + +// String returns the ValueSourceType as string value. +func (t ValueSourceType) String() string { + return string(t) +} + +// ValueSourceType values. +const ( + ValueSourceTypeAttribute ValueSourceType = "attribute" + ValueSourceTypeImplicit ValueSourceType = "implicit" + ValueSourceTypeStyle ValueSourceType = "style" + ValueSourceTypeContents ValueSourceType = "contents" + ValueSourceTypePlaceholder ValueSourceType = "placeholder" + ValueSourceTypeRelatedElement ValueSourceType = "relatedElement" +) + +// UnmarshalJSON satisfies [json.Unmarshaler]. +func (t *ValueSourceType) UnmarshalJSON(buf []byte) error { + s := string(buf) + s = strings.TrimSuffix(strings.TrimPrefix(s, `"`), `"`) + + switch ValueSourceType(s) { + case ValueSourceTypeAttribute: + *t = ValueSourceTypeAttribute + case ValueSourceTypeImplicit: + *t = ValueSourceTypeImplicit + case ValueSourceTypeStyle: + *t = ValueSourceTypeStyle + case ValueSourceTypeContents: + *t = ValueSourceTypeContents + case ValueSourceTypePlaceholder: + *t = ValueSourceTypePlaceholder + case ValueSourceTypeRelatedElement: + *t = ValueSourceTypeRelatedElement + default: + return fmt.Errorf("unknown ValueSourceType value: %v", s) + } + return nil +} + +// ValueNativeSourceType enum of possible native property sources (as a +// subtype of a particular AXValueSourceType). +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#type-AXValueNativeSourceType +type ValueNativeSourceType string + +// String returns the ValueNativeSourceType as string value. +func (t ValueNativeSourceType) String() string { + return string(t) +} + +// ValueNativeSourceType values. +const ( + ValueNativeSourceTypeDescription ValueNativeSourceType = "description" + ValueNativeSourceTypeFigcaption ValueNativeSourceType = "figcaption" + ValueNativeSourceTypeLabel ValueNativeSourceType = "label" + ValueNativeSourceTypeLabelfor ValueNativeSourceType = "labelfor" + ValueNativeSourceTypeLabelwrapped ValueNativeSourceType = "labelwrapped" + ValueNativeSourceTypeLegend ValueNativeSourceType = "legend" + ValueNativeSourceTypeRubyannotation ValueNativeSourceType = "rubyannotation" + ValueNativeSourceTypeTablecaption ValueNativeSourceType = "tablecaption" + ValueNativeSourceTypeTitle ValueNativeSourceType = "title" + ValueNativeSourceTypeOther ValueNativeSourceType = "other" +) + +// UnmarshalJSON satisfies [json.Unmarshaler]. +func (t *ValueNativeSourceType) UnmarshalJSON(buf []byte) error { + s := string(buf) + s = strings.TrimSuffix(strings.TrimPrefix(s, `"`), `"`) + + switch ValueNativeSourceType(s) { + case ValueNativeSourceTypeDescription: + *t = ValueNativeSourceTypeDescription + case ValueNativeSourceTypeFigcaption: + *t = ValueNativeSourceTypeFigcaption + case ValueNativeSourceTypeLabel: + *t = ValueNativeSourceTypeLabel + case ValueNativeSourceTypeLabelfor: + *t = ValueNativeSourceTypeLabelfor + case ValueNativeSourceTypeLabelwrapped: + *t = ValueNativeSourceTypeLabelwrapped + case ValueNativeSourceTypeLegend: + *t = ValueNativeSourceTypeLegend + case ValueNativeSourceTypeRubyannotation: + *t = ValueNativeSourceTypeRubyannotation + case ValueNativeSourceTypeTablecaption: + *t = ValueNativeSourceTypeTablecaption + case ValueNativeSourceTypeTitle: + *t = ValueNativeSourceTypeTitle + case ValueNativeSourceTypeOther: + *t = ValueNativeSourceTypeOther + default: + return fmt.Errorf("unknown ValueNativeSourceType value: %v", s) + } + return nil +} + +// ValueSource a single source for a computed AX property. +// +// See: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility#type-AXValueSource +type ValueSource struct { + Type ValueSourceType `json:"type"` // What type of source this is. + Value *Value `json:"value,omitempty,omitzero"` // The value of this property source. + Attribute string `json:"attribute,omitempty,omitzero"` // The name of the relevant attribute, if any. + AttributeValue *Value `json:"attributeValue,omitempty,omitzero"` // The value of the relevant attribute, if any. + Superseded bool `json:"superseded"` // Whether this source is superseded by a higher priority source. + NativeSource ValueNativeSourceType `json:"nativeSource,omitempty,omitzero"` // The native markup source for this value, e.g. a