mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
- Set viewport size to avoid responsive mode - Added way more debug logging - Added facility to write a screenshot on error in verbose mode. - Added a deadline for each iteraction of clicking on and waiting for a thing.
273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
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))]
|
|
}
|