From 1a2910dfb0ea61a54a1e15b68f69e4b9311279ae Mon Sep 17 00:00:00 2001 From: vircoys Date: Tue, 11 Aug 2026 21:44:06 +0800 Subject: [PATCH] feat(dialtesting): extend Lightpanda browser checks --- dialtesting/README.md | 17 ++ dialtesting/browser.go | 41 +++++ dialtesting/browser_test.go | 28 ++- .../browserdial/lightpanda/lightpanda.go | 76 +++++++- .../lightpanda/lightpanda_args_test.go | 87 +++++++++ dialtesting/browserdial/runner/runner.go | 167 ++++++++++++------ dialtesting/browserdial/runner/runner_test.go | 133 +++++++++++++- dialtesting/browserdial/script/script.go | 3 +- dialtesting/browserdial/script/script_test.go | 4 + 9 files changed, 489 insertions(+), 67 deletions(-) create mode 100644 dialtesting/browserdial/lightpanda/lightpanda_args_test.go diff --git a/dialtesting/README.md b/dialtesting/README.md index db1311cc..917591a5 100644 --- a/dialtesting/README.md +++ b/dialtesting/README.md @@ -28,6 +28,18 @@ Browser tasks require these binaries on the dial node: Chrome can be configured by `Task.SetOption()["chrome_path"]`. Lightpanda can be configured by `Task.SetOption()["lightpanda_path"]`. +Lightpanda also reads these node-level task options: + +- `browser_ca_cert_file`: absolute path to a custom CA certificate file. +- `browser_ca_cert_dir`: absolute path to a custom CA certificate directory. +- `browser_proxy_url`: default HTTP proxy URL. +- `browser_block_private_network`: set to `true` to block private-network requests. +- `browser_block_cidrs`: comma-separated CIDRs to block instead of all private ranges. + +When a custom CA is configured, the runner passes a detected system CA directory +and the custom CA path as separate Lightpanda arguments so both trust sources are +loaded without moving certificate files. + ## Task Fields Browser tasks use the normal common task fields, including: @@ -189,6 +201,7 @@ Supported step actions: | --- | --- | --- | | `goto` | `url` or top-level `target` | Navigate to a page. | | `wait_for_selector` | `selector` | Wait until the selector appears. | +| `wait_for_url` | `contains`, `equals`, or `text` | Wait until the current URL matches. | | `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. | @@ -196,6 +209,10 @@ Supported step actions: | `assert_text` | `selector`, plus `contains`, `equals`, or `text` | Assert element text. | | `eval` | `value` or `text` | Evaluate JavaScript in the page. | +`wait_for_url`, `assert_title`, `assert_url`, and `assert_text` poll until they +match or time out. A step `timeout_ms` takes precedence; otherwise the script +timeout applies. + 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. diff --git a/dialtesting/browser.go b/dialtesting/browser.go index 379c6eb4..bed6b647 100644 --- a/dialtesting/browser.go +++ b/dialtesting/browser.go @@ -40,6 +40,11 @@ const ( optionLightpandaPath = "lightpanda_path" optionLightpandaPathCamel = "lightpandaPath" + optionBrowserCACertFile = "browser_ca_cert_file" + optionBrowserCACertDir = "browser_ca_cert_dir" + optionBrowserProxyURL = "browser_proxy_url" + optionBlockPrivateNetwork = "browser_block_private_network" + optionBrowserBlockCIDRs = "browser_block_cidrs" optionChromePath = "chrome_path" optionChromePathCamel = "chromePath" @@ -346,6 +351,11 @@ func (t *BrowserTask) runBrowserDialEmbedded(path string, viewport BrowserViewpo Tags: t.Tags, EngineName: engineName, LightpandaPath: t.lightpandaPath(), + CACertFile: t.browserCACertFile(), + CACertDir: t.browserCACertDir(), + DefaultProxyURL: t.browserDefaultProxyURL(), + BlockPrivateNetwork: t.browserBlockPrivateNetwork(), + PrivateNetworkCIDRs: t.browserPrivateNetworkCIDRs(), ChromePath: t.chromePath(), StartupTimeout: 5 * time.Second, ScreenshotOnFailure: t.AdvanceOptions != nil && t.AdvanceOptions.ScreenshotOnFailure, @@ -505,6 +515,37 @@ func (t *BrowserTask) lightpandaPath() string { return "" } +func (t *BrowserTask) browserCACertFile() string { + return strings.TrimSpace(t.GetOption()[optionBrowserCACertFile]) +} + +func (t *BrowserTask) browserCACertDir() string { + return strings.TrimSpace(t.GetOption()[optionBrowserCACertDir]) +} + +func (t *BrowserTask) browserDefaultProxyURL() string { + return strings.TrimSpace(t.GetOption()[optionBrowserProxyURL]) +} + +func (t *BrowserTask) browserBlockPrivateNetwork() bool { + return strings.EqualFold(strings.TrimSpace(t.GetOption()[optionBlockPrivateNetwork]), "true") +} + +func (t *BrowserTask) browserPrivateNetworkCIDRs() []string { + value := strings.TrimSpace(t.GetOption()[optionBrowserBlockCIDRs]) + if value == "" { + return nil + } + parts := strings.Split(value, ",") + cidrs := make([]string, 0, len(parts)) + for _, part := range parts { + if cidr := strings.TrimSpace(part); cidr != "" { + cidrs = append(cidrs, cidr) + } + } + return cidrs +} + func (t *BrowserTask) chromePath() string { if value := t.GetOption()[optionChromePath]; value != "" { return value diff --git a/dialtesting/browser_test.go b/dialtesting/browser_test.go index df20a9e8..6ff5f482 100644 --- a/dialtesting/browser_test.go +++ b/dialtesting/browser_test.go @@ -58,7 +58,12 @@ func TestBrowserTaskRunSetsLightpandaPath(t *testing.T) { task, err := NewTask("", browserTask) require.NoError(t, err) task.SetOption(map[string]string{ - optionLightpandaPath: "/opt/datakit/lightpanda", + optionLightpandaPath: "/opt/datakit/lightpanda", + optionBrowserCACertFile: "/etc/datakit/certs/internal-ca.pem", + optionBrowserCACertDir: "/etc/datakit/certs/roots", + optionBrowserProxyURL: "http://proxy.example.com:8080", + optionBlockPrivateNetwork: "true", + optionBrowserBlockCIDRs: "10.0.0.0/8, 192.168.0.0/16", }) var gotOptions browserrunner.EngineOptions @@ -70,6 +75,11 @@ func TestBrowserTaskRunSetsLightpandaPath(t *testing.T) { assert.Equal(t, "OK", tags["status"]) assert.Equal(t, int64(1), fields["success"]) assert.Equal(t, "/opt/datakit/lightpanda", gotOptions.LightpandaPath) + assert.Equal(t, "/etc/datakit/certs/internal-ca.pem", gotOptions.CACertFile) + assert.Equal(t, "/etc/datakit/certs/roots", gotOptions.CACertDir) + assert.Equal(t, "http://proxy.example.com:8080", gotOptions.ProxyURL) + assert.True(t, gotOptions.BlockPrivateNetwork) + assert.Equal(t, []string{"10.0.0.0/8", "192.168.0.0/16"}, gotOptions.PrivateNetworkCIDRs) } func TestBrowserTaskRunSetsChromePath(t *testing.T) { @@ -691,6 +701,22 @@ func TestBrowserTaskLightpandaPathOption(t *testing.T) { assert.Empty(t, task.lightpandaPath()) } +func TestBrowserTaskLightpandaRuntimeOptions(t *testing.T) { + task := &BrowserTask{Task: &Task{}} + task.SetOption(map[string]string{ + optionBrowserCACertFile: " /etc/datakit/certs/internal-ca.pem ", + optionBrowserCACertDir: " /etc/datakit/certs/roots ", + optionBrowserProxyURL: " http://proxy.example.com:8080 ", + optionBlockPrivateNetwork: "TRUE", + optionBrowserBlockCIDRs: "10.0.0.0/8, 192.168.0.0/16", + }) + assert.Equal(t, "/etc/datakit/certs/internal-ca.pem", task.browserCACertFile()) + assert.Equal(t, "/etc/datakit/certs/roots", task.browserCACertDir()) + assert.Equal(t, "http://proxy.example.com:8080", task.browserDefaultProxyURL()) + assert.True(t, task.browserBlockPrivateNetwork()) + assert.Equal(t, []string{"10.0.0.0/8", "192.168.0.0/16"}, task.browserPrivateNetworkCIDRs()) +} + func TestBrowserTaskChromePathOption(t *testing.T) { task := &BrowserTask{Task: &Task{}} task.SetOption(map[string]string{optionChromePath: "/opt/chrome"}) diff --git a/dialtesting/browserdial/lightpanda/lightpanda.go b/dialtesting/browserdial/lightpanda/lightpanda.go index cd3ba8a4..bb1d6843 100644 --- a/dialtesting/browserdial/lightpanda/lightpanda.go +++ b/dialtesting/browserdial/lightpanda/lightpanda.go @@ -28,6 +28,11 @@ import ( const defaultHost = "127.0.0.1" +var systemCACertificateDirectories = []string{ + "/etc/ssl/certs", + "/etc/pki/tls/certs", +} + type Engine struct { ctx context.Context cancel context.CancelFunc @@ -48,11 +53,15 @@ type requestInfo struct { } func NewEngine(ctx context.Context, options runner.EngineOptions) (runner.Engine, error) { + arguments, err := lightpandaArguments(options) + if err != nil { + return nil, err + } executable, err := resolveExecutable(options.LightpandaPath) if err != nil { return nil, err } - activeSession, err := start(ctx, executable, options.StartupTimeout) + activeSession, err := start(ctx, executable, options.StartupTimeout, arguments) if err != nil { return nil, err } @@ -89,6 +98,65 @@ func NewEngine(ctx context.Context, options runner.EngineOptions) (runner.Engine return engine, nil } +func lightpandaArguments(options runner.EngineOptions) ([]string, error) { + return lightpandaArgumentsWithSystemCADirectories(options, systemCACertificateDirectories) +} + +func lightpandaArgumentsWithSystemCADirectories(options runner.EngineOptions, systemDirectories []string) ([]string, error) { + arguments := []string{} + caCertFile := strings.TrimSpace(options.CACertFile) + caCertDir := strings.TrimSpace(options.CACertDir) + if caCertFile != "" || caCertDir != "" { + for _, directory := range systemDirectories { + directory = strings.TrimSpace(directory) + if directory == "" || directory == caCertDir { + continue + } + info, err := os.Stat(directory) + if err == nil && info.IsDir() { + arguments = append(arguments, "--ca-path", directory) + break + } + } + } + if caCertFile != "" { + if !filepath.IsAbs(caCertFile) { + return nil, fmt.Errorf("browser CA certificate file path must be absolute: %s", caCertFile) + } + arguments = append(arguments, "--ca-cert", caCertFile) + } + if caCertDir != "" { + if !filepath.IsAbs(caCertDir) { + return nil, fmt.Errorf("browser CA certificate directory path must be absolute: %s", caCertDir) + } + arguments = append(arguments, "--ca-path", caCertDir) + } + if proxyURL := strings.TrimSpace(options.ProxyURL); proxyURL != "" { + parsed, err := url.Parse(proxyURL) + if err != nil || parsed.Host == "" || !strings.EqualFold(parsed.Scheme, "http") { + return nil, fmt.Errorf("lightpanda proxy URL must use the http scheme and include a host") + } + arguments = append(arguments, "--http-proxy", proxyURL) + } + cidrs := make([]string, 0, len(options.PrivateNetworkCIDRs)) + for _, value := range options.PrivateNetworkCIDRs { + cidr := strings.TrimSpace(value) + if cidr == "" { + continue + } + if _, _, err := net.ParseCIDR(cidr); err != nil { + return nil, fmt.Errorf("invalid private network CIDR %q: %w", cidr, err) + } + cidrs = append(cidrs, cidr) + } + if len(cidrs) > 0 { + arguments = append(arguments, "--block-cidrs", strings.Join(cidrs, ",")) + } else if options.BlockPrivateNetwork { + arguments = append(arguments, "--block-private-networks") + } + return arguments, nil +} + func (e *Engine) ConfigureBrowser(ctx context.Context, config runner.BrowserConfig) error { actionCtx, cancel := e.actionContext(ctx) defer cancel() @@ -362,7 +430,7 @@ type session struct { logs *limitedBuffer } -func start(parent context.Context, executable string, startupTimeout time.Duration) (*session, error) { +func start(parent context.Context, executable string, startupTimeout time.Duration, extraArguments []string) (*session, error) { if startupTimeout <= 0 { startupTimeout = 5 * time.Second } @@ -373,7 +441,9 @@ func start(parent context.Context, executable string, startupTimeout time.Durati 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)) + arguments := []string{"serve", "--host", defaultHost, "--port", strconv.Itoa(port)} + arguments = append(arguments, extraArguments...) + cmd := exec.CommandContext(procCtx, executable, arguments...) cmd.Stdout = logs cmd.Stderr = logs if err := cmd.Start(); err != nil { diff --git a/dialtesting/browserdial/lightpanda/lightpanda_args_test.go b/dialtesting/browserdial/lightpanda/lightpanda_args_test.go new file mode 100644 index 00000000..dca7d51a --- /dev/null +++ b/dialtesting/browserdial/lightpanda/lightpanda_args_test.go @@ -0,0 +1,87 @@ +// 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 lightpanda + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/GuanceCloud/cliutils/dialtesting/browserdial/runner" +) + +func TestLightpandaArguments(t *testing.T) { + root := t.TempDir() + caFile := filepath.Join(root, "internal-ca.pem") + caDir := filepath.Join(root, "roots") + arguments, err := lightpandaArgumentsWithSystemCADirectories(runner.EngineOptions{ + CACertFile: caFile, + CACertDir: caDir, + ProxyURL: "http://user:password@proxy.example.com:8080", + }, nil) + if err != nil { + t.Fatal(err) + } + want := []string{ + "--ca-cert", caFile, + "--ca-path", caDir, + "--http-proxy", "http://user:password@proxy.example.com:8080", + } + if !reflect.DeepEqual(arguments, want) { + t.Fatalf("arguments = %#v, want %#v", arguments, want) + } + + arguments, err = lightpandaArguments(runner.EngineOptions{BlockPrivateNetwork: true}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(arguments, []string{"--block-private-networks"}) { + t.Fatalf("private network block argument is missing: %#v", arguments) + } + arguments, err = lightpandaArguments(runner.EngineOptions{}) + if err != nil { + t.Fatal(err) + } + if len(arguments) != 0 { + t.Fatalf("zero-value options should preserve existing behavior: %#v", arguments) + } +} + +func TestLightpandaArgumentsPreserveSystemCAAndCustomCIDRs(t *testing.T) { + systemDirectory := t.TempDir() + customDirectory := t.TempDir() + arguments, err := lightpandaArgumentsWithSystemCADirectories(runner.EngineOptions{ + CACertDir: customDirectory, + PrivateNetworkCIDRs: []string{"10.0.0.0/8", "192.168.0.0/16"}, + }, []string{systemDirectory}) + if err != nil { + t.Fatal(err) + } + want := []string{ + "--ca-path", systemDirectory, + "--ca-path", customDirectory, + "--block-cidrs", "10.0.0.0/8,192.168.0.0/16", + } + if !reflect.DeepEqual(arguments, want) { + t.Fatalf("arguments = %#v, want %#v", arguments, want) + } +} + +func TestLightpandaArgumentsRejectInvalidValues(t *testing.T) { + tests := []runner.EngineOptions{ + {CACertFile: "internal-ca.pem"}, + {CACertDir: "certs"}, + {ProxyURL: "https://proxy.example.com:8443"}, + {ProxyURL: "socks5://proxy.example.com:1080"}, + {ProxyURL: "http:///missing-host"}, + {PrivateNetworkCIDRs: []string{"not-a-cidr"}}, + } + for _, options := range tests { + if _, err := lightpandaArgumentsWithSystemCADirectories(options, nil); err == nil { + t.Fatalf("expected invalid options to fail: %#v", options) + } + } +} diff --git a/dialtesting/browserdial/runner/runner.go b/dialtesting/browserdial/runner/runner.go index a082ebd4..d2525d28 100644 --- a/dialtesting/browserdial/runner/runner.go +++ b/dialtesting/browserdial/runner/runner.go @@ -62,15 +62,19 @@ type BrowserCookie struct { 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 + LightpandaPath string + CACertFile string + CACertDir string + BlockPrivateNetwork bool + PrivateNetworkCIDRs []string + ChromePath string + ScreenshotDir string + RunID string + ViewportWidth int + ViewportHeight int + ProxyURL string + IgnoreHTTPSErrors bool + StartupTimeout time.Duration } type Options struct { @@ -80,6 +84,11 @@ type Options struct { Tags map[string]string EngineName string LightpandaPath string + CACertFile string + CACertDir string + DefaultProxyURL string + BlockPrivateNetwork bool + PrivateNetworkCIDRs []string ChromePath string StartupTimeout time.Duration ScreenshotOnFailure bool @@ -189,9 +198,6 @@ func runLoaded(ctx context.Context, loaded script.Script, options Options, runID 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 @@ -246,20 +252,24 @@ func retryRecordFromResult(result Result) evidence.RetryRecord { } 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) + proxyURL := engineProxyURL(options.ProxyURL, loaded.ProxyURL, options.DefaultProxyURL) 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, + LightpandaPath: options.LightpandaPath, + CACertFile: options.CACertFile, + CACertDir: options.CACertDir, + BlockPrivateNetwork: options.BlockPrivateNetwork, + PrivateNetworkCIDRs: append([]string(nil), options.PrivateNetworkCIDRs...), + 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" @@ -436,20 +446,10 @@ type screenshotOptions struct { RunID string } -func engineProxyURL(engineName string, values ...string) string { - if engineName == "lightpanda" { - return "" - } +func engineProxyURL(values ...string) string { 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} @@ -508,12 +508,13 @@ func executeSteps(ctx context.Context, engine Engine, s script.Script, timeoutMS 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)} + var conditionErr conditionTimeoutError + if !errors.As(err, &conditionErr) { + if errors.Is(runErr, context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: timeoutMS} + } else if errors.Is(stepErr, context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + err = errorsx.TimeoutError{TimeoutMS: deadlineTimeoutMS(ctx, stepCtx, timeoutMS, step.TimeoutMS)} + } } if plan.Auth { err = errorsx.AuthError{Err: err} @@ -661,6 +662,14 @@ func executeStep(ctx context.Context, engine Engine, s script.Script, step scrip return engine.Navigate(ctx, target) case "wait_for_selector": return engine.WaitForSelector(ctx, step.Selector) + case "wait_for_url": + return pollCondition(ctx, step.Action, func() error { + currentURL, err := engine.URL(ctx) + if err != nil { + return err + } + return compare("url", currentURL, step) + }) case "click": return engine.Click(ctx, step.Selector) case "fill": @@ -670,23 +679,29 @@ func executeStep(ctx context.Context, engine Engine, s script.Script, step scrip } 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) + return pollCondition(ctx, step.Action, func() error { + 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) + return pollCondition(ctx, step.Action, func() error { + 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) + return pollCondition(ctx, step.Action, func() error { + text, err := engine.Text(ctx, step.Selector) + if err != nil { + return err + } + return compare("text", text, step) + }) case "eval": expression := step.Value if expression == "" { @@ -699,6 +714,48 @@ func executeStep(ctx context.Context, engine Engine, s script.Script, step scrip } } +const conditionPollInterval = 100 * time.Millisecond + +type conditionTimeoutError struct { + action string + err error +} + +func (e conditionTimeoutError) Error() string { + return fmt.Sprintf("%s timed out: %v", e.action, e.err) +} + +func (e conditionTimeoutError) Unwrap() error { + return e.err +} + +func pollCondition(ctx context.Context, action string, check func() error) error { + if err := ctx.Err(); err != nil { + return err + } + lastErr := check() + if lastErr == nil { + return nil + } + ticker := time.NewTicker(conditionPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return ctx.Err() + } + return conditionTimeoutError{action: action, err: lastErr} + case <-ticker.C: + if err := check(); err == nil { + return nil + } else { + lastErr = err + } + } + } +} + func stepValue(step script.Step, vars map[string]string) (string, error) { name := strings.TrimSpace(step.ValueFrom) if name == "" { @@ -796,6 +853,8 @@ func classifyFailureType(err error, steps []evidence.StepResult, failReason stri switch action { case "assert_title", "assert_url", "assert_text": return "assertion_failed" + case "wait_for_url": + return "timeout" case "goto": return "navigation_failed" case "wait_for_selector", "click", "fill": diff --git a/dialtesting/browserdial/runner/runner_test.go b/dialtesting/browserdial/runner/runner_test.go index e31ddc58..fc06b221 100644 --- a/dialtesting/browserdial/runner/runner_test.go +++ b/dialtesting/browserdial/runner/runner_test.go @@ -607,12 +607,11 @@ func TestRunAdditionalFailureAndStepBranches(t *testing.T) { gotOptions = options return &fakeEngine{}, nil }, - }); !result.Success || gotOptions.ProxyURL != "" { - t.Fatalf("expected lightpanda proxy to be ignored, result=%#v options=%#v", result, gotOptions) + }); !result.Success || gotOptions.ProxyURL != "http://127.0.0.1:7897" { + t.Fatalf("expected lightpanda task proxy, 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, @@ -621,11 +620,8 @@ func TestRunAdditionalFailureAndStepBranches(t *testing.T) { 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.Success || gotOptions.ProxyURL != "http://127.0.0.1:7897" { + t.Fatalf("expected lightpanda script proxy, result=%#v options=%#v", result, gotOptions) } result := RunScript(context.Background(), script.Script{ @@ -693,6 +689,89 @@ func TestRunConfigureAndScreenshotUnavailableBranches(t *testing.T) { } } +func TestConditionsPollUntilMatch(t *testing.T) { + tests := []struct { + name string + step script.Step + engine *pollingEngine + calls func(*pollingEngine) int + }{ + { + name: "wait for URL", + step: script.Step{Action: "wait_for_url", Contains: "/dashboard"}, + engine: &pollingEngine{urls: []string{"https://example.com/login", "https://example.com/dashboard"}}, + calls: func(engine *pollingEngine) int { return engine.urlCalls }, + }, + { + name: "assert title", + step: script.Step{Action: "assert_title", Equals: "Dashboard"}, + engine: &pollingEngine{titles: []string{"Loading", "Dashboard"}}, + calls: func(engine *pollingEngine) int { return engine.titleCalls }, + }, + { + name: "assert text", + step: script.Step{Action: "assert_text", Selector: "main", Contains: "Ready"}, + engine: &pollingEngine{texts: []string{"Loading", "Ready"}}, + calls: func(engine *pollingEngine) int { return engine.textCalls }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := executeStep(ctx, test.engine, script.Script{}, test.step, nil); err != nil { + t.Fatal(err) + } + if calls := test.calls(test.engine); calls != 2 { + t.Fatalf("condition checks = %d, want 2", calls) + } + }) + } +} + +func TestConditionTimeoutClassification(t *testing.T) { + for _, test := range []struct { + action string + failureType string + }{ + {action: "wait_for_url", failureType: "timeout"}, + {action: "assert_url", failureType: "assertion_failed"}, + } { + t.Run(test.action, func(t *testing.T) { + result := RunScript(context.Background(), script.Script{ + TimeoutMS: 1_000, + Steps: []script.Step{{ + Action: test.action, + Contains: "/dashboard", + TimeoutMS: 150, + }}, + }, Options{ + EngineFactory: func(context.Context, EngineOptions) (Engine, error) { + return &pollingEngine{urls: []string{"https://example.com/login"}}, nil + }, + }) + if result.Success || result.FailureType != test.failureType { + t.Fatalf("unexpected result: %#v", result) + } + if result.Error == nil || !strings.Contains(result.Error.Message, "https://example.com/login") { + t.Fatalf("last observed URL is missing: %#v", result.Error) + } + }) + } +} + +func TestProxyPrecedence(t *testing.T) { + if got := engineProxyURL("task", "script", "node"); got != "task" { + t.Fatalf("proxy = %q, want task", got) + } + if got := engineProxyURL("", "script", "node"); got != "script" { + t.Fatalf("proxy = %q, want script", got) + } + if got := engineProxyURL("", "", "node"); got != "node" { + t.Fatalf("proxy = %q, want node", got) + } +} + func writeScript(t *testing.T, content string) string { t.Helper() dir := t.TempDir() @@ -718,6 +797,44 @@ func scriptForTest() script.Script { } } +type pollingEngine struct { + fakeEngine + urls []string + titles []string + texts []string + urlCalls int + titleCalls int + textCalls int +} + +func (e *pollingEngine) URL(context.Context) (string, error) { + value := sequenceValue(e.urls, e.urlCalls) + e.urlCalls++ + return value, nil +} + +func (e *pollingEngine) Title(context.Context) (string, error) { + value := sequenceValue(e.titles, e.titleCalls) + e.titleCalls++ + return value, nil +} + +func (e *pollingEngine) Text(context.Context, string) (string, error) { + value := sequenceValue(e.texts, e.textCalls) + e.textCalls++ + return value, nil +} + +func sequenceValue(values []string, index int) string { + if len(values) == 0 { + return "" + } + if index >= len(values) { + return values[len(values)-1] + } + return values[index] +} + type fakeEngine struct { url string title string diff --git a/dialtesting/browserdial/script/script.go b/dialtesting/browserdial/script/script.go index 90ddd4bd..c7181208 100644 --- a/dialtesting/browserdial/script/script.go +++ b/dialtesting/browserdial/script/script.go @@ -66,6 +66,7 @@ type Step struct { var supportedActions = map[string]struct{}{ "goto": {}, "wait_for_selector": {}, + "wait_for_url": {}, "click": {}, "fill": {}, "assert_title": {}, @@ -213,7 +214,7 @@ func (s Script) validateStep(label string, index int, step Step) error { if expectedText(step) == "" { return fmt.Errorf("%s %d assert_text requires contains, equals, or text", label, index+1) } - case "assert_title", "assert_url": + case "wait_for_url", "assert_title", "assert_url": if expectedText(step) == "" { return fmt.Errorf("%s %d %s requires contains, equals, or text", label, index+1, action) } diff --git a/dialtesting/browserdial/script/script_test.go b/dialtesting/browserdial/script/script_test.go index 93d11387..6acd31be 100644 --- a/dialtesting/browserdial/script/script_test.go +++ b/dialtesting/browserdial/script/script_test.go @@ -182,6 +182,7 @@ func TestValidateCoversErrorBranchesAndStepHelpers(t *testing.T) { {Steps: []Step{{Action: "unknown"}}}, {Steps: []Step{{Action: "goto"}}}, {Steps: []Step{{Action: "wait_for_selector"}}}, + {Steps: []Step{{Action: "wait_for_url"}}}, {Steps: []Step{{Action: "assert_text", Selector: "body"}}}, {Steps: []Step{{Action: "assert_title"}}}, {Steps: []Step{{Action: "assert_url"}}}, @@ -192,6 +193,9 @@ func TestValidateCoversErrorBranchesAndStepHelpers(t *testing.T) { t.Fatalf("expected validation error for %#v", tc) } } + if err := (Script{Steps: []Step{{Action: "wait_for_url", Contains: "/dashboard"}}}).Validate(); err != nil { + t.Fatalf("valid wait_for_url failed validation: %v", err) + } jsonPath := filepath.Join(t.TempDir(), "script.json") if err := os.WriteFile(jsonPath, []byte(`{"steps":[{"action":"goto","url":"https://example.com"}]}`), 0o644); err != nil {