mirror of https://github.com/grafana/grafana
E2E: Rework the runner (#105712)
parent
fd7b6091a2
commit
185d1a1530
@ -1,39 +0,0 @@ |
|||||||
name: e2e suite |
|
||||||
|
|
||||||
on: |
|
||||||
workflow_call: |
|
||||||
inputs: |
|
||||||
package: |
|
||||||
type: string |
|
||||||
required: true |
|
||||||
suite: |
|
||||||
type: string |
|
||||||
required: true |
|
||||||
|
|
||||||
jobs: |
|
||||||
main: |
|
||||||
runs-on: ubuntu-latest-8-cores |
|
||||||
steps: |
|
||||||
- uses: actions/checkout@v4 |
|
||||||
with: |
|
||||||
persist-credentials: false |
|
||||||
- uses: actions/download-artifact@v4 |
|
||||||
with: |
|
||||||
name: ${{ inputs.package }} |
|
||||||
- uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e |
|
||||||
with: |
|
||||||
verb: run |
|
||||||
args: go run ./pkg/build/e2e --package=grafana.tar.gz --suite=${{ inputs.suite }} |
|
||||||
- name: Set suite name |
|
||||||
id: set-suite-name |
|
||||||
if: success() || failure() |
|
||||||
env: |
|
||||||
SUITE: ${{ inputs.suite }} |
|
||||||
run: | |
|
||||||
echo "suite=$(echo "$SUITE" | sed 's/\//-/g')" >> "$GITHUB_OUTPUT" |
|
||||||
- uses: actions/upload-artifact@v4 |
|
||||||
if: success() || failure() |
|
||||||
with: |
|
||||||
name: e2e-${{ steps.set-suite-name.outputs.suite }}-${{github.run_number}} |
|
||||||
path: videos |
|
||||||
retention-days: 1 |
|
@ -0,0 +1,351 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"os" |
||||||
|
"os/exec" |
||||||
|
"os/signal" |
||||||
|
"path" |
||||||
|
"path/filepath" |
||||||
|
"regexp" |
||||||
|
"strings" |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/urfave/cli/v3" |
||||||
|
) |
||||||
|
|
||||||
|
func main() { |
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
if err := Run().Run(ctx, os.Args); err != nil { |
||||||
|
cancel() |
||||||
|
fmt.Println(err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func Run() *cli.Command { |
||||||
|
var suitePath string |
||||||
|
|
||||||
|
return &cli.Command{ |
||||||
|
Name: "e2e", |
||||||
|
Usage: "Run the test suite", |
||||||
|
Flags: []cli.Flag{ |
||||||
|
&cli.StringFlag{ |
||||||
|
Name: "command", |
||||||
|
Usage: "Cypress command to run. 'open' can be useful for development (enum: run, open)", |
||||||
|
Value: "run", |
||||||
|
Validator: func(s string) error { |
||||||
|
if s != "run" && s != "open" { |
||||||
|
return fmt.Errorf("invalid command: %s, must be 'run' or 'open'", s) |
||||||
|
} |
||||||
|
return nil |
||||||
|
}, |
||||||
|
}, |
||||||
|
&cli.StringFlag{ |
||||||
|
Name: "browser", |
||||||
|
Usage: "Browser to run tests with (e.g.: chrome, electron)", |
||||||
|
Value: "chrome", |
||||||
|
}, |
||||||
|
&cli.StringFlag{ |
||||||
|
Name: "grafana-base-url", |
||||||
|
Usage: "Base URL for Grafana", |
||||||
|
Value: "http://localhost:3001", |
||||||
|
}, |
||||||
|
&cli.BoolFlag{ |
||||||
|
Name: "cypress-video", |
||||||
|
Usage: "Enable Cypress video recordings", |
||||||
|
Value: false, |
||||||
|
}, |
||||||
|
&cli.BoolFlag{ |
||||||
|
Name: "smtp-plugin", |
||||||
|
Usage: "Enable SMTP plugin", |
||||||
|
Value: false, |
||||||
|
}, |
||||||
|
&cli.BoolFlag{ |
||||||
|
Name: "benchmark-plugin", |
||||||
|
Usage: "Enable Benchmark plugin", |
||||||
|
Value: false, |
||||||
|
}, |
||||||
|
&cli.BoolFlag{ |
||||||
|
Name: "slowmo", |
||||||
|
Usage: "Slow down the test run", |
||||||
|
Value: false, |
||||||
|
}, |
||||||
|
&cli.StringSliceFlag{ |
||||||
|
Name: "env", |
||||||
|
Usage: "Additional Cypress environment variables to set (format: KEY=VALUE)", |
||||||
|
Validator: func(s []string) error { |
||||||
|
pattern := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*`) |
||||||
|
for _, v := range s { |
||||||
|
if !pattern.MatchString(v) { |
||||||
|
return fmt.Errorf("invalid environment variable format: %s, must be KEY=VALUE", v) |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
}, |
||||||
|
}, |
||||||
|
&cli.StringSliceFlag{ |
||||||
|
Name: "parameters", |
||||||
|
Usage: "Additional parameters to pass to the Cypress command (e.g. --headed)", |
||||||
|
}, |
||||||
|
&cli.DurationFlag{ |
||||||
|
Name: "timeout", |
||||||
|
Usage: "Timeout for the Cypress command (precision: milliseconds)", |
||||||
|
Value: time.Second * 30, |
||||||
|
Validator: func(d time.Duration) error { |
||||||
|
if d < 0 { |
||||||
|
return fmt.Errorf("timeout must be a positive duration") |
||||||
|
} |
||||||
|
if d.Round(time.Millisecond) != d { |
||||||
|
return fmt.Errorf("timeout must be a whole number of milliseconds") |
||||||
|
} |
||||||
|
return nil |
||||||
|
}, |
||||||
|
}, |
||||||
|
&cli.StringFlag{ |
||||||
|
Name: "timezone", |
||||||
|
Usage: "Timezone to set for the Cypress run (e.g. 'America/New_York')", |
||||||
|
Value: "Pacific/Honolulu", |
||||||
|
}, |
||||||
|
|
||||||
|
&cli.BoolFlag{ |
||||||
|
Name: "start-grafana", |
||||||
|
Usage: "Start and wait for Grafana before running the tests", |
||||||
|
Value: true, |
||||||
|
Category: "Grafana Server", |
||||||
|
}, |
||||||
|
&cli.StringFlag{ |
||||||
|
Name: "license-path", |
||||||
|
Usage: "Path to the Grafana Enterprise license file (optional; requires --start-grafana)", |
||||||
|
Value: "", |
||||||
|
TakesFile: true, |
||||||
|
Category: "Grafana Server", |
||||||
|
}, |
||||||
|
&cli.BoolFlag{ |
||||||
|
Name: "image-renderer", |
||||||
|
Usage: "Install the image renderer plugin (requires --start-grafana)", |
||||||
|
Category: "Grafana Server", |
||||||
|
}, |
||||||
|
|
||||||
|
&cli.StringFlag{ |
||||||
|
Name: "suite", |
||||||
|
Usage: "Path to the suite to run (e.g. './e2e/dashboards-suite')", |
||||||
|
TakesFile: true, |
||||||
|
Required: true, |
||||||
|
Destination: &suitePath, |
||||||
|
}, |
||||||
|
}, |
||||||
|
Action: runAction, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func runAction(ctx context.Context, c *cli.Command) error { |
||||||
|
suitePath := c.String("suite") |
||||||
|
suitePath, err := normalisePath(suitePath) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to normalise suite path: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
repoRoot, err := gitRepoRoot(ctx, suitePath) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to get git repo root: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
screenshotsFolder := path.Join(suitePath, "screenshots") |
||||||
|
videosFolder := path.Join(suitePath, "videos") |
||||||
|
fileServerFolder := path.Join(repoRoot, "e2e", "cypress") |
||||||
|
fixturesFolder := path.Join(fileServerFolder, "fixtures") |
||||||
|
downloadsFolder := path.Join(fileServerFolder, "downloads") |
||||||
|
benchmarkPluginResultsFolder := path.Join(suitePath, "benchmark-results") |
||||||
|
reporter := path.Join(repoRoot, "e2e", "log-reporter.js") |
||||||
|
|
||||||
|
env := map[string]string{ |
||||||
|
"BENCHMARK_PLUGIN_ENABLED": fmt.Sprintf("%t", c.Bool("benchmark-plugin")), |
||||||
|
"SMTP_PLUGIN_ENABLED": fmt.Sprintf("%t", c.Bool("smtp-plugin")), |
||||||
|
"BENCHMARK_PLUGIN_RESULTS_FOLDER": benchmarkPluginResultsFolder, |
||||||
|
"SLOWMO": "0", |
||||||
|
"BASE_URL": c.String("grafana-base-url"), |
||||||
|
} |
||||||
|
for _, v := range c.StringSlice("env") { |
||||||
|
parts := strings.SplitN(v, "=", 2) |
||||||
|
if len(parts) != 2 { |
||||||
|
return fmt.Errorf("invalid environment variable format: %s, must be KEY=VALUE", v) |
||||||
|
} |
||||||
|
env[parts[0]] = parts[1] |
||||||
|
} |
||||||
|
|
||||||
|
cypressConfig := map[string]string{ |
||||||
|
"screenshotsFolder": screenshotsFolder, |
||||||
|
"fixturesFolder": fixturesFolder, |
||||||
|
"videosFolder": videosFolder, |
||||||
|
"downloadsFolder": downloadsFolder, |
||||||
|
"fileServerFolder": fileServerFolder, |
||||||
|
"reporter": reporter, |
||||||
|
|
||||||
|
"specPattern": path.Join(suitePath, "*.spec.ts"), |
||||||
|
"defaultCommandTimeout": fmt.Sprintf("%d", c.Duration("timeout").Milliseconds()), |
||||||
|
"viewportWidth": "1920", |
||||||
|
"viewportHeight": "1080", |
||||||
|
"trashAssetsBeforeRuns": "false", |
||||||
|
"baseUrl": c.String("grafana-base-url"), |
||||||
|
"video": fmt.Sprintf("%t", c.Bool("cypress-video")), |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
if c.Bool("start-grafana") { |
||||||
|
startServerPath := path.Join(repoRoot, "scripts", "grafana-server", "start-server") |
||||||
|
waitForGrafanaPath := path.Join(repoRoot, "scripts", "grafana-server", "wait-for-grafana") |
||||||
|
go func() { |
||||||
|
var args []string |
||||||
|
if c.String("license-path") != "" { |
||||||
|
args = append(args, c.String("license-path")) |
||||||
|
} |
||||||
|
//nolint:gosec
|
||||||
|
cmd := exec.CommandContext(ctx, startServerPath, args...) |
||||||
|
cmd.Dir = repoRoot |
||||||
|
cmd.Env = os.Environ() |
||||||
|
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", c.String("timezone"))) |
||||||
|
if c.Bool("image-renderer") { |
||||||
|
cmd.Env = append(cmd.Env, "INSTALL_IMAGE_RENDERER=true") |
||||||
|
} |
||||||
|
cmd.Stdout = prefixGrafana(os.Stdout) |
||||||
|
cmd.Stderr = prefixGrafana(os.Stderr) |
||||||
|
cmd.Stdin = nil |
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil { |
||||||
|
fmt.Println("Error running Grafana:", err) |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
//nolint:gosec
|
||||||
|
cmd := exec.CommandContext(ctx, waitForGrafanaPath) |
||||||
|
cmd.Dir = repoRoot |
||||||
|
cmd.Env = os.Environ() |
||||||
|
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", c.String("timezone"))) |
||||||
|
cmd.Stdout = prefixGrafana(os.Stdout) |
||||||
|
cmd.Stderr = prefixGrafana(os.Stderr) |
||||||
|
cmd.Stdin = nil |
||||||
|
if err := cmd.Run(); err != nil { |
||||||
|
return fmt.Errorf("failed to wait for Grafana: %w", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
args := []string{"run", "cypress", c.String("command"), |
||||||
|
"--env", joinCypressCfg(env), |
||||||
|
"--config", joinCypressCfg(cypressConfig), |
||||||
|
"--browser", c.String("browser")} |
||||||
|
args = append(args, c.StringSlice("parameters")...) |
||||||
|
//nolint:gosec
|
||||||
|
cmd := exec.CommandContext(ctx, "yarn", args...) |
||||||
|
cmd.Dir = repoRoot |
||||||
|
cmd.Env = os.Environ() |
||||||
|
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", c.String("timezone"))) |
||||||
|
cmd.Stdout = prefixCypress(os.Stdout) |
||||||
|
cmd.Stderr = prefixCypress(os.Stderr) |
||||||
|
cmd.Stdin = os.Stdin |
||||||
|
return cmd.Run() |
||||||
|
} |
||||||
|
|
||||||
|
func gitRepoRoot(ctx context.Context, dir string) (string, error) { |
||||||
|
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") |
||||||
|
cmd.Dir = dir |
||||||
|
out, err := cmd.Output() |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("failed to get git repo root: %w", err) |
||||||
|
} |
||||||
|
p := strings.TrimSpace(string(out)) |
||||||
|
p, err = normalisePath(p) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("failed to normalise git repo root path: %w", err) |
||||||
|
} |
||||||
|
return p, nil |
||||||
|
} |
||||||
|
|
||||||
|
func normalisePath(p string) (string, error) { |
||||||
|
absPath, err := filepath.Abs(p) |
||||||
|
if err != nil { |
||||||
|
return "", fmt.Errorf("failed to get absolute path: %w", err) |
||||||
|
} |
||||||
|
return path.Clean(filepath.ToSlash(absPath)), nil |
||||||
|
} |
||||||
|
|
||||||
|
func joinCypressCfg(cfg map[string]string) string { |
||||||
|
config := make([]string, 0, len(cfg)) |
||||||
|
for k, v := range cfg { |
||||||
|
config = append(config, fmt.Sprintf("%s=%s", k, v)) |
||||||
|
} |
||||||
|
return strings.Join(config, ",") |
||||||
|
} |
||||||
|
|
||||||
|
const ( |
||||||
|
resetColor = "\033[0m" |
||||||
|
yellowColor = "\033[0;33m" |
||||||
|
cyanColor = "\033[0;36m" |
||||||
|
) |
||||||
|
|
||||||
|
func prefixCypress(w io.Writer) io.Writer { |
||||||
|
if _, ok := os.LookupEnv("CI"); ok { |
||||||
|
return w |
||||||
|
} |
||||||
|
|
||||||
|
return newWrappingOutput(cyanColor+"Cypress: ", resetColor, w) |
||||||
|
} |
||||||
|
|
||||||
|
func prefixGrafana(w io.Writer) io.Writer { |
||||||
|
if _, ok := os.LookupEnv("CI"); ok { |
||||||
|
return w |
||||||
|
} |
||||||
|
|
||||||
|
return newWrappingOutput(yellowColor+"Grafana: ", resetColor, w) |
||||||
|
} |
||||||
|
|
||||||
|
var _ io.Writer = (*wrappingOutput)(nil) |
||||||
|
|
||||||
|
type wrappingOutput struct { |
||||||
|
prefix string |
||||||
|
suffix string |
||||||
|
mu *sync.Mutex |
||||||
|
inner io.Writer |
||||||
|
writtenPrefix bool |
||||||
|
} |
||||||
|
|
||||||
|
func newWrappingOutput(prefix, suffix string, inner io.Writer) *wrappingOutput { |
||||||
|
return &wrappingOutput{ |
||||||
|
prefix: prefix, |
||||||
|
suffix: suffix, |
||||||
|
mu: &sync.Mutex{}, |
||||||
|
inner: inner, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (p *wrappingOutput) Write(b []byte) (int, error) { |
||||||
|
p.mu.Lock() |
||||||
|
defer p.mu.Unlock() |
||||||
|
|
||||||
|
for line := range bytes.Lines(b) { |
||||||
|
if !p.writtenPrefix { |
||||||
|
if _, err := p.inner.Write([]byte(p.prefix)); err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
p.writtenPrefix = true |
||||||
|
} |
||||||
|
if _, err := p.inner.Write(line); err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
if bytes.HasSuffix(line, []byte("\n")) { |
||||||
|
p.writtenPrefix = false |
||||||
|
if _, err := p.inner.Write([]byte(p.suffix)); err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return len(b), nil |
||||||
|
} |
Loading…
Reference in new issue