E2E runner updates (#64505)

* Add a helper for getting env vars we expect to be defined

* Enable strict mode in TypeScript, add tsx for running browser

* Add custom script for running browse/codegen modes with webauthn setup

* Normalize test file paths to the e2e directory

* Wire up the --teleport-log-level flag

* Add @avatus, @ravicious, @ryanclark as codeowners of e2e deps

* Keep e2e tsconfig.json isolated, will run type-check in CI

* Reset changes to root tsconfig.json

* Wrap `import.meta.url` in `fileURLToPath`
This commit is contained in:
Ryan Clark
2026-03-11 13:56:29 +00:00
committed by GitHub
parent f0e8a4e8c3
commit b7792a2e42
15 changed files with 553 additions and 22 deletions
+1
View File
@@ -7,6 +7,7 @@
# Owners for JS dependency updates.
/pnpm-lock.yaml @avatus @gzdunek @ravicious @zmb3 @r0mant
/web/packages/teleterm/package.json @gzdunek @ravicious
/e2e/pnpm-lock.yaml @ryanclark @avatus @ravicious
# Owners for Go dependency updates.
/go.mod @russjones @r0mant @zmb3 @rosstimothy
+1 -1
View File
@@ -6,7 +6,7 @@ teleport:
data_dir: {{ .DataDir }}
log:
output: stderr
severity: INFO
severity: {{ .LogLevel }}
format:
output: text
auth_server: localhost:{{ .AuthServerPort }}
+34
View File
@@ -0,0 +1,34 @@
/**
* 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/>.
*/
// This file provides helper functions to read required environment variables for the E2E tests.
function required(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`required environment variable ${name} is not set`);
}
return value;
}
export const password = required('E2E_PASSWORD');
export const webauthnPrivateKey = required('E2E_WEBAUTHN_PRIVATE_KEY');
export const webauthnCredentialId = required('E2E_WEBAUTHN_CREDENTIAL_ID');
export const inviteUrl = required('E2E_INVITE_URL');
+2 -1
View File
@@ -18,13 +18,14 @@
import { type Page } from '@playwright/test';
import { password as e2ePassword } from './env';
import { expect } from './test';
import { mockWebAuthn } from './webauthn';
export async function login(
page: Page,
username = 'bob',
password = process.env.E2E_PASSWORD
password = e2ePassword
) {
await page.addInitScript(() =>
localStorage.setItem('grv_teleport_license_acknowledged', 'true')
+6 -5
View File
@@ -23,12 +23,13 @@ import { expect } from '../test';
const TERMINAL_TIMEOUT = 10_000;
export class TerminalPage {
private readonly input = this.page.getByRole('textbox', {
name: 'Terminal input',
});
private readonly terminal = this.page.getByTestId('terminal');
private readonly input;
private readonly terminal;
constructor(private page: Page) {}
constructor(private page: Page) {
this.input = page.getByRole('textbox', { name: 'Terminal input' });
this.terminal = page.getByTestId('terminal');
}
async waitForReady() {
await expect(this.input).toBeVisible({ timeout: TERMINAL_TIMEOUT });
+2 -1
View File
@@ -18,12 +18,13 @@
import type { Page } from '@playwright/test';
import { inviteUrl } from './env';
import { mockWebAuthn } from './webauthn';
export async function signup(page: Page) {
await mockWebAuthn(page);
await page.goto(process.env.E2E_INVITE_URL);
await page.goto(inviteUrl);
await page.getByRole('button', { name: 'Get started' }).click();
await page.getByRole('textbox', { name: 'Password', exact: true }).click();
+3 -4
View File
@@ -18,8 +18,7 @@
import { Page } from '@playwright/test';
const privateKeyBase64 = process.env.E2E_WEBAUTHN_PRIVATE_KEY;
const credentialIdBase64 = process.env.E2E_WEBAUTHN_CREDENTIAL_ID;
import { webauthnCredentialId, webauthnPrivateKey } from './env';
// mockWebAuthn sets up a virtual webauthn authenticator on the page.
export async function mockWebAuthn(page: Page) {
@@ -42,10 +41,10 @@ export async function mockWebAuthn(page: Page) {
await cdpSession.send('WebAuthn.addCredential', {
authenticatorId,
credential: {
credentialId: credentialIdBase64,
credentialId: webauthnCredentialId,
isResidentCredential: false,
rpId: 'localhost',
privateKey: privateKeyBase64,
privateKey: webauthnPrivateKey,
signCount: 0,
},
});
+4 -1
View File
@@ -1,13 +1,16 @@
{
"name": "@gravitational/e2e",
"version": "1.0.0",
"type": "module",
"description": "Playwright tests for e2e testing.",
"scripts": {
"test": "./run.sh",
"type-check": "tsc --noEmit",
"show-report": "playwright show-report --port 0"
},
"license": "Apache-2.0",
"devDependencies": {
"@playwright/test": "^1.55.1"
"@playwright/test": "^1.55.1",
"tsx": "^4.21.0"
}
}
+303
View File
@@ -11,19 +11,191 @@ importers:
'@playwright/test':
specifier: ^1.55.1
version: 1.56.1
tsx:
specifier: ^4.21.0
version: 4.21.0
packages:
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
@@ -34,15 +206,137 @@ packages:
engines: {node: '>=18'}
hasBin: true
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
snapshots:
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
playwright-core@1.56.1: {}
playwright@1.56.1:
@@ -50,3 +344,12 @@ snapshots:
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
resolve-pkg-maps@1.0.0: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.3
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
+49 -1
View File
@@ -24,6 +24,8 @@ import (
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
)
var sshNode = registerFixture("ssh-node", "start and connect a Teleport SSH node, runs in Docker")
@@ -43,6 +45,8 @@ type e2eFlags struct {
testFiles []string
}
var validTeleportLogLevels = []string{"DEBUG", "INFO", "WARN", "ERROR"}
func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
var f e2eFlags
@@ -84,13 +88,57 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
enableAllFixtures()
}
f.testFiles = flag.Args()
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, ", "))
}
e2eDir := filepath.Join(repoRoot, "e2e")
var err error
f.testFiles, err = normalizeTestFiles(e2eDir, flag.Args())
if err != nil {
return nil, 0, err
}
mode, err := modes.resolve()
return &f, mode, err
}
func normalizeTestFiles(e2eDir string, args []string) ([]string, error) {
if len(args) == 0 {
return nil, nil
}
callerDir := os.Getenv("E2E_CALLER_DIR")
if callerDir == "" {
var err error
callerDir, err = os.Getwd()
if err != nil {
return nil, fmt.Errorf("getting current working directory: %w", err)
}
}
normalized := make([]string, 0, len(args))
for _, arg := range args {
abs := arg
if !filepath.IsAbs(abs) {
abs = filepath.Join(callerDir, abs)
}
rel, err := filepath.Rel(e2eDir, abs)
if err != nil {
return nil, fmt.Errorf("making %q relative to e2e dir: %w", arg, err)
}
normalized = append(normalized, rel)
}
return normalized, nil
}
func stringFlagWithEnv(fs *flag.FlagSet, p *string, name, env, fallback, usage string) {
if v := os.Getenv(env); v != "" {
fallback = v
+26 -6
View File
@@ -44,6 +44,26 @@ func (p *playwrightRunner) startURL() string {
return fmt.Sprintf("https://localhost:%d/web", p.config.proxyPort)
}
// callerRelativePaths returns paths relative to the caller's working directory
// so that logged paths are cmd+clickable in terminals.
func (p *playwrightRunner) callerRelativePaths(paths []string) []string {
callerDir := os.Getenv("E2E_CALLER_DIR")
if callerDir == "" {
return paths
}
out := make([]string, len(paths))
for i, path := range paths {
abs := filepath.Join(p.config.e2eDir, path)
if rel, err := filepath.Rel(callerDir, abs); err == nil {
out[i] = rel
} else {
out[i] = path
}
}
return out
}
func (p *playwrightRunner) run(ctx context.Context, mode runMode) error {
switch mode {
case modeTest:
@@ -78,7 +98,7 @@ func (p *playwrightRunner) test(ctx context.Context, debug bool) error {
if len(p.config.testFiles) > 0 {
args := append([]string{"exec", "playwright", "test"}, extraArgs...)
args = append(args, p.config.testFiles...)
slog.Info("running e2e tests", "files", p.config.testFiles)
slog.Info("running e2e tests", "files", p.callerRelativePaths(p.config.testFiles))
return p.pnpm(ctx, args, env)
}
@@ -113,7 +133,8 @@ func (p *playwrightRunner) browse(ctx context.Context) error {
}
// openAuthenticated runs the setup project to generate auth state, then opens
// Playwright in the given mode (codegen or open) with the saved session.
// a Chromium browser with a virtual WebAuthn authenticator pre-loaded so that
// MFA challenges resolve automatically.
func (p *playwrightRunner) openAuthenticated(ctx context.Context, playwrightCmd string) error {
env, err := p.startEnv(ctx)
if err != nil {
@@ -125,12 +146,11 @@ func (p *playwrightRunner) openAuthenticated(ctx context.Context, playwrightCmd
return err
}
slog.Info("opening playwright " + playwrightCmd + " (with auth)")
slog.Info("opening playwright " + playwrightCmd + " (with auth and WebAuthn)")
return p.pnpm(ctx, []string{
"exec", "playwright", playwrightCmd,
"--load-storage=.auth/user.json",
"--ignore-https-errors",
"exec", "tsx", "scripts/open-with-webauthn.ts",
playwrightCmd,
p.startURL(),
}, env)
}
+2
View File
@@ -149,6 +149,7 @@ type TeleportConfig struct {
KeyFilePath string
CertFilePath string
LicenseFile string
LogLevel string
}
func generateTeleportConfig(templatePath string, config *e2eConfig) (string, error) {
@@ -159,6 +160,7 @@ func generateTeleportConfig(templatePath string, config *e2eConfig) (string, err
KeyFilePath: filepath.Join(config.certsDir, keyFileName),
CertFilePath: filepath.Join(config.certsDir, certFileName),
LicenseFile: config.licenseFile,
LogLevel: config.teleportLogLevel,
}
return renderTemplate(templatePath, teleportConfig)
+104
View File
@@ -0,0 +1,104 @@
/**
* 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/>.
*/
// open-with-webauthn launches a Chromium browser with a virtual WebAuthn
// authenticator preloaded so that MFA challenges resolve automatically
// in codegen and browse modes.
//
// Usage: pnpm exec tsx scripts/open-with-webauthn.ts <codegen|open> <url>
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, type BrowserContext } from '@playwright/test';
import { mockWebAuthn } from '../helpers/webauthn';
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
const green = (s: string) => `\x1b[32m${s}\x1b[39m`;
const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
function info(msg: string) {
process.stdout.write(`${green('✓')} ${msg}\n`);
}
function error(msg: string) {
process.stderr.write(`${red('✗')} ${msg}\n`);
}
const mode = process.argv[2] as 'codegen' | 'open';
const startURL = process.argv[3];
if (!mode || !startURL) {
error(
'Usage: pnpm exec tsx scripts/open-with-webauthn.ts <codegen|open> <url>'
);
process.exit(1);
}
const e2eDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const storageStatePath = join(e2eDir, '.auth/user.json');
info(`launching Chromium ${dim(`(mode: ${mode})`)}`);
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({
storageState: storageStatePath,
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
await mockWebAuthn(page);
info('virtual WebAuthn authenticator registered');
if (mode === 'codegen') {
// _enableRecorder is the internal API that `playwright codegen` itself uses
// to attach the code-generation inspector to a browser context.
type ExtendedBrowserContext = BrowserContext & {
_enableRecorder: (options: {
language: string;
mode: 'recording';
}) => Promise<void>;
};
await (context as ExtendedBrowserContext)._enableRecorder({
language: 'javascript',
mode: 'recording',
});
info('Playwright recorder enabled');
}
info(`navigating to ${cyan(bold(startURL))}`);
await page.goto(startURL);
// Exit when the user closes the last page or the browser disconnects.
page.on('close', () => {
if (context.pages().length === 0) {
browser.close().finally(() => process.exit(0));
}
});
browser.on('disconnected', () => process.exit(0));
// Block until exit.
await new Promise(() => {});
+3 -2
View File
@@ -16,12 +16,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { login } from '../helpers/login';
import { test as setup } from '../helpers/test';
const authFile = join(__dirname, '../.auth/user.json');
const authFile = join(dirname(fileURLToPath(import.meta.url)), '../.auth/user.json');
setup('authenticate', async ({ page }) => {
await login(page);
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}