Auto detect fixture usage in E2E tests, supporting changes for running enterprise e2e tests (#65141)

* Auto detect fixture usage in tests

* Changes to runner for enabling e e2e tests

* Only detect fixtures in test/UI/debug modes

* Make scanning more resilient - require test.use, allow for multiple lines

* Fix e2e/runner CI generate check

* Make scanning context aware to match with running individual tests

* Log enabled fixtures on one line

* Rename gen-fixtures, move go gen to main.go

* Return an error if no tests are matched

* gen-ts-fixtures: Use text/template instead of manually writing to buf

* Ignore generated fixtures.ts instead of formatting it

* Fix pnpm lockfile patching for when e/e2e is missing

* Simplify test running mode detection for scanning fixtures

* go generate

* Remove codegen from test run logic

* Handle run.sh receiving e/e2e directly

* Allow for fixtures to be enabled manually through flags

* Fix logging of enabled fixtures to include the ones set by flags

* Handle inline comments

* Update cmd in generated file

* Only pnpm install if teleport/connect needs building

* Error if UI mode is invoked with connect tests

* Handle errors better when checking if a binary exists

* Add info to the README about manually enabling fixtures

* Debug -> Warn

* Add test files to --ui

* Handle comments in strings properly

* Remove connect TSH from pnpm install logic

* Ignore flags in run.sh

* Handle double quotes just in case

* Add line number back

* Handle commented out imports

* Always include host.docker.internal in the certs

* Only scan fixtures in test mode/when test files are specified

* Make e2e/run.sh work with absolute paths to e/ tests

* Scan fixtures when running tests but not in UI mode

---------

Co-authored-by: Maja Cieślak <[email protected]>
This commit is contained in:
Ryan Clark
2026-04-03 12:57:18 +00:00
committed by GitHub
co-authored by Maja Cieślak
parent 1f696443d5
commit 6266cd9eba
25 changed files with 1257 additions and 168 deletions
+11 -1
View File
@@ -110,6 +110,16 @@ jobs:
key: go-mod-${{ runner.os }}-${{ hashFiles('go.sum') }}
restore-keys: go-mod-${{ runner.os }}-
- name: Check generated files are up to date
working-directory: e2e/runner
run: |
go generate ./...
if ! git diff --quiet -- ../helpers/fixtures.ts; then
echo "::error::e2e/helpers/fixtures.ts is out of date. Run 'go generate ./...' from e2e/runner and commit the result."
git diff -- ../helpers/fixtures.ts
exit 1
fi
- name: Restore Rust output cache
id: rust-cache
uses: actions/cache/restore@v5
@@ -256,7 +266,7 @@ jobs:
run: mkdir -p ~/.docker && echo '{}' > ~/.docker/config.json
- name: Run tests
run: xvfb-run e2e/runner/e2e --no-build --full
run: xvfb-run e2e/runner/e2e --no-build
env:
E2E_CALLER_DIR: ${{ github.workspace }}
+1
View File
@@ -12,6 +12,7 @@
"**/*_pb.*",
"**/ironrdp/pkg/**",
"web/.storybook/public/mockServiceWorker.js",
"e2e/helpers/fixtures.ts"
],
"sortPackageJson": false,
"sortImports": {
+13 -4
View File
@@ -2,13 +2,22 @@ function afterAllResolved(lockfile, context) {
// The pnpm lockfile contains entries for all workspaces.
// This means that if the `e` repo is not cloned, we will get a different
// lockfile.
// The ugly workaround is to add an entry for e/web/teleport workspace manually.
// We pass an empty object because `e/web/teleport` package.json doesn't have
// any dependencies.
// The ugly workaround is to add entries for enterprise workspaces manually.
if (!lockfile.importers['e/web/teleport']) {
context.log(`Workspace 'e/web/teleport' is not present, patching lockfile.`);
lockfile.importers['e/web/teleport'] = {};
}
if (!lockfile.importers['e/e2e']) {
context.log(`Workspace 'e/e2e' is not present, patching lockfile.`);
lockfile.importers['e/e2e'] = {
dependencies: {
'@gravitational/e2e': 'link:../../e2e',
},
specifiers: {
'@gravitational/e2e': 'workspace:*',
},
};
}
return lockfile;
}
@@ -16,4 +25,4 @@ module.exports = {
hooks: {
afterAllResolved,
},
};
};
+2 -1
View File
@@ -8,6 +8,7 @@ set -eo pipefail
MAKE="${MAKE:-make}"
BUILDDIR="${BUILDDIR:-build}"
TSH_SOURCE="${TSH_SOURCE:-./tool/tsh}"
cargo --version && rustc --version && echo "ARCH=$(uname -m)"
@@ -15,7 +16,7 @@ mkdir -p build-logs
${MAKE} "${BUILDDIR}/teleport" 2>&1 | tee build-logs/teleport.log & pid_teleport=$!
${MAKE} "${BUILDDIR}/tctl" 2>&1 | tee build-logs/tctl.log & pid_tctl=$!
go build -tags webauthnmock -o "${BUILDDIR}/tsh-e2e-webauthnmock" ./tool/tsh 2>&1 | tee build-logs/tsh.log & pid_tsh=$!
go build -tags webauthnmock -o "${BUILDDIR}/tsh-e2e-webauthnmock" "${TSH_SOURCE}" 2>&1 | tee build-logs/tsh.log & pid_tsh=$!
failed=0
wait $pid_teleport || { echo "::error::make ${BUILDDIR}/teleport failed with exit code $?"; failed=1; }
+19 -11
View File
@@ -43,7 +43,6 @@ By default, the runner runs in test mode. Use one of the following flags to chan
| `-v` | `false` | Enable debug logging |
| `--no-build` | `false` | Skip `make` binaries (useful during development) |
| `--quiet` | `false` | Redirect Teleport logs to file instead of stdout |
| `--full` | `false` | Enable all optional fixtures |
| `--replace-certs` | `false` | Generate new self-signed certificates |
| `--update-snapshots` | `false` | Update Playwright snapshot baselines |
| `--teleport-log-level` | `INFO` | Teleport log severity (`DEBUG`, `INFO`, `WARN`, `ERROR`) |
@@ -54,10 +53,19 @@ By default, the runner runs in test mode. Use one of the following flags to chan
### Fixtures
| Flag | Description |
|-------------------|--------------------------------------------------------------------------------------|
| `--with-ssh-node` | Start and connect a Teleport SSH node (runs in Docker) |
| `--with-connect` | Build Teleport Connect. Enabled by default when running tests in `e2e/tests/connect` |
Fixtures are optional pieces of test infrastructure (like an SSH node or Teleport Connect) that are
auto-detected from test files. When a test declares `test.use({ fixtures: ['ssh-node'] })`, the runner
automatically starts the required infrastructure.
Available fixtures:
| Fixture | Description |
|--------------|--------------------------------------------------------------------------------------|
| `ssh-node` | Start and connect a Teleport SSH node (runs in Docker) |
| `connect` | Build Teleport Connect. Auto-detected from Connect test helpers. |
Fixtures can also be enabled manually with `--with-<name>` flags (e.g. `--with-ssh-node`, `--with-connect`),
which is useful for modes like `--codegen` or `--browse` where auto-detection does not run.
### Common Commands
@@ -65,7 +73,7 @@ Typically, you'll want to run with `--no-build` during test development to skip
run. `--quiet` is also useful to reduce the noise from Teleport logs. The logs are captured in `teleport.log` for
debugging purposes.
Connect is built automatically when running `tests/connect` paths or when using `--browse-connect`, or `--full`.
Connect is built automatically when running `tests/connect` paths or when using `--browse-connect`.
```bash
# Run a specific test, skip rebuilding (fastest iteration loop)
@@ -90,7 +98,7 @@ Connect is built automatically when running `tests/connect` paths or when using
./e2e/run.sh --codegen
# Update snapshot baselines after a visual change
./e2e/run.sh --update-snapshots --with-ssh-node e2e/tests/web/with-ssh-node/ssh.spec.ts
./e2e/run.sh --update-snapshots e2e/tests/web/authenticated/ssh.spec.ts
```
### More Examples
@@ -99,11 +107,11 @@ Connect is built automatically when running `tests/connect` paths or when using
# Run all tests
./e2e/run.sh
# Run SSH node tests with the fixture enabled
./e2e/run.sh --with-ssh-node e2e/tests/web/with-ssh-node/ssh.spec.ts
# Run SSH node tests (fixture is auto-detected)
./e2e/run.sh e2e/tests/web/authenticated/ssh.spec.ts
# Run tests with all fixtures enabled, skipping the Teleport build
./e2e/run.sh --full --no-build
# Run all tests, skipping the Teleport build
./e2e/run.sh --no-build
# Run against an existing Teleport instance (doesn't work yet as authentication is hardcoded to the e2e setup and we need to figure out auth for remote instances)
./e2e/run.sh --teleport-url https://localhost:3080
+4 -2
View File
@@ -25,13 +25,13 @@ import { fileURLToPath } from 'node:url';
import {
_electron as electron,
expect,
test as base,
type Page,
TestInfo,
ElectronApplication,
} from '@playwright/test';
import { connectTshBin, connectAppDir, password, startUrl } from './env';
import { test as fixtureBase } from './fixtures';
export async function launchApp(homeDir: string) {
const requireFromApp = module.createRequire(
@@ -86,7 +86,7 @@ export interface App {
appConfigPath: string;
}
export const test = base.extend<{
export const test = fixtureBase.extend<{
autoLogin: boolean;
/**
* Sets app config before launching the app.
@@ -119,6 +119,8 @@ export const test = base.extend<{
},
});
test.use({ fixtures: ['connect'] });
export type AppConfigSetup =
| {
kind: 'appConfigPatch';
@@ -16,38 +16,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main
// Code generated by e2e/runner/cmd/gen-ts-fixtures; DO NOT EDIT.
import (
"flag"
)
import { test as base } from '@playwright/test';
var allFixtures []*fixture
type Fixture =
| 'ssh-node'
| 'connect';
type fixture struct {
name string
usage string
enabled bool
}
// registerFixture declares a new optional piece of test infrastructure
// (like an SSH node) and adds it to the global registry so it gets a --with-<name> flag.
func registerFixture(name, usage string) *fixture {
f := &fixture{name: name, usage: usage}
allFixtures = append(allFixtures, f)
return f
}
func bindFixtureFlags(fs *flag.FlagSet) {
for _, f := range allFixtures {
fs.BoolVar(&f.enabled, "with-"+f.name, false, f.usage)
}
}
func enableAllFixtures() {
for _, f := range allFixtures {
f.enabled = true
}
}
export const test = base.extend<{ fixtures: Fixture[] }>({
fixtures: [[], { option: true }],
});
+7 -3
View File
@@ -27,9 +27,13 @@ export async function login(
username = 'bob',
password = e2ePassword
) {
await page.addInitScript(() =>
localStorage.setItem('grv_teleport_license_acknowledged', 'true')
);
await page.addInitScript(() => {
localStorage.setItem('grv_teleport_license_acknowledged', 'true');
localStorage.setItem(
'grv_teleport_identity_security_recommendations_unified_resources_cta_seen',
'true'
);
});
await mockWebAuthn(page);
+1 -2
View File
@@ -16,8 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test as base } from '@playwright/test';
import { test as base } from './fixtures';
import { UnifiedResourcesPage } from './pages/UnifiedResources';
export const CLUSTER_NAME = 'teleport-e2e';
-6
View File
@@ -70,12 +70,6 @@ export default defineConfig({
testDir: './tests/web/unauthenticated',
use: { ...browserDevices[browser] },
},
{
name: `${browser}:with-ssh-node`,
testDir: './tests/web/with-ssh-node',
use: { ...browserDevices[browser], storageState: authState },
dependencies: [setupName],
},
];
}),
+14 -1
View File
@@ -1,3 +1,16 @@
#!/bin/sh
export E2E_CALLER_DIR="$PWD"
cd "$(dirname "$0")/runner" && GOWORK=off go build -o e2e . && exec ./e2e "$@"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
# If any argument points to an enterprise e2e test, delegate to e/e2e/run.sh.
for arg in "$@"; do
case "$arg" in
-*) continue ;;
e/e2e/* | */e/e2e/* | e/e2e | */e/e2e)
exec "$REPO_ROOT/e/e2e/run.sh" "$@"
;;
esac
done
cd "$SCRIPT_DIR/runner" && GOWORK=off go build -o e2e . && exec ./e2e "$@"
+91 -46
View File
@@ -29,14 +29,37 @@ import (
"runtime"
"golang.org/x/sync/errgroup"
"github.com/gravitational/teleport/e2e/runner/fixtures"
)
// build compiles teleport binaries and installs playwright dependencies in parallel.
func build(ctx context.Context, config *e2eConfig) error {
buildTeleport := config.teleportBuildDir != "" &&
shouldBuild(filepath.Join(config.teleportBuildDir, "build", "teleport"), config.noBuild)
buildTctl := config.tctlBin == filepath.Join(config.repoRoot, "build", "tctl") &&
shouldBuild(config.tctlBin, config.noBuild)
nodeBuildDir := config.teleportBuildDir
if nodeBuildDir == "" {
nodeBuildDir = config.repoRoot
}
var buildNode bool
if fixtures.SSHNode.Enabled && runtime.GOOS != "linux" {
buildNode = shouldBuild(filepath.Join(nodeBuildDir, "build", "teleport-node"), config.noBuild)
}
buildConnect := fixtures.Connect.Enabled &&
shouldBuild(filepath.Join(config.repoRoot, "web", "packages", "teleterm", "build", "app"), config.noBuild)
buildConnectTsh := fixtures.Connect.Enabled &&
shouldBuild(config.connectTshBinPath, config.noBuild)
// Both the teleport build (through make build/teleport -> build-ui) and the Connect build need JS
// deps installed. Running pnpm install concurrently from multiple goroutines would cause a race,
// so we ensure JS deps are installed up front before starting concurrent work.
if !config.noBuild && (config.teleportBuildDir != "" || connect.enabled) {
needsJSDeps := buildTeleport || buildConnect
if needsJSDeps {
slog.Info("ensuring JS dependencies are installed")
if err := runMake(ctx, config.repoRoot, "ensure-js-deps"); err != nil {
return err
@@ -45,46 +68,42 @@ func build(ctx context.Context, config *e2eConfig) error {
g, ctx := errgroup.WithContext(ctx)
if !config.noBuild {
if config.teleportBuildDir != "" {
buildDir := config.teleportBuildDir
g.Go(func() error {
slog.Info("building teleport", "dir", buildDir)
if buildTeleport {
buildDir := config.teleportBuildDir
g.Go(func() error {
slog.Info("building teleport", "dir", buildDir)
return runMake(ctx, buildDir, "build/teleport")
})
} else {
slog.Info("teleport binary overridden, skipping build", "path", config.teleportBin)
}
if config.tctlBin == filepath.Join(config.repoRoot, "build", "tctl") {
g.Go(func() error {
slog.Info("building tctl")
return runMake(ctx, config.repoRoot, "build/tctl")
})
} else {
slog.Info("tctl binary overridden, skipping build", "path", config.tctlBin)
}
return runMake(ctx, buildDir, "build/teleport")
})
} else if config.teleportBuildDir == "" {
slog.Debug("teleport binary overridden, skipping build", "path", config.teleportBin)
} else if config.noBuild {
slog.Debug("skipping teleport build (--no-build)", "path", config.teleportBin)
}
if sshNode.enabled && !config.noBuild && runtime.GOOS != "linux" {
if buildTctl {
g.Go(func() error {
// Fall back to repoRoot when the teleport binary is overridden; the docker node
// always needs a Linux binary built from source.
buildDir := config.teleportBuildDir
if buildDir == "" {
buildDir = config.repoRoot
}
slog.Info("cross-compiling teleport for linux (docker node)", "dir", buildDir)
slog.Info("building tctl")
output := filepath.Join(buildDir, "build", "teleport-node")
return runMake(ctx, config.repoRoot, "build/tctl")
})
} else if config.tctlBin != filepath.Join(config.repoRoot, "build", "tctl") {
slog.Debug("tctl binary overridden, skipping build", "path", config.tctlBin)
} else if config.noBuild {
slog.Debug("skipping tctl build (--no-build)", "path", config.tctlBin)
}
if buildNode {
g.Go(func() error {
slog.Info("cross-compiling teleport for linux (docker node)", "dir", nodeBuildDir)
output := filepath.Join(nodeBuildDir, "build", "teleport-node")
cmd := exec.CommandContext(ctx, "go", "build",
"-o", output,
"-buildvcs=false",
"./tool/teleport",
)
cmd.Dir = buildDir
cmd.Dir = nodeBuildDir
env := append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=1")
if os.Getenv("CC") == "" {
env = append(env, "CC=x86_64-unknown-linux-gnu-gcc")
@@ -119,24 +138,32 @@ func build(ctx context.Context, config *e2eConfig) error {
})
}
if connect.enabled && !config.noBuild {
g.Go(func() error {
slog.Info("building Teleport Connect")
if err := runInDir(ctx, config.repoRoot, "pnpm", "--filter=@gravitational/teleterm", "build"); err != nil {
return fmt.Errorf("pnpm --filter=@gravitational/teleterm build: %w", err)
}
if fixtures.Connect.Enabled {
if buildConnect {
g.Go(func() error {
slog.Info("building Teleport Connect")
if err := runInDir(ctx, config.repoRoot, "pnpm", "--filter=@gravitational/teleterm", "build"); err != nil {
return fmt.Errorf("pnpm --filter=@gravitational/teleterm build: %w", err)
}
return nil
})
return nil
})
} else if config.noBuild {
slog.Debug("skipping Teleport Connect build (--no-build)")
}
g.Go(func() error {
slog.Info("building tsh with webauthnmock tag for Teleport Connect e2e")
if err := runInDir(ctx, config.repoRoot, "go", "build", "-tags", "webauthnmock", "-o", config.connectTshBinPath, "./tool/tsh"); err != nil {
return fmt.Errorf("go build -tags webauthnmock ./tool/tsh: %w", err)
}
if buildConnectTsh {
g.Go(func() error {
slog.Info("building tsh with webauthnmock tag for Teleport Connect e2e")
if err := runInDir(ctx, config.repoRoot, "go", "build", "-tags", "webauthnmock", "-o", config.connectTshBinPath, "./tool/tsh"); err != nil {
return fmt.Errorf("go build -tags webauthnmock ./tool/tsh: %w", err)
}
return nil
})
return nil
})
} else if config.noBuild {
slog.Debug("skipping tsh-webauthnmock build (--no-build)", "path", config.connectTshBinPath)
}
}
return g.Wait()
@@ -167,3 +194,21 @@ func runInDir(ctx context.Context, dir, name string, args ...string) error {
return nil
}
func shouldBuild(path string, noBuild bool) bool {
if !noBuild {
return true
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
slog.Info("binary missing, rebuilding", "path", path)
} else {
slog.Warn("error checking binary, rebuilding just in case", "path", path, "error", err)
}
return true
}
return false
}
+2 -6
View File
@@ -38,8 +38,7 @@ const (
)
// generateSelfSignedCert creates a self-signed TLS certificate and key in certsDir.
// If withDockerHost is true, host.docker.internal is added to the SANs.
func generateSelfSignedCert(certsDir string, withDockerHost bool) error {
func generateSelfSignedCert(certsDir string) error {
if err := os.MkdirAll(certsDir, 0o755); err != nil {
return err
}
@@ -54,10 +53,7 @@ func generateSelfSignedCert(certsDir string, withDockerHost bool) error {
return err
}
dnsNames := []string{"localhost", "teleport-e2e"}
if withDockerHost {
dnsNames = append(dnsNames, "host.docker.internal")
}
dnsNames := []string{"localhost", "teleport-e2e", "host.docker.internal"}
tmpl := &x509.Certificate{
SerialNumber: serial,
+83
View File
@@ -0,0 +1,83 @@
/**
* 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"
"fmt"
"os"
"path/filepath"
"text/template"
"github.com/gravitational/teleport/e2e/runner/fixtures"
)
var tmpl = template.Must(template.New("fixtures.ts").Parse(`/**
* 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/>.
*/
// Code generated by e2e/runner/cmd/gen-ts-fixtures; DO NOT EDIT.
import { test as base } from '@playwright/test';
type Fixture =
{{- range .Fixtures}}
| '{{.Name}}'
{{- end}};
export const test = base.extend<{ fixtures: Fixture[] }>({
fixtures: [[], { option: true }],
});
`))
func main() {
all := fixtures.All()
if len(all) == 0 {
fmt.Fprintln(os.Stderr, "no fixtures registered")
os.Exit(1)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ Fixtures []*fixtures.Fixture }{all}); err != nil {
fmt.Fprintf(os.Stderr, "executing template: %v\n", err)
os.Exit(1)
}
outPath := filepath.Join("..", "helpers", "fixtures.ts")
if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil {
fmt.Fprintf(os.Stderr, "writing %s: %v\n", outPath, err)
os.Exit(1)
}
fmt.Printf("generated %s with %d fixture(s)\n", outPath, len(all))
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 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 fixtures
import "flag"
var (
SSHNode = register("ssh-node")
Connect = register("connect")
)
type Fixture struct {
Name string
Enabled bool
}
func (f *Fixture) String() string {
return f.Name
}
var all []*Fixture
func register(name string) *Fixture {
f := &Fixture{Name: name}
all = append(all, f)
return f
}
func All() []*Fixture {
return all
}
func Enabled() []*Fixture {
var enabled []*Fixture
for _, f := range all {
if f.Enabled {
enabled = append(enabled, f)
}
}
return enabled
}
func BindFlags(fs *flag.FlagSet) {
for _, f := range all {
fs.BoolVar(&f.Enabled, "with-"+f.Name, false, "enable the "+f.Name+" fixture")
}
}
func FindByName(name string) *Fixture {
for _, f := range all {
if f.Name == name {
return f
}
}
return nil
}
+22 -20
View File
@@ -26,16 +26,14 @@ import (
"path/filepath"
"slices"
"strings"
)
var sshNode = registerFixture("ssh-node", "start and connect a Teleport SSH node, runs in Docker")
var connect = registerFixture("connect", "build Teleport Connect")
"github.com/gravitational/teleport/e2e/runner/fixtures"
)
var validBrowsers = []string{"chromium", "firefox", "webkit"}
type e2eFlags struct {
noBuild bool
full bool
quiet bool
verbose bool
replaceCerts bool
@@ -76,7 +74,6 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
flag.BoolVar(&f.verbose, "v", false, "enable debug logging")
flag.BoolVar(&f.noBuild, "no-build", false, "skip make binaries") // useful for running during development to avoid rebuilding Teleport every time
flag.BoolVar(&f.quiet, "quiet", false, "redirect Teleport logs to file instead of stdout") // used in CI to avoid flooding logs with Teleport logs
flag.BoolVar(&f.full, "full", false, "enable all optional fixtures")
flag.BoolVar(&f.replaceCerts, "replace-certs", false, "generate new self-signed certificates")
flag.BoolVar(&f.updateSnapshots, "update-snapshots", false, "update Playwright snapshot baselines")
flag.StringVar(&f.teleportLogLevel, "teleport-log-level", "INFO", "Teleport log severity (DEBUG, INFO, WARN, ERROR)")
@@ -94,7 +91,7 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
flag.StringVar(&f.reportRepo, "repo", "", "GitHub repo name (e.g. teleport.e), auto-detected if omitted")
flag.StringVar(&f.reportSHA, "sha", "", "commit SHA to download artifacts for (overrides PR head SHA)")
bindFixtureFlags(flag.CommandLine)
fixtures.BindFlags(flag.CommandLine)
modes.bindFlags(flag.CommandLine)
flag.Parse()
@@ -107,10 +104,6 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
logLevel.Set(slog.LevelDebug)
}
if f.full {
enableAllFixtures()
}
f.teleportLogLevel = strings.ToUpper(f.teleportLogLevel)
if !slices.Contains(validTeleportLogLevels, f.teleportLogLevel) {
return nil, 0, fmt.Errorf("invalid --teleport-log-level %q, must be one of: %s", f.teleportLogLevel, strings.Join(validTeleportLogLevels, ", "))
@@ -149,41 +142,50 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
f.tracePath = args[0]
}
if mode != modeReport && mode != modeTestResults {
isTestRun := mode == modeTest || mode == modeUI || mode == modeDebug
if isTestRun {
e2eDir := filepath.Join(repoRoot, "e2e")
f.testFiles, err = normalizeTestFiles(e2eDir, flag.Args())
if err != nil {
return nil, 0, err
}
if len(f.testFiles) > 0 || mode != modeUI {
for _, fix := range scanFixtures(e2eDir, f.testFiles) {
fix.Enabled = true
}
}
}
// Auto-enable Connect if intent is explicit via mode or selected test paths.
if mode == modeBrowseConnect {
connect.enabled = true
fixtures.Connect.Enabled = true
f.browsers = []string{}
}
for _, file := range f.testFiles {
slashPath := filepath.ToSlash(file)
if slashPath == "." || slashPath == "tests" || slashPath == "tests/connect" || strings.HasPrefix(slashPath, "tests/connect/") {
connect.enabled = true
break
}
if enabled := fixtures.Enabled(); len(enabled) > 0 {
slog.Info("enabled fixtures", "fixtures", enabled)
}
// If every specified test file targets connect, skip browser instances.
if len(f.testFiles) > 0 {
allConnect := true
anyConnect := false
for _, file := range f.testFiles {
slashPath := filepath.ToSlash(file)
if slashPath != "tests/connect" && !strings.HasPrefix(slashPath, "tests/connect/") {
if slashPath == "tests/connect" || strings.HasPrefix(slashPath, "tests/connect/") {
anyConnect = true
} else {
allConnect = false
break
}
}
if anyConnect && mode == modeUI {
return nil, 0, fmt.Errorf("--ui is not supported for Connect tests (Connect runs in Electron, not a browser)")
}
if allConnect {
f.browsers = []string{}
}
+28 -22
View File
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
//go:generate go run ./cmd/gen-ts-fixtures
package main
import (
@@ -32,6 +34,8 @@ import (
"github.com/lmittmann/tint"
"golang.org/x/sync/errgroup"
"github.com/gravitational/teleport/e2e/runner/fixtures"
)
var logLevel = new(slog.LevelVar)
@@ -120,10 +124,11 @@ func main() {
type e2eConfig struct {
e2eFlags
isCI bool
repoRoot string
e2eDir string
certsDir string
isCI bool
repoRoot string
e2eDir string
sharedDir string // shared resource dir (templates, scripts); defaults to e2eDir
certsDir string
nodeConfigTemplate string
teleportConfigTemplate string
@@ -148,17 +153,25 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
sharedDir := e2eDir
if v := os.Getenv("E2E_SHARED_DIR"); v != "" {
sharedDir = v
}
repoRoot := filepath.Dir(e2eDir)
config := &e2eConfig{
e2eFlags: *flags,
isCI: isCI,
repoRoot: filepath.Dir(e2eDir),
repoRoot: repoRoot,
e2eDir: e2eDir,
sharedDir: sharedDir,
certsDir: filepath.Join(e2eDir, "certs"),
stateTemplate: filepath.Join(e2eDir, "config", "state.yaml.tmpl"),
teleportConfigTemplate: filepath.Join(e2eDir, "config", "teleport.yaml.tmpl"),
nodeConfigTemplate: filepath.Join(e2eDir, "node", "node.yaml.tmpl"),
connectAppDir: filepath.Join(filepath.Dir(e2eDir), "web", "packages", "teleterm"),
connectTshBinPath: filepath.Join(filepath.Dir(e2eDir), "build", "tsh-e2e-webauthnmock"),
stateTemplate: filepath.Join(sharedDir, "config", "state.yaml.tmpl"),
teleportConfigTemplate: filepath.Join(sharedDir, "config", "teleport.yaml.tmpl"),
nodeConfigTemplate: filepath.Join(sharedDir, "node", "node.yaml.tmpl"),
connectAppDir: filepath.Join(repoRoot, "web", "packages", "teleterm"),
connectTshBinPath: filepath.Join(repoRoot, "build", "tsh-e2e-webauthnmock"),
}
switch config.teleportBin {
@@ -198,7 +211,7 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
config.instances = append(config.instances, inst)
}
if connect.enabled {
if fixtures.Connect.Enabled {
config.connectInstance = &browserInstance{
browser: "connect",
log: newBrowserLogger("connect"),
@@ -210,7 +223,7 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
var portTargets []*int
for _, inst := range config.instances {
portTargets = append(portTargets, &inst.proxyPort, &inst.authPort)
if sshNode.enabled {
if fixtures.SSHNode.Enabled {
portTargets = append(portTargets, &inst.sshPort)
}
}
@@ -240,7 +253,7 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
case os.IsNotExist(statErr) || config.replaceCerts:
slog.Info("generating self-signed TLS certificates", "dir", config.certsDir)
if err := generateSelfSignedCert(config.certsDir, sshNode.enabled); err != nil {
if err := generateSelfSignedCert(config.certsDir); err != nil {
return fmt.Errorf("failed to generate TLS certificates: %w", err)
}
}
@@ -336,7 +349,7 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
}
}()
if sshNode.enabled {
if fixtures.SSHNode.Enabled {
slog.Info("running with SSH node fixture enabled")
nodeBin := config.teleportBin
@@ -400,15 +413,8 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
}
}
var extraProjects []string
// Project names from playwright.config.ts.
if sshNode.enabled {
extraProjects = append(extraProjects, "with-ssh-node")
}
pw := &playwrightRunner{
config: config,
extraProjects: extraProjects,
config: config,
}
return pw.run(ctx, mode)
+12 -10
View File
@@ -34,8 +34,7 @@ import (
)
type playwrightRunner struct {
config *e2eConfig
extraProjects []string
config *e2eConfig
}
func (p *playwrightRunner) startURL(inst *browserInstance) string {
@@ -91,9 +90,7 @@ func (p *playwrightRunner) test(ctx context.Context, debug bool) error {
return fmt.Errorf("cleaning blob-reports directory: %w", err)
}
baseProjects := make([]string, 0, 2+len(p.extraProjects))
baseProjects = append(baseProjects, "authenticated", "unauthenticated")
baseProjects = append(baseProjects, p.extraProjects...)
baseProjects := []string{"authenticated", "unauthenticated"}
var extraArgs []string
if p.config.updateSnapshots {
@@ -115,7 +112,7 @@ func (p *playwrightRunner) test(ctx context.Context, debug bool) error {
args := []string{"exec", "playwright", "test"}
args = append(args, extraArgs...)
args = append(args, "--reporter=blob,./scripts/dot-progress-reporter.ts")
args = append(args, "--reporter=blob,"+filepath.Join(p.config.sharedDir, "scripts", "dot-progress-reporter.ts"))
for _, proj := range baseProjects {
args = append(args, "--project="+inst.browser+":"+proj)
@@ -152,7 +149,7 @@ func (p *playwrightRunner) test(ctx context.Context, debug bool) error {
args := []string{"exec", "playwright", "test"}
args = append(args, extraArgs...)
args = append(args, "--reporter=blob,./scripts/dot-progress-reporter.ts", "--project=connect")
args = append(args, "--reporter=blob,"+filepath.Join(p.config.sharedDir, "scripts", "dot-progress-reporter.ts"), "--project=connect")
if len(p.config.testFiles) > 0 {
args = append(args, p.config.testFiles...)
@@ -196,7 +193,12 @@ func (p *playwrightRunner) ui(ctx context.Context) error {
return err
}
return p.pnpm(ctx, []string{"exec", "playwright", "test", "--ui"}, env)
args := []string{"exec", "playwright", "test", "--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 {
@@ -221,7 +223,7 @@ func (p *playwrightRunner) openWebAuthenticated(ctx context.Context, playwrightC
slog.Info("opening playwright " + playwrightCmd + " (with auth and WebAuthn)")
return p.pnpm(ctx, []string{
"exec", "tsx", "scripts/open-with-webauthn.ts",
"exec", "tsx", filepath.Join(p.config.sharedDir, "scripts", "open-with-webauthn.ts"),
playwrightCmd,
p.startURL(inst),
}, env)
@@ -239,7 +241,7 @@ func (p *playwrightRunner) openConnectAuthenticated(ctx context.Context) error {
slog.Info("opening Teleport Connect (with auth)")
return p.pnpm(ctx, []string{"exec", "tsx", "scripts/open-connect.ts"}, env)
return p.pnpm(ctx, []string{"exec", "tsx", filepath.Join(p.config.sharedDir, "scripts", "open-connect.ts")}, env)
}
+479
View File
@@ -0,0 +1,479 @@
/**
* 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 (
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/gravitational/teleport/e2e/runner/fixtures"
)
// fixtureArrayRe matches fixture array declarations within a test.use() call body.
// - fixtures: ['ssh-node']
// - fixtures: [['connect'], { option: true }]
var fixtureArrayRe = regexp.MustCompile(`fixtures:\s*\[+([^]]*)]`)
// lineNumberSuffixRe matches a trailing :line_number on a test path (e.g. "my-spec.ts:42").
var lineNumberSuffixRe = regexp.MustCompile(`:\d+$`)
// fixtureRefRe extracts individual quoted fixture names from the matched array contents.
var fixtureRefRe = regexp.MustCompile(`['"]([^'"]+)['"]`)
// helperImportRe matches imports from the e2e helpers package and captures the module name.
// e.g. `from '@gravitational/e2e/helpers/connect'` → "connect"
var helperImportRe = regexp.MustCompile(`from\s+['"]@gravitational/e2e/helpers/(\w+)['"]`)
const testUseCallPrefix = "test.use("
// scanTarget represents a file to scan with an optional line constraint.
type scanTarget struct {
path string
line int // 0 means scan entire file
}
// blockRange represents a brace-delimited block in a source file (1-indexed lines).
type blockRange struct {
start, end int
}
// callRange represents the byte offsets of a test.use(...) call in the content string.
type callRange struct {
start, end int
}
// scanFixtures scans test files and the helpers they import to discover which fixtures are needed.
func scanFixtures(e2eDir string, testFiles []string) []*fixtures.Fixture {
targets, err := resolveFilesToScan(e2eDir, testFiles)
if err != nil {
slog.Warn("fixture scan: error resolving files", "error", err)
return nil
}
slog.Debug("fixture scan: resolved targets", "count", len(targets))
// Helpers can also reference fixtures (such as Connect), so we need to scan them as well.
importedHelpers := make(map[string]bool)
for _, t := range targets {
for _, helper := range parseHelperImports(t.path) {
importedHelpers[helper] = true
}
}
// Helpers are always scanned fully (no line targeting).
// No existence check needed — scanFile handles missing files gracefully.
for helper := range importedHelpers {
targets = append(targets, scanTarget{
path: filepath.Join(e2eDir, "helpers", helper+".ts"),
})
}
slog.Debug("fixture scan: total files to scan", "count", len(targets))
seen := make(map[string]struct{})
var result []*fixtures.Fixture
for _, t := range targets {
for _, f := range scanFile(t.path, t.line) {
if _, ok := seen[f.Name]; ok {
continue
}
seen[f.Name] = struct{}{}
result = append(result, f)
}
}
return result
}
func resolveFilesToScan(e2eDir string, testFiles []string) ([]scanTarget, error) {
if len(testFiles) == 0 {
paths, err := walkSpecFiles(filepath.Join(e2eDir, "tests"))
if err != nil {
return nil, err
}
targets := make([]scanTarget, len(paths))
for i, p := range paths {
targets[i] = scanTarget{path: p}
}
return targets, nil
}
// Cache the full spec file list lazily for substring filter fallback,
// so we walk the tree at most once even with multiple filter arguments.
var allSpecs []string
var targets []scanTarget
for _, tf := range testFiles {
// Extract optional Playwright :line suffix (e.g. "my-spec.ts:42").
var line int
if loc := lineNumberSuffixRe.FindStringIndex(tf); loc != nil {
var err error
line, err = strconv.Atoi(tf[loc[0]+1:])
if err != nil {
return nil, err
}
tf = tf[:loc[0]]
}
abs := filepath.Join(e2eDir, tf)
info, err := os.Stat(abs)
if err == nil {
if info.IsDir() {
matches, err := walkSpecFiles(abs)
if err != nil {
return nil, err
}
for _, m := range matches {
targets = append(targets, scanTarget{path: m})
}
} else {
targets = append(targets, scanTarget{path: abs, line: line})
}
continue
}
// Not a concrete path — treat as a Playwright substring filter
// and match against all spec files.
if allSpecs == nil {
allSpecs, err = walkSpecFiles(filepath.Join(e2eDir, "tests"))
if err != nil {
return nil, err
}
}
before := len(targets)
for _, spec := range allSpecs {
rel, _ := filepath.Rel(e2eDir, spec)
if strings.Contains(rel, tf) {
targets = append(targets, scanTarget{path: spec, line: line})
}
}
if len(targets) == before {
return nil, fmt.Errorf("test path %q did not resolve to any spec files", tf)
}
}
return targets, nil
}
func walkSpecFiles(root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".spec.ts") {
files = append(files, path)
}
return nil
})
return files, err
}
func parseHelperImports(path string) []string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
cleaned := strings.Join(stripComments(lines), "\n")
var helpers []string
for _, match := range helperImportRe.FindAllStringSubmatch(cleaned, -1) {
helpers = append(helpers, match[1])
}
return helpers
}
func scanFile(path string, targetLine int) []*fixtures.Fixture {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
cleaned := stripComments(lines)
blocks := parseBlocks(cleaned)
content := strings.Join(cleaned, "\n")
var result []*fixtures.Fixture
for _, call := range findTestUseCalls(content) {
callLine := 1 + strings.Count(content[:call.start], "\n")
if targetLine > 0 && !fixtureInScope(callLine, targetLine, blocks) {
continue
}
body := content[call.start:call.end]
for _, m := range fixtureArrayRe.FindAllStringSubmatch(body, -1) {
for _, ref := range fixtureRefRe.FindAllStringSubmatch(m[1], -1) {
if f := fixtures.FindByName(ref[1]); f != nil {
result = append(result, f)
}
}
}
}
return result
}
func stripComments(lines []string) []string {
cleaned := make([]string, len(lines))
inBlock := false
for i, line := range lines {
if inBlock {
if idx := strings.Index(line, "*/"); idx >= 0 {
inBlock = false
line = line[idx+2:]
} else {
continue
}
}
if idx := findBlockCommentOpen(line); idx >= 0 {
if endIdx := strings.Index(line[idx+2:], "*/"); endIdx >= 0 {
// Single-line block comment.
line = line[:idx] + line[idx+2+endIdx+2:]
} else {
inBlock = true
line = line[:idx]
}
}
// Strip trailing // comment that is outside string literals.
if idx := findInlineComment(line); idx >= 0 {
line = line[:idx]
}
cleaned[i] = line
}
return cleaned
}
// findInlineComment returns the byte offset of the first // that is not inside a single-quoted, double-quoted, or
// backtick string literal, or -1.
func findInlineComment(line string) int {
var quote byte
for i := 0; i < len(line); i++ {
ch := line[i]
if quote != 0 {
if ch == '\\' {
i++
} else if ch == quote {
quote = 0
}
continue
}
switch ch {
case '\'', '"', '`':
quote = ch
case '/':
if i+1 < len(line) && line[i+1] == '/' {
return i
}
}
}
return -1
}
// findBlockCommentOpen returns the byte offset of the first /* that is not inside a string literal, or -1.
func findBlockCommentOpen(line string) int {
var quote byte
for i := 0; i < len(line); i++ {
ch := line[i]
if quote != 0 {
if ch == '\\' {
i++
} else if ch == quote {
quote = 0
}
continue
}
switch ch {
case '\'', '"', '`':
quote = ch
case '/':
if i+1 < len(line) && line[i+1] == '*' {
return i
}
}
}
return -1
}
func parseBlocks(lines []string) []blockRange {
var blocks []blockRange
var stack []int
inTemplateLiteral := false
for i, line := range lines {
lineNum := i + 1
var quote byte
for j := 0; j < len(line); j++ {
ch := line[j]
if inTemplateLiteral && quote == 0 {
quote = '`'
}
if quote != 0 {
if ch == '\\' {
j++
} else if ch == quote {
if quote == '`' {
inTemplateLiteral = false
}
quote = 0
}
continue
}
switch ch {
case '\'', '"':
quote = ch
case '`':
quote = '`'
inTemplateLiteral = true
case '{':
stack = append(stack, lineNum)
case '}':
if len(stack) > 0 {
start := stack[len(stack)-1]
stack = stack[:len(stack)-1]
blocks = append(blocks, blockRange{start: start, end: lineNum})
}
}
}
if quote != 0 && quote != '`' {
quote = 0
}
}
return blocks
}
func findTestUseCalls(content string) []callRange {
var calls []callRange
offset := 0
for {
idx := strings.Index(content[offset:], testUseCallPrefix)
if idx < 0 {
break
}
callStart := offset + idx
// Start paren counting after the opening '(' in "test.use("
depth := 1
pos := callStart + len(testUseCallPrefix)
var quote byte
for pos < len(content) && depth > 0 {
ch := content[pos]
if quote != 0 {
if ch == '\\' {
pos++ // skip escaped character
} else if ch == quote {
quote = 0
}
pos++
continue
}
switch ch {
case '\'', '"', '`':
quote = ch
case '(':
depth++
case ')':
depth--
}
pos++
}
if depth == 0 {
calls = append(calls, callRange{start: callStart, end: pos})
}
offset = pos
}
return calls
}
func fixtureInScope(fixtureLine, targetLine int, blocks []blockRange) bool {
var enclosing *blockRange
for i := range blocks {
b := &blocks[i]
if fixtureLine > b.start && fixtureLine < b.end {
if enclosing == nil || (b.end-b.start) < (enclosing.end-enclosing.start) {
enclosing = b
}
}
}
if enclosing == nil {
return true
}
return targetLine >= enclosing.start && targetLine <= enclosing.end
}
+371
View File
@@ -0,0 +1,371 @@
/**
* 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 (
"os"
"path/filepath"
"testing"
"github.com/gravitational/teleport/e2e/runner/fixtures"
)
func TestScanFile(t *testing.T) {
tests := []struct {
name string
content string
wantNames []string
}{
{
name: "test.use with single fixture",
content: `test.use({ fixtures: ['ssh-node'] });`,
wantNames: []string{"ssh-node"},
},
{
name: "test.use with multiple fixtures",
content: `test.use({ fixtures: ['ssh-node', 'connect'] });`,
wantNames: []string{"ssh-node", "connect"},
},
{
name: "test.use with nested brackets",
content: `test.use({ fixtures: [['connect'], { option: true }] });`,
wantNames: []string{"connect"},
},
{
name: "bare fixtures array without test.use is ignored",
content: ` fixtures: [['connect'], { option: true }],`,
wantNames: nil,
},
{
name: "commented out line is skipped",
content: `// test.use({ fixtures: ['ssh-node'] });`,
wantNames: nil,
},
{
name: "no fixtures",
content: `test.use({ autoLogin: true });`,
wantNames: nil,
},
{
name: "mixed with other options",
content: `test.use({ autoLogin: true, fixtures: ['connect'] });`,
wantNames: []string{"connect"},
},
{
name: "multiline fixture array",
content: `test.use({
fixtures: [
'ssh-node',
'connect',
],
});`,
wantNames: []string{"ssh-node", "connect"},
},
{
name: "multiline with comments between",
content: `test.use({
fixtures: [
// 'ssh-node',
'connect',
],
});`,
wantNames: []string{"connect"},
},
{
name: "nested parens in options before fixtures",
content: `test.use({ timeout: getTimeout(), fixtures: ['ssh-node'] });`,
wantNames: []string{"ssh-node"},
},
{
name: "block comment is stripped",
content: `/* test.use({ fixtures: ['ssh-node'] }); */
test.use({ fixtures: ['connect'] });`,
wantNames: []string{"connect"},
},
{
name: "trailing inline comment is stripped",
content: `someCode; // test.use({ fixtures: ['connect'] })`,
wantNames: nil,
},
{
name: "inline comment after real fixture is stripped",
content: `test.use({ fixtures: ['ssh-node'] }); // test.use({ fixtures: ['connect'] })`,
wantNames: []string{"ssh-node"},
},
{
name: "braces inside string literals do not corrupt blocks",
content: "const s = \"{ not a block }\";\ntest.use({ fixtures: ['ssh-node'] });",
wantNames: []string{"ssh-node"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
tmpFile := filepath.Join(dir, "test.spec.ts")
writeFile(t, dir, "test.spec.ts", tt.content)
got := scanFile(tmpFile, 0)
if len(got) != len(tt.wantNames) {
t.Fatalf("got %d fixtures, want %d", len(got), len(tt.wantNames))
}
for i, f := range got {
if f.Name != tt.wantNames[i] {
t.Errorf("fixture[%d] name = %q, want %q", i, f.Name, tt.wantNames[i])
}
}
})
}
}
func TestScanFileLineScope(t *testing.T) {
content := `test.use({ fixtures: ['ssh-node'] }); // 1 (top-level)
// 2
test.describe('connect tests', () => { // 3
test.use({ fixtures: ['connect'] }); // 4
// 5
test('opens connect', async () => { // 6
// test body // 7
}); // 8
}); // 9
// 10
test.describe('web tests', () => { // 11
test('opens web', async () => { // 12
// test body // 13
}); // 14
}); // 15
// 16
test.describe(() => { // 17
test.use({ fixtures: ['connect'] }); // 18
test('one', async () => { // 19
// test body // 20
}); // 21
test('two', async () => { // 22
// test body // 23
}); // 24
}); // 25`
tests := []struct {
name string
targetLine int
wantNames []string
}{
{
name: "no line filter returns all fixtures",
targetLine: 0,
wantNames: []string{"ssh-node", "connect", "connect"},
},
{
name: "line inside connect describe gets top-level and connect",
targetLine: 7,
wantNames: []string{"ssh-node", "connect"},
},
{
name: "line inside web describe gets only top-level",
targetLine: 13,
wantNames: []string{"ssh-node"},
},
{
name: "line targeting specific test inside describe with test.use",
targetLine: 23,
wantNames: []string{"ssh-node", "connect"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
tmpFile := filepath.Join(dir, "test.spec.ts")
writeFile(t, dir, "test.spec.ts", content)
got := scanFile(tmpFile, tt.targetLine)
if len(got) != len(tt.wantNames) {
t.Fatalf("got %d fixtures, want %d: %v", len(got), len(tt.wantNames), fixtureNames(got))
}
for i, f := range got {
if f.Name != tt.wantNames[i] {
t.Errorf("fixture[%d] name = %q, want %q", i, f.Name, tt.wantNames[i])
}
}
})
}
}
func TestParseHelperImports(t *testing.T) {
content := `import { test, expect } from '@gravitational/e2e/helpers/connect';
import { startUrl } from '@gravitational/e2e/helpers/env';
import { chromium } from '@playwright/test';
`
tmpFile := filepath.Join(t.TempDir(), "test.spec.ts")
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
got := parseHelperImports(tmpFile)
want := []string{"connect", "env"}
if len(got) != len(want) {
t.Fatalf("got %d imports, want %d: %v", len(got), len(want), got)
}
for i, h := range got {
if h != want[i] {
t.Errorf("import[%d] = %q, want %q", i, h, want[i])
}
}
}
func TestScanFixtures(t *testing.T) {
e2eDir := t.TempDir()
helpersDir := createDir(t, e2eDir, "helpers")
testsDir := createDir(t, e2eDir, "tests", "connect")
webDir := createDir(t, e2eDir, "tests", "web", "authenticated")
// Helper that declares Connect fixture as default.
connectHelper := `import { test as fixtureBase } from './fixtures';
export const test = fixtureBase.extend<{}>({});
test.use({ fixtures: ['connect'] });
`
writeFile(t, helpersDir, "connect.ts", connectHelper)
// Helper with no fixtures.
testHelper := `import { test as base } from './fixtures';
export const test = base.extend<{}>({});
`
writeFile(t, helpersDir, "test.ts", testHelper)
// Connect spec file imports from connect helper.
connectSpec := `import { test } from '@gravitational/e2e/helpers/connect';
test('something', async () => {});
`
writeFile(t, testsDir, "auth.spec.ts", connectSpec)
// Web spec file imports from test helper (no fixtures).
webSpec := `import { test } from '@gravitational/e2e/helpers/test';
test('something', async () => {});
`
writeFile(t, webDir, "roles.spec.ts", webSpec)
t.Run("connect test detects Connect fixture via helper", func(t *testing.T) {
rel, _ := filepath.Rel(e2eDir, filepath.Join(testsDir, "auth.spec.ts"))
got := scanFixtures(e2eDir, []string{rel})
if len(got) != 1 {
t.Fatalf("expected 1 fixture, got %d", len(got))
}
if got[0].Name != "connect" {
t.Errorf("expected fixture name 'connect', got %q", got[0].Name)
}
})
t.Run("web test does not detect Connect fixture", func(t *testing.T) {
rel, _ := filepath.Rel(e2eDir, filepath.Join(webDir, "roles.spec.ts"))
got := scanFixtures(e2eDir, []string{rel})
if len(got) != 0 {
t.Fatalf("expected 0 fixtures, got %d", len(got))
}
})
}
func TestResolveFilesToScan(t *testing.T) {
e2eDir := t.TempDir()
testsDir := createDir(t, e2eDir, "tests", "connect")
writeFile(t, testsDir, "auth.spec.ts", "test('auth', async () => {});")
writeFile(t, testsDir, "session.spec.ts", "test('session', async () => {});")
t.Run("file with line number", func(t *testing.T) {
rel, _ := filepath.Rel(e2eDir, filepath.Join(testsDir, "auth.spec.ts"))
targets, err := resolveFilesToScan(e2eDir, []string{rel + ":42"})
if err != nil {
t.Fatal(err)
}
if len(targets) != 1 {
t.Fatalf("expected 1 target, got %d", len(targets))
}
if targets[0].line != 42 {
t.Errorf("expected line 42, got %d", targets[0].line)
}
})
t.Run("directory expands to spec files", func(t *testing.T) {
targets, err := resolveFilesToScan(e2eDir, []string{"tests/connect"})
if err != nil {
t.Fatal(err)
}
if len(targets) != 2 {
t.Fatalf("expected 2 targets, got %d", len(targets))
}
for _, tgt := range targets {
if tgt.line != 0 {
t.Errorf("directory target should have line=0, got %d", tgt.line)
}
}
})
t.Run("substring filter matches spec files", func(t *testing.T) {
targets, err := resolveFilesToScan(e2eDir, []string{"auth"})
if err != nil {
t.Fatal(err)
}
if len(targets) != 1 {
t.Fatalf("expected 1 target, got %d", len(targets))
}
})
}
func createDir(t *testing.T, path ...string) string {
t.Helper()
dir := filepath.Join(path...)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("creating directory %s: %v", dir, err)
}
return dir
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing file %s: %v", path, err)
}
}
func fixtureNames(ff []*fixtures.Fixture) []string {
names := make([]string, len(ff))
for i, f := range ff {
names[i] = f.Name
}
return names
}
+4
View File
@@ -55,6 +55,10 @@ func pollUntil(ctx context.Context, timeout, interval time.Duration, probe func(
}
func resolveE2EDir() (string, error) {
if v := os.Getenv("E2E_DIR"); v != "" {
return filepath.Abs(v)
}
exePath, err := os.Executable()
if err != nil {
return "", err
@@ -18,6 +18,8 @@
import { test } from '@gravitational/e2e/helpers/test';
test.use({ fixtures: ['ssh-node'] });
test('verify that a user can SSH into a node', async ({
unifiedResourcesPage,
}) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+7 -1
View File
@@ -7,7 +7,7 @@ settings:
overrides:
[email protected]>[email protected]: ^3.2.3
pnpmfileChecksum: sha256-UhbfH9wqbTOi0Lx+Gm0eQ8EkqLSQxYCWXAFDAbl28uo=
pnpmfileChecksum: sha256-TR6zTaL3sXZMyu+QoRdd27aSUTH6sH0gp+M2XjxgTUI=
patchedDependencies:
[email protected]:
@@ -224,6 +224,12 @@ importers:
specifier: ^8.0.0
version: 8.0.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
e/e2e:
dependencies:
'@gravitational/e2e':
specifier: workspace:*
version: link:../../e2e
e/web/teleport: {}
e2e:
+1
View File
@@ -2,6 +2,7 @@ packages:
- web/packages/*
- e/web/*
- e2e
- e/e2e
onlyBuiltDependencies:
- '@swc/core'
- cbor-extract