Files
teleport/e2e/runner/playwright.go
T
Ryan Clark 59d065e511 e2e: Add a login test, remove auth setup from report, better error reporting (#67259)
* Add a dedicated login e2e test

* Remove authenticated setup tests from summary

* Change Roles assertion to a header assertion

* pnpm format

* Avoid collisions during report merging

* Use global setup to avoid authentication tests appearing in report

* Add browser name back to auth state file

* Make the default export nicer

* Surface non-test errors and fix stat tally in runner report

* Generate auth state via global setup for browse/codegen

* Report flaky test details and name failing tests

* Persist trace attachments for --test-results

* Make signup test retry-safe and tolerate login rate limit

* pnpm format

* Skip global setup when there are no bootstrapped users

* Make darktheme test retry-safe and tolerate login rate limit
2026-05-29 17:47:23 +00:00

423 lines
12 KiB
Go

/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/sync/errgroup"
)
type playwrightRunner struct {
config *e2eConfig
}
func (p *playwrightRunner) startURL(inst *testInstance) string {
if p.config.teleportURL != "" {
return p.config.teleportURL + "/web"
}
return fmt.Sprintf("https://localhost:%d/web", inst.proxyPort)
}
// callerRelativePaths returns paths relative to the caller's working directory
// so that logged paths are cmd+clickable in terminals.
func (p *playwrightRunner) callerRelativePaths(paths []string) []string {
callerDir := os.Getenv("E2E_CALLER_DIR")
if callerDir == "" {
return paths
}
out := make([]string, len(paths))
for i, path := range paths {
abs := filepath.Join(p.config.e2eDir, path)
if rel, err := filepath.Rel(callerDir, abs); err == nil {
out[i] = rel
} else {
out[i] = path
}
}
return out
}
func (p *playwrightRunner) run(ctx context.Context, mode runMode) error {
switch mode {
case modeTest:
return p.test(ctx, false)
case modeDebug:
return p.test(ctx, true)
case modeUI:
return p.ui(ctx)
case modeCodegen:
return p.codegen(ctx)
case modeBrowse:
return p.openWebAuthenticated(ctx, "open")
case modeBrowseConnect:
return p.openConnectAuthenticated(ctx)
default:
return fmt.Errorf("unknown mode: %s", mode)
}
}
func (p *playwrightRunner) test(ctx context.Context, debug bool) error {
// Keep blobs (and the attachments Playwright extracts into
// blob-reports/resources during merge) under test-results, so they're part
// of the uploaded test-results artifact and the --test-results trace flow
// can resolve them. Outside test-results the merged report references a
// transient sibling dir that's deleted next run and never uploaded.
blobBaseDir := filepath.Join(p.config.e2eDir, "test-results", "blob-reports")
if err := os.RemoveAll(blobBaseDir); err != nil {
return fmt.Errorf("cleaning blob-reports directory: %w", err)
}
baseProjects := []string{"authenticated", "unauthenticated"}
var extraArgs []string
if p.config.updateSnapshots {
extraArgs = append(extraArgs, "--update-snapshots")
}
var g errgroup.Group
g.SetLimit(2)
for _, inst := range p.config.instances {
g.Go(func() error {
if err := inst.start(ctx); err != nil {
return err
}
defer inst.stop()
env, err := p.startEnv(inst)
if err != nil {
return fmt.Errorf("building env for %s: %w", inst.browser, err)
}
if debug {
env = append(env, "PWDEBUG=1")
}
env = append(env, "PLAYWRIGHT_BLOB_OUTPUT_FILE="+filepath.Join(blobBaseDir, inst.browser+".zip"))
args := []string{"exec", "playwright", "test", p.configFlag()}
args = append(args, extraArgs...)
args = append(args, "--reporter=blob,"+filepath.Join(p.config.sharedDir, "scripts", "dot-progress-reporter.ts"))
// Avoid `.playwright-artifacts-<n>` collisions across parallel pnpm runs.
args = append(args, "--output=test-results/"+inst.browser)
for _, proj := range baseProjects {
args = append(args, "--project="+inst.browser+":"+proj)
}
if len(p.config.testFiles) > 0 {
args = append(args, p.config.testFiles...)
}
if len(p.config.testFiles) > 0 {
inst.log.Info("running e2e tests", "files", p.config.testFiles)
} else {
inst.log.Info("running e2e tests", "projects", baseProjects)
}
if err := p.pnpm(ctx, args, env); err != nil {
return fmt.Errorf("playwright tests failed for %s: %w", inst.browser, err)
}
return nil
})
}
if ci := p.config.connectInstance; ci != nil {
g.Go(func() error {
if err := ci.start(ctx); err != nil {
return err
}
defer ci.stop()
env, err := p.startEnv(ci)
if err != nil {
return fmt.Errorf("building env for connect: %w", err)
}
if debug {
env = append(env, "PWDEBUG=1")
}
env = append(env, "PLAYWRIGHT_BLOB_OUTPUT_FILE="+filepath.Join(blobBaseDir, "connect.zip"))
args := []string{"exec", "playwright", "test", p.configFlag()}
args = append(args, extraArgs...)
args = append(args, "--reporter=blob,"+filepath.Join(p.config.sharedDir, "scripts", "dot-progress-reporter.ts"), "--project=connect", "--output=test-results/connect")
if len(p.config.testFiles) > 0 {
args = append(args, p.config.testFiles...)
}
if len(p.config.testFiles) > 0 {
ci.log.Info("running e2e tests", "files", p.config.testFiles)
} else {
ci.log.Info("running e2e tests", "projects", []string{"connect"})
}
if err := p.pnpm(ctx, args, env); err != nil {
return fmt.Errorf("playwright tests failed for connect: %w", err)
}
return nil
})
}
testErr := g.Wait()
slog.Info("merging blob reports")
mergeArgs := []string{"exec", "playwright", "merge-reports", p.configFlag(), blobBaseDir}
mergeEnv := os.Environ()
mergeEnv = append(mergeEnv, "FORCE_COLOR=1")
if err := p.pnpmQuiet(ctx, mergeArgs, mergeEnv); err != nil {
slog.Warn("failed to merge reports", "error", err)
if testErr == nil {
return err
}
} else {
// Merge consumed the per-browser blobs; drop them so the test-results
// artifact carries only the extracted resources/, not the (redundant,
// larger) raw blob archives with every attachment embedded twice.
if blobs, err := filepath.Glob(filepath.Join(blobBaseDir, "*.zip")); err == nil {
for _, b := range blobs {
_ = os.Remove(b)
}
}
}
return testErr
}
func (p *playwrightRunner) ui(ctx context.Context) error {
slog.Info("starting playwright in UI mode")
if len(p.config.instances) == 0 {
return fmt.Errorf("no test instances configured")
}
inst := p.config.instances[0]
if err := inst.start(ctx); err != nil {
return err
}
defer inst.stop()
env, err := p.startEnv(inst)
if err != nil {
return err
}
args := []string{"exec", "playwright", "test", p.configFlag(), "--ui"}
if len(p.config.testFiles) > 0 {
args = append(args, p.config.testFiles...)
}
return p.pnpm(ctx, args, env)
}
func (p *playwrightRunner) codegen(ctx context.Context) error {
return p.openWebAuthenticated(ctx, "codegen")
}
// openWebAuthenticated runs the global setup to generate auth state, then opens
// a Chromium browser with a virtual WebAuthn authenticator pre-loaded so that
// MFA challenges resolve automatically.
func (p *playwrightRunner) openWebAuthenticated(ctx context.Context, playwrightCmd string) error {
if len(p.config.instances) == 0 {
return fmt.Errorf("no test instances configured")
}
inst := p.config.instances[0]
if err := inst.start(ctx); err != nil {
return err
}
defer inst.stop()
env, err := p.startEnv(inst)
if err != nil {
return err
}
slog.Debug("running global setup to generate auth state")
if err := p.pnpm(ctx, []string{"exec", "tsx", filepath.Join(p.config.sharedDir, "global-setup.ts")}, env); err != nil {
return err
}
slog.Info("opening playwright " + playwrightCmd + " (with auth and WebAuthn)")
return p.pnpm(ctx, []string{
"exec", "tsx", filepath.Join(p.config.sharedDir, "scripts", "open-with-webauthn.ts"),
playwrightCmd,
p.startURL(inst),
}, env)
}
func (p *playwrightRunner) openConnectAuthenticated(ctx context.Context) error {
inst := p.config.connectInstance
if inst == nil {
return fmt.Errorf("connect instance not configured (run Connect specific tests or use --with-connect)")
}
if err := inst.start(ctx); err != nil {
return err
}
defer inst.stop()
env, err := p.startEnv(inst)
if err != nil {
return err
}
slog.Info("opening Teleport Connect (with auth)")
return p.pnpm(ctx, []string{"exec", "tsx", filepath.Join(p.config.sharedDir, "scripts", "open-connect.ts")}, env)
}
// startEnv builds the environment variables that Playwright tests need,
// including START_URL, credentials, and tctl paths for invite URL generation.
func (p *playwrightRunner) startEnv(inst *testInstance) ([]string, error) {
env := os.Environ()
// Force color output since Playwright's TTY detection won't work
// when stdout/stderr are wrapped by the rewrite writer.
env = append(env, "FORCE_COLOR=1")
if os.Getenv("START_URL") == "" {
env = append(env, "START_URL="+p.startURL(inst))
}
env = append(env, "E2E_DIR="+p.config.e2eDir)
if p.config.creds != nil {
env = append(env, "E2E_USERS_FILE="+filepath.Join(p.config.e2eDir, ".auth", "user-credentials.json"))
}
env = append(env, "E2E_TCTL_BIN="+p.config.tctlBin)
env = append(env, "E2E_TELEPORT_CONFIG="+inst.teleportConfigPath)
env = append(env, "E2E_BROWSERS="+strings.Join(p.config.browsers, ","))
env = append(env, "E2E_BROWSER="+inst.browser)
env = append(env, "E2E_CONNECT_TSH_BIN="+p.config.connectTshBinPath)
env = append(env, "E2E_CONNECT_APP_DIR="+p.config.connectAppDir)
return env, nil
}
func (p *playwrightRunner) pnpmQuiet(ctx context.Context, args []string, env []string) error {
cmd := exec.CommandContext(ctx, "pnpm", args...)
cmd.Dir = p.config.sharedDir
cmd.Env = env
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("command exited with code %d: %w", exitErr.ExitCode(), err)
}
return fmt.Errorf("failed to run command: %w", err)
}
return nil
}
func (p *playwrightRunner) pnpm(ctx context.Context, args []string, env []string) error {
cmd := exec.CommandContext(ctx, "pnpm", args...)
cmd.Dir = p.config.sharedDir
cmd.Env = env
stdout, stderr := p.outputWriters()
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("command exited with code %d: %w", exitErr.ExitCode(), err)
}
return fmt.Errorf("failed to run command: %w", err)
}
return nil
}
// outputWriters returns stdout/stderr writers that rewrite Playwright
// output so paths are clickable and commands are runnable from the
// caller's working directory.
func (p *playwrightRunner) outputWriters() (io.Writer, io.Writer) {
callerDir := os.Getenv("E2E_CALLER_DIR")
// Always rewrite the show-report command; conditionally prefix paths.
var pathPrefix string
if callerDir != "" && callerDir != p.config.e2eDir {
if rel, err := filepath.Rel(callerDir, p.config.e2eDir); err == nil && rel != "." {
pathPrefix = rel + "/"
}
}
var showReportCmd string
if p.config.isCI {
if pr := ciPRNumber(); pr > 0 {
showReportCmd = ciReportCmd(pr)
}
}
if showReportCmd == "" {
showReportCmd = "pnpm show-report"
if pathPrefix != "" {
showReportCmd = fmt.Sprintf("(cd %s && pnpm show-report)", pathPrefix[:len(pathPrefix)-1])
}
}
rewrite := func(p []byte) []byte {
if pathPrefix != "" {
p = bytes.ReplaceAll(p, []byte("test-results/"), []byte(pathPrefix+"test-results/"))
p = bytes.ReplaceAll(p, []byte("tests/"), []byte(pathPrefix+"tests/"))
}
p = bytes.ReplaceAll(p, []byte("pnpm exec playwright show-report"), []byte(showReportCmd))
return p
}
return rewriteWriter{os.Stdout, rewrite}, rewriteWriter{os.Stderr, rewrite}
}
type rewriteWriter struct {
w io.Writer
rewrite func([]byte) []byte
}
func (rw rewriteWriter) Write(p []byte) (int, error) {
if _, err := rw.w.Write(rw.rewrite(p)); err != nil {
return 0, err
}
return len(p), nil
}
func (p *playwrightRunner) configFlag() string {
return "--config=" + filepath.Join(p.config.e2eDir, "playwright.config.ts")
}