package dashboard import ( "context" "fmt" "net/url" "os" "path/filepath" "time" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" "golang.org/x/xerrors" "github.com/coder/coder/v2/cryptorand" "cdr.dev/slog" ) // Action is just a function that does something. type Action func(ctx context.Context) error // Selector locates an element on a page. type Selector string // Target is a thing that can be clicked. type Target struct { // Label is a human-readable label for the target. Label Label // ClickOn is the selector that locates the element to be clicked. ClickOn Selector // WaitFor is a selector that is expected to appear after the target is clicked. WaitFor Selector } // Label identifies an action. type Label string var defaultTargets = []Target{ { Label: "workspace_list", ClickOn: `nav a[href="/workspaces"]:not(.active)`, WaitFor: `tr[role="button"][data-testid^="workspace-"]`, }, { Label: "starter_templates", ClickOn: `a[href="/starter-templates"]`, WaitFor: `a[href^="/starter-templates/"]`, }, { Label: "workspace_details", ClickOn: `tr[role="button"][data-testid^="workspace-"]`, WaitFor: `tr[role="button"][data-testid^="build-"]`, }, { Label: "workspace_build_details", ClickOn: `tr[role="button"][data-testid^="build-"]`, WaitFor: `*[aria-label="Build details"]`, }, { Label: "template_list", ClickOn: `nav a[href="/templates"]:not(.active)`, WaitFor: `tr[role="button"][data-testid^="template-"]`, }, { Label: "template_docs", ClickOn: `a[href^="/templates/"][href$="/docs"]:not([aria-current])`, WaitFor: `#readme`, }, { Label: "template_files", ClickOn: `a[href^="/templates/"][href$="/docs"]:not([aria-current])`, WaitFor: `.monaco-editor`, }, { Label: "template_versions", ClickOn: `a[href^="/templates/"][href$="/versions"]:not([aria-current])`, WaitFor: `tr[role="button"][data-testid^="version-"]`, }, { Label: "template_version_details", ClickOn: `tr[role="button"][data-testid^="version-"]`, WaitFor: `.monaco-editor`, }, { Label: "user_list", ClickOn: `nav a[href^="/users"]:not(.active)`, WaitFor: `tr[data-testid^="user-"]`, }, } // clickRandomElement returns an action that will click an element from defaultTargets. // If no elements are found, an error is returned. // If more than one element is found, one is chosen at random. // The label of the clicked element is returned. func clickRandomElement(ctx context.Context, log slog.Logger, randIntn func(int) int, deadline time.Time) (Label, Action, error) { var xpath Selector var found bool var err error matches := make([]Target, 0) for _, tgt := range defaultTargets { xpath, found, err = randMatch(ctx, log, tgt.ClickOn, randIntn, deadline) if err != nil { return "", nil, xerrors.Errorf("find matches for %q: %w", tgt.ClickOn, err) } if !found { continue } matches = append(matches, Target{ Label: tgt.Label, ClickOn: xpath, WaitFor: tgt.WaitFor, }) } if len(matches) == 0 { log.Debug(ctx, "no matches found this time") return "", nil, xerrors.Errorf("no matches found") } match := pick(matches, randIntn) act := func(actx context.Context) error { log.Debug(ctx, "clicking", slog.F("label", match.Label), slog.F("xpath", match.ClickOn)) if err := runWithDeadline(ctx, deadline, chromedp.Click(match.ClickOn, chromedp.NodeReady)); err != nil { log.Error(ctx, "click failed", slog.F("label", match.Label), slog.F("xpath", match.ClickOn), slog.Error(err)) return xerrors.Errorf("click %q: %w", match.ClickOn, err) } if err := runWithDeadline(ctx, deadline, chromedp.WaitReady(match.WaitFor)); err != nil { log.Error(ctx, "wait failed", slog.F("label", match.Label), slog.F("xpath", match.WaitFor), slog.Error(err)) return xerrors.Errorf("wait for %q: %w", match.WaitFor, err) } return nil } return match.Label, act, nil } // randMatch returns a random match for the given selector. // The returned selector is the full XPath of the matched node. // If no matches are found, an error is returned. // If multiple matches are found, one is chosen at random. func randMatch(ctx context.Context, log slog.Logger, s Selector, randIntn func(int) int, deadline time.Time) (Selector, bool, error) { var nodes []*cdp.Node log.Debug(ctx, "getting nodes for selector", slog.F("selector", s)) if err := runWithDeadline(ctx, deadline, chromedp.Nodes(s, &nodes, chromedp.NodeReady, chromedp.AtLeast(0))); err != nil { log.Debug(ctx, "failed to get nodes for selector", slog.F("selector", s), slog.Error(err)) return "", false, xerrors.Errorf("get nodes for selector %q: %w", s, err) } if len(nodes) == 0 { log.Debug(ctx, "no nodes found for selector", slog.F("selector", s)) return "", false, nil } n := pick(nodes, randIntn) log.Debug(ctx, "found node", slog.F("node", n.FullXPath())) return Selector(n.FullXPath()), true, nil } func waitForWorkspacesPageLoaded(ctx context.Context, deadline time.Time) error { return runWithDeadline(ctx, deadline, chromedp.WaitReady(`tbody.MuiTableBody-root`)) } func runWithDeadline(ctx context.Context, deadline time.Time, acts ...chromedp.Action) error { deadlineCtx, deadlineCancel := context.WithDeadline(ctx, deadline) defer deadlineCancel() c := chromedp.FromContext(ctx) tasks := chromedp.Tasks(acts) return tasks.Do(cdp.WithExecutor(deadlineCtx, c.Target)) } // initChromeDPCtx initializes a chromedp context with the given session token cookie // //nolint:revive // yes, headless is a control flag func initChromeDPCtx(ctx context.Context, log slog.Logger, u *url.URL, sessionToken string, headless bool) (context.Context, context.CancelFunc, error) { dir, err := os.MkdirTemp("", "scaletest-dashboard-*") if err != nil { return nil, nil, err } allocOpts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.UserDataDir(dir), chromedp.DisableGPU, ) if !headless { // headless is the default allocOpts = append(allocOpts, chromedp.Flag("headless", false)) } allocCtx, allocCtxCancel := chromedp.NewExecAllocator(ctx, allocOpts...) cdpCtx, cdpCancel := chromedp.NewContext(allocCtx) cancelFunc := func() { cdpCancel() allocCtxCancel() if err := os.RemoveAll(dir); err != nil { log.Error(ctx, "failed to remove temp user data dir", slog.F("dir", dir), slog.Error(err)) } } // force a viewport size of 1024x768 so we don't go into mobile mode if err := chromedp.Run(cdpCtx, chromedp.EmulateViewport(1024, 768)); err != nil { cancelFunc() allocCtxCancel() return nil, nil, xerrors.Errorf("set viewport size: %w", err) } // set cookies if err := setSessionTokenCookie(cdpCtx, sessionToken, u.Host); err != nil { cancelFunc() return nil, nil, xerrors.Errorf("set session token cookie: %w", err) } // visit main page if err := visitMainPage(cdpCtx, u); err != nil { cancelFunc() return nil, nil, xerrors.Errorf("visit main page: %w", err) } return cdpCtx, cancelFunc, nil } func setSessionTokenCookie(ctx context.Context, token, domain string) error { exp := cdp.TimeSinceEpoch(time.Now().Add(24 * time.Hour)) err := chromedp.Run(ctx, network.SetCookie("coder_session_token", token). WithExpires(&exp). WithDomain(domain). WithHTTPOnly(false)) if err != nil { return xerrors.Errorf("set coder_session_token cookie: %w", err) } return nil } func visitMainPage(ctx context.Context, u *url.URL) error { return chromedp.Run(ctx, chromedp.Navigate(u.String())) } func Screenshot(ctx context.Context, name string) (string, error) { var buf []byte if err := chromedp.Run(ctx, chromedp.CaptureScreenshot(&buf)); err != nil { return "", xerrors.Errorf("capture screenshot: %w", err) } randExt, err := cryptorand.String(4) if err != nil { // this should never happen return "", xerrors.Errorf("generate random string: %w", err) } fname := fmt.Sprintf("scaletest-dashboard-%s-%s-%s.png", name, time.Now().Format("20060102-150405"), randExt) pwd, err := os.Getwd() if err != nil { return "", xerrors.Errorf("get working directory: %w", err) } fpath := filepath.Join(pwd, fname) f, err := os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return "", xerrors.Errorf("open file: %w", err) } defer f.Close() if _, err := f.Write(buf); err != nil { return "", xerrors.Errorf("write file: %w", err) } return fpath, nil } // pick chooses a random element from a slice. // If the slice is empty, it returns the zero value of the type. func pick[T any](s []T, randIntn func(int) int) T { if len(s) == 0 { var zero T return zero } // nolint:gosec return s[randIntn(len(s))] }