mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
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:
co-authored by
Maja Cieślak
parent
1f696443d5
commit
6266cd9eba
@@ -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 }}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"**/*_pb.*",
|
||||
"**/ironrdp/pkg/**",
|
||||
"web/.storybook/public/mockServiceWorker.js",
|
||||
"e2e/helpers/fixtures.ts"
|
||||
],
|
||||
"sortPackageJson": false,
|
||||
"sortImports": {
|
||||
|
||||
+13
-4
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
Generated
+7
-1
@@ -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:
|
||||
|
||||
@@ -2,6 +2,7 @@ packages:
|
||||
- web/packages/*
|
||||
- e/web/*
|
||||
- e2e
|
||||
- e/e2e
|
||||
onlyBuiltDependencies:
|
||||
- '@swc/core'
|
||||
- cbor-extract
|
||||
|
||||
Reference in New Issue
Block a user