diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 5b2a3d4fd6b..5793566f08a 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -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
diff --git a/e2e/config/teleport.yaml.tmpl b/e2e/config/teleport.yaml.tmpl
index c3a44024506..0fc94ec6f59 100644
--- a/e2e/config/teleport.yaml.tmpl
+++ b/e2e/config/teleport.yaml.tmpl
@@ -6,7 +6,7 @@ teleport:
data_dir: {{ .DataDir }}
log:
output: stderr
- severity: INFO
+ severity: {{ .LogLevel }}
format:
output: text
auth_server: localhost:{{ .AuthServerPort }}
diff --git a/e2e/helpers/env.ts b/e2e/helpers/env.ts
new file mode 100644
index 00000000000..ea0e3b90518
--- /dev/null
+++ b/e2e/helpers/env.ts
@@ -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 .
+ */
+
+// 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');
diff --git a/e2e/helpers/login.ts b/e2e/helpers/login.ts
index 6069b596662..3eb18cd3ad5 100644
--- a/e2e/helpers/login.ts
+++ b/e2e/helpers/login.ts
@@ -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')
diff --git a/e2e/helpers/pages/Terminal.ts b/e2e/helpers/pages/Terminal.ts
index 29a0d5d3e44..4cef534b7bb 100644
--- a/e2e/helpers/pages/Terminal.ts
+++ b/e2e/helpers/pages/Terminal.ts
@@ -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 });
diff --git a/e2e/helpers/signup.ts b/e2e/helpers/signup.ts
index 5e55789c2e4..d8d07980fbf 100644
--- a/e2e/helpers/signup.ts
+++ b/e2e/helpers/signup.ts
@@ -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();
diff --git a/e2e/helpers/webauthn.ts b/e2e/helpers/webauthn.ts
index 92b14af86e7..a26e5a316a7 100644
--- a/e2e/helpers/webauthn.ts
+++ b/e2e/helpers/webauthn.ts
@@ -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,
},
});
diff --git a/e2e/package.json b/e2e/package.json
index 91161da2c83..ed3500fa8d5 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -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"
}
}
diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml
index b178f6864d6..416591ba2c9 100644
--- a/e2e/pnpm-lock.yaml
+++ b/e2e/pnpm-lock.yaml
@@ -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
diff --git a/e2e/runner/flags.go b/e2e/runner/flags.go
index 9d8008bc257..4679a9611d6 100644
--- a/e2e/runner/flags.go
+++ b/e2e/runner/flags.go
@@ -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
diff --git a/e2e/runner/playwright.go b/e2e/runner/playwright.go
index 53062cc4437..df89b7ae976 100644
--- a/e2e/runner/playwright.go
+++ b/e2e/runner/playwright.go
@@ -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)
}
diff --git a/e2e/runner/teleport.go b/e2e/runner/teleport.go
index ad7b1a5ed6f..b2313365766 100644
--- a/e2e/runner/teleport.go
+++ b/e2e/runner/teleport.go
@@ -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)
diff --git a/e2e/scripts/open-with-webauthn.ts b/e2e/scripts/open-with-webauthn.ts
new file mode 100644
index 00000000000..31264fccb13
--- /dev/null
+++ b/e2e/scripts/open-with-webauthn.ts
@@ -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 .
+ */
+
+// 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
+
+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 '
+ );
+ 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;
+ };
+
+ 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(() => {});
diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts
index 2e226754d08..971d4f3a856 100644
--- a/e2e/tests/auth.setup.ts
+++ b/e2e/tests/auth.setup.ts
@@ -16,12 +16,13 @@
* along with this program. If not, see .
*/
-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);
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 00000000000..c84e9fc600f
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}