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"] +}