E2E runner improvements (#64568)

* Only build teleport/tctl, skip if passed as flags, support e/

* Add support for running in different browsers

* Use a local build of teleport for the SSH node fixture

* Add a custom print summary to log results after execution finished

* Add typescript for type checking in CI

* Update mockWebAuthn to be browser agnostic

* Move the tctl invocation from Go to the test

* Save logs

* Allow for a custom docker host to be passed in

* Update playwright

* Run separate Teleport instances for each browser

* Give Connect its own Teleport instance

* Add custom dot reporter

* Add @gravitational/e2e/helpers path

* Use START_URL instead of INVITE_URL in connect login

* Always use custom dot reporter

* Always use blob reporter locally

* Only run connect if only connect tests are specified

* Do not run chromium if only running connect

* Silence the output from playwright report merging

* Ensure chromium Teleport instance doesn't start when running --browse-connect

* Log test files not projects if test files are specified
This commit is contained in:
Ryan Clark
2026-03-17 15:00:08 +00:00
committed by GitHub
parent 93accf25b5
commit 5a236216bc
34 changed files with 1688 additions and 343 deletions
+2 -2
View File
@@ -433,7 +433,7 @@
{
"name": "@playwright/test",
"importNames": ["test", "expect"],
"message": "Import 'test' and 'expect' from '../../helpers/test' instead."
"message": "Import 'test' and 'expect' from '@gravitational/e2e/helpers/test' instead."
}
]
}
@@ -450,7 +450,7 @@
{
"name": "@playwright/test",
"importNames": ["test", "expect"],
"message": "Import 'test' and 'expect' from '../../helpers/connect' instead."
"message": "Import 'test' and 'expect' from '@gravitational/e2e/helpers/connect' instead."
}
]
}
+5
View File
@@ -1,13 +1,18 @@
node_modules/
test-results/
playwright-report/
blob-reports/
playwright/.cache/
runner/e2e
runner/runner
certs/
data/
config/teleport.yaml
config/*-teleport.yaml
config/state.yaml
node/node.yaml
node/*-node.yaml
teleport.log
teleport-*.log
docker-node-*.log
.auth/
+2 -2
View File
@@ -29,7 +29,7 @@ import {
TestInfo,
} from '@playwright/test';
import { connectTshBin, connectAppDir, password, inviteUrl } from './env';
import { connectTshBin, connectAppDir, password, startUrl } from './env';
export async function launchApp(homeDir: string) {
const requireFromApp = module.createRequire(
@@ -72,7 +72,7 @@ export async function login(page: Page): Promise<void> {
const clusterInput = page.getByPlaceholder('teleport.example.com');
await expect(clusterInput).toBeVisible();
await clusterInput.fill(new URL(inviteUrl).host);
await clusterInput.fill(new URL(startUrl).host);
await expect(page.getByRole('button', { name: 'Next' })).toBeEnabled();
await page.getByRole('button', { name: 'Next', exact: true }).click();
+3 -1
View File
@@ -31,6 +31,8 @@ function required(name: string) {
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');
export const tctlBin = required('E2E_TCTL_BIN');
export const teleportConfig = required('E2E_TELEPORT_CONFIG');
export const startUrl = required('START_URL');
export const connectTshBin = required('E2E_CONNECT_TSH_BIN');
export const connectAppDir = required('E2E_CONNECT_APP_DIR');
+2 -4
View File
@@ -39,11 +39,9 @@ export class TerminalPage {
await this.input.pressSequentially(command + '\n');
}
async expectSnapshot(name: string) {
await this.page.waitForTimeout(500);
await expect(this.terminal).toHaveScreenshot(name, {
async waitForText(text: string) {
await expect(this.terminal).toContainText(text, {
timeout: TERMINAL_TIMEOUT,
maxDiffPixelRatio: 0.02,
});
}
}
+1
View File
@@ -43,6 +43,7 @@ export class UnifiedResourcesPage {
const popupPromise = this.page.waitForEvent('popup');
await this.page.getByRole('menuitem', { name: login }).click();
const popup = await popupPromise;
await popup.waitForLoadState('load');
return new TerminalPage(popup);
}
+8 -3
View File
@@ -18,13 +18,18 @@
import type { Page } from '@playwright/test';
import { inviteUrl } from './env';
import { generateInviteURL } from './tctl';
import { mockWebAuthn } from './webauthn';
export async function signup(page: Page) {
export async function signup(page: Page, username: string) {
await page.addInitScript(() =>
localStorage.setItem('grv_teleport_license_acknowledged', 'true')
);
await mockWebAuthn(page);
await page.goto(inviteUrl);
const inviteURL = generateInviteURL(username);
await page.goto(inviteURL);
await page.getByRole('button', { name: 'Get started' }).click();
await page.getByRole('textbox', { name: 'Password', exact: true }).click();
+59
View File
@@ -0,0 +1,59 @@
/**
* 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/>.
*/
import { execFileSync } from 'child_process';
import { tctlBin, teleportConfig } from './env';
const inviteURLRe = /https?:\/\/\S+\/web\/invite\/[0-9a-f]+/;
const inviteURLHostRe = /:\/\/[^/]+/;
export function generateInviteURL(username: string, roles = 'access,editor') {
const baseURL = process.env.START_URL || '';
const proxyOrigin = baseURL ? new URL(baseURL).origin : '';
const out = tctl('users', 'add', username, `--roles=${roles}`);
const match = out.match(inviteURLRe);
if (!match) {
throw new Error(`failed to parse invite URL from tctl output: ${out}`);
}
let inviteURL = match[0];
// tctl may output a placeholder like https://<proxyhost>:3080/... when it can't determine
// the proxy public address. Replace the host with the known proxy address.
if (proxyOrigin) {
inviteURL = inviteURL.replace(
inviteURLHostRe,
new URL(proxyOrigin).origin.replace(/^https?/, '')
);
}
return inviteURL;
}
export function deleteUser(username: string) {
tctl('users', 'rm', username);
}
function tctl(...args: string[]) {
return execFileSync(tctlBin, [...args, '-c', teleportConfig], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
});
}
+313 -24
View File
@@ -16,38 +16,327 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
createHash,
createPrivateKey,
createPublicKey,
createSign,
} from 'crypto';
import { Page } from '@playwright/test';
import { Encoder } from 'cbor-x';
import { webauthnCredentialId, webauthnPrivateKey } from './env';
// mockWebAuthn sets up a virtual webauthn authenticator on the page.
declare global {
function __e2eWebAuthn(json: string): Promise<string>;
}
// mockWebAuthn sets up a mock for the WebAuthn API in the browser context in a way that
// is compatible with Chromium, Firefox and WebKit.
export async function mockWebAuthn(page: Page) {
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send('WebAuthn.enable');
const credIdBuf = Buffer.from(webauthnCredentialId, 'base64');
const privateKey = createPrivateKey({
key: Buffer.from(webauthnPrivateKey, 'base64'),
format: 'der',
type: 'pkcs8',
});
const jwk = privateKey.export({ format: 'jwk' });
const x = Buffer.from(jwk.x!, 'base64url');
const y = Buffer.from(jwk.y!, 'base64url');
const pubKeyCOSE = encodeEC2PublicKeyCOSE(x, y);
const spkiPubicKey = createPublicKey(privateKey).export({
format: 'der',
type: 'spki',
});
const credIdB64 = credIdBuf.toString('base64');
const spkiPublicKeyB64 = spkiPubicKey.toString('base64');
const { authenticatorId } = await cdpSession.send(
'WebAuthn.addVirtualAuthenticator',
{
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
let signCount = 0;
await page.exposeFunction('__e2eWebAuthn', async (optionsJSON: string) => {
const opts: WebAuthnRequest = JSON.parse(optionsJSON);
signCount++;
const clientDataJSON = Buffer.from(
JSON.stringify({
type: opts.type,
challenge: opts.challenge,
origin: opts.origin,
crossOrigin: false,
})
);
const rpIdHash = createHash('sha256').update(opts.rpId).digest();
const counter = Buffer.alloc(4);
counter.writeUInt32BE(signCount);
if (opts.type === 'webauthn.create') {
const authenticatorData = Buffer.concat([
rpIdHash,
Buffer.from([0x45]), // UP + UV + AT
counter,
Buffer.alloc(16), // aaguid
Buffer.from([credIdBuf.length >> 8, credIdBuf.length & 0xff]),
credIdBuf,
pubKeyCOSE,
]);
const result: WebAuthnCreateResult = {
credentialId: credIdB64,
authenticatorData: authenticatorData.toString('base64'),
clientDataJSON: clientDataJSON.toString('base64'),
attestationObject:
encodeAttestationObject(authenticatorData).toString('base64'),
publicKey: spkiPublicKeyB64,
publicKeyAlgorithm: -7,
};
return JSON.stringify(result);
}
);
await cdpSession.send('WebAuthn.addCredential', {
authenticatorId,
credential: {
credentialId: webauthnCredentialId,
isResidentCredential: false,
rpId: 'localhost',
privateKey: webauthnPrivateKey,
signCount: 0,
},
const authenticatorData = Buffer.concat([
rpIdHash,
Buffer.from([0x05]), // UP + UV
counter,
]);
const signature = createSign('SHA256')
.update(
Buffer.concat([
authenticatorData,
createHash('sha256').update(clientDataJSON).digest(),
])
)
.sign(privateKey);
const result: WebAuthnGetResult = {
credentialId: credIdB64,
authenticatorData: authenticatorData.toString('base64'),
clientDataJSON: clientDataJSON.toString('base64'),
signature: signature.toString('base64'),
};
return JSON.stringify(result);
});
return { authenticatorId, cdpSession };
await page.addInitScript(initWebAuthnOverride);
}
function initWebAuthnOverride() {
// @ts-expect-error polyfill
self.PublicKeyCredential = class PublicKeyCredential {};
// @ts-expect-error polyfill
self.AuthenticatorAttestationResponse = class AuthenticatorAttestationResponse {};
// @ts-expect-error polyfill
self.AuthenticatorAssertionResponse = class AuthenticatorAssertionResponse {};
if (!navigator.credentials) {
const credentials = {
create: () => Promise.reject(new Error('WebAuthn not available')),
get: () => Promise.reject(new Error('WebAuthn not available')),
};
Object.defineProperty(navigator, 'credentials', {
value: Object.create(credentials),
configurable: true,
});
}
function bufToBase64(buf: ArrayBuffer | Uint8Array) {
const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
let binary = '';
for (const b of bytes) {
binary += String.fromCharCode(b);
}
return btoa(binary);
}
function base64ToBuf(b64: string): ArrayBuffer {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufToBase64url(buf: ArrayBuffer | Uint8Array) {
return bufToBase64(buf)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function base64ToBase64url(b64: string) {
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function callWebAuthn<
T extends WebAuthnCreateResult | WebAuthnGetResult,
>(
type: WebAuthnRequest['type'],
challenge: ArrayBuffer,
rpId?: string
): Promise<T> {
return JSON.parse(
await self.__e2eWebAuthn(
JSON.stringify({
type,
challenge: bufToBase64url(challenge),
rpId: rpId || location.hostname,
origin: location.origin,
} satisfies WebAuthnRequest)
)
);
}
function buildCredential(
result: WebAuthnCreateResult | WebAuthnGetResult,
responseProto:
| AuthenticatorAttestationResponse
| AuthenticatorAssertionResponse,
response: AuthenticatorAttestationResponse | AuthenticatorAssertionResponse
): Credential {
const credential = {
id: base64ToBase64url(result.credentialId),
rawId: base64ToBuf(result.credentialId),
type: 'public-key' as const,
authenticatorAttachment: 'platform' as const,
response,
getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
return {};
},
};
Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
Object.setPrototypeOf(credential.response, responseProto);
return credential;
}
const cred = navigator.credentials;
const proto: CredentialsContainer = Object.getPrototypeOf(cred); // we have to override the prototype because WebKit doesn't allow own property overrides on the instance
const origCreate = cred.create.bind(cred);
const origGet = cred.get.bind(cred);
proto.create = async function (options?: CredentialCreationOptions) {
if (!options?.publicKey) {
return origCreate(options);
}
const result = await callWebAuthn<WebAuthnCreateResult>(
'webauthn.create',
options.publicKey.challenge as ArrayBuffer,
options.publicKey.rp?.id
);
const response: AuthenticatorAttestationResponse = {
clientDataJSON: base64ToBuf(result.clientDataJSON),
attestationObject: base64ToBuf(result.attestationObject),
getTransports() {
return ['internal'];
},
getPublicKey() {
return base64ToBuf(result.publicKey);
},
getPublicKeyAlgorithm() {
return result.publicKeyAlgorithm;
},
getAuthenticatorData() {
return base64ToBuf(result.authenticatorData);
},
};
return buildCredential(
result,
AuthenticatorAttestationResponse.prototype,
response
);
};
proto.get = async function (options?: CredentialRequestOptions) {
if (!options?.publicKey) {
return origGet(options);
}
const result = await callWebAuthn<WebAuthnGetResult>(
'webauthn.get',
options.publicKey.challenge as ArrayBuffer,
options.publicKey.rpId
);
const response: AuthenticatorAssertionResponse = {
authenticatorData: base64ToBuf(result.authenticatorData),
clientDataJSON: base64ToBuf(result.clientDataJSON),
signature: base64ToBuf(result.signature),
userHandle: null,
};
return buildCredential(
result,
AuthenticatorAssertionResponse.prototype,
response
);
};
}
interface WebAuthnRequest {
type: 'webauthn.create' | 'webauthn.get';
challenge: string;
rpId: string;
origin: string;
}
interface WebAuthnCreateResult {
credentialId: string;
authenticatorData: string;
clientDataJSON: string;
attestationObject: string;
publicKey: string;
publicKeyAlgorithm: number;
}
interface WebAuthnGetResult {
credentialId: string;
authenticatorData: string;
clientDataJSON: string;
signature: string;
}
// configure cbor-x encoder with the options that are compatible with Teleport
const cbor = new Encoder({
useRecords: false,
variableMapSize: true,
// @ts-expect-error useTag259ForMaps is supported at runtime but missing from the type definitions
useTag259ForMaps: false,
});
function encodeEC2PublicKeyCOSE(x: Buffer, y: Buffer) {
return Buffer.from(
cbor.encode(
new Map<number, number | Buffer>([
[1, 2], // kty: EC2
[3, -7], // alg: ES256
[-1, 1], // crv: P-256
[-2, x],
[-3, y],
])
)
);
}
function encodeAttestationObject(authData: Buffer) {
return Buffer.from(
cbor.encode({
fmt: 'none',
attStmt: {},
authData,
})
);
}
-7
View File
@@ -1,10 +1,3 @@
ARG TELEPORT_VERSION=18
FROM public.ecr.aws/gravitational/teleport-ent-distroless:${TELEPORT_VERSION} AS src
# Copy the teleport binary to debian so we can SSH into the container and run /bin/bash
FROM debian:bookworm-slim
COPY --from=src /usr/local/bin/teleport /usr/local/bin/teleport
ENTRYPOINT ["teleport", "start", "--insecure", "-c", "/etc/teleport/node.yaml"]
+1 -1
View File
@@ -9,7 +9,7 @@ teleport:
severity: TRACE
format:
output: text
auth_server: host.docker.internal:{{ .AuthServerPort }}
auth_server: {{ .AuthServerHost }}:{{ .AuthServerPort }}
auth_service:
enabled: "no"
+5 -2
View File
@@ -10,7 +10,10 @@
},
"license": "Apache-2.0",
"devDependencies": {
"@playwright/test": "^1.55.1",
"tsx": "^4.21.0"
"@playwright/test": "^1.58.2",
"@types/node": "^25.5.0",
"cbor-x": "^1.6.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
+45 -29
View File
@@ -21,48 +21,64 @@ import { defineConfig, devices } from '@playwright/test';
// Default to localhost:3080/web/login if START_URL is not defined.
const baseURL = process.env.START_URL || 'http://localhost:3080/web/login';
const webUse = {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true,
baseURL,
const browserList = (process.env.E2E_BROWSERS || 'chromium').split(',');
const browserDevices: Record<string, object> = {
chromium: { ...devices['Desktop Chrome'], channel: 'chromium' },
firefox: { ...devices['Desktop Firefox'] },
webkit: { ...devices['Desktop Safari'] },
};
export default defineConfig({
testDir: './tests',
timeout: 15_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { open: 'never' }]],
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/.results.json' }],
],
use: {
ignoreHTTPSErrors: true,
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'setup',
testDir: './tests/web',
testMatch: /.*\.setup\.ts/,
use: webUse,
},
{
name: 'authenticated',
testDir: './tests/web/authenticated',
use: { ...webUse, storageState: '.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'unauthenticated',
testDir: './tests/web/unauthenticated',
use: webUse,
},
{
name: 'with-ssh-node',
testDir: './tests/web/with-ssh-node',
use: { ...webUse, storageState: '.auth/user.json' },
dependencies: ['setup'],
},
...browserList.flatMap(browser => {
const setupName = `${browser}:setup`;
const authState = `.auth/${browser}-user.json`;
return [
{
name: setupName,
testDir: './tests/web',
testMatch: /.*\.setup\.ts/,
use: browserDevices[browser],
},
{
name: `${browser}:authenticated`,
testDir: './tests/web/authenticated',
use: { ...browserDevices[browser], storageState: authState },
dependencies: [setupName],
},
{
name: `${browser}:unauthenticated`,
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],
},
];
}),
{
name: 'connect',
testDir: './tests/connect',
+129 -14
View File
@@ -9,14 +9,53 @@ importers:
.:
devDependencies:
'@playwright/test':
specifier: ^1.55.1
version: 1.56.1
specifier: ^1.58.2
version: 1.58.2
'@types/node':
specifier: ^25.5.0
version: 25.5.0
cbor-x:
specifier: ^1.6.4
version: 1.6.4
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@cbor-extract/[email protected]':
resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==}
cpu: [arm64]
os: [darwin]
'@cbor-extract/[email protected]':
resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==}
cpu: [x64]
os: [darwin]
'@cbor-extract/[email protected]':
resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==}
cpu: [arm64]
os: [linux]
'@cbor-extract/[email protected]':
resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==}
cpu: [arm]
os: [linux]
'@cbor-extract/[email protected]':
resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==}
cpu: [x64]
os: [linux]
'@cbor-extract/[email protected]':
resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==}
cpu: [x64]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
@@ -173,11 +212,25 @@ packages:
cpu: [x64]
os: [win32]
'@playwright/[email protected]6.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
'@playwright/[email protected]8.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@types/[email protected]':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
[email protected]:
resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -196,13 +249,17 @@ packages:
[email protected]:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
[email protected].1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
[email protected].1:
resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
[email protected]6.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
[email protected]8.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
@@ -214,8 +271,34 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
snapshots:
'@cbor-extract/[email protected]':
optional: true
'@cbor-extract/[email protected]':
optional: true
'@cbor-extract/[email protected]':
optional: true
'@cbor-extract/[email protected]':
optional: true
'@cbor-extract/[email protected]':
optional: true
'@cbor-extract/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
@@ -294,9 +377,32 @@ snapshots:
'@esbuild/[email protected]':
optional: true
'@playwright/[email protected]6.1':
'@playwright/[email protected]8.2':
dependencies:
playwright: 1.56.1
playwright: 1.58.2
'@types/[email protected]':
dependencies:
undici-types: 7.18.2
[email protected]:
dependencies:
node-gyp-build-optional-packages: 5.1.1
optionalDependencies:
'@cbor-extract/cbor-extract-darwin-arm64': 2.2.2
'@cbor-extract/cbor-extract-darwin-x64': 2.2.2
'@cbor-extract/cbor-extract-linux-arm': 2.2.2
'@cbor-extract/cbor-extract-linux-arm64': 2.2.2
'@cbor-extract/cbor-extract-linux-x64': 2.2.2
'@cbor-extract/cbor-extract-win32-x64': 2.2.2
optional: true
[email protected]:
optionalDependencies:
cbor-extract: 2.2.2
[email protected]:
optional: true
[email protected]:
optionalDependencies:
@@ -337,11 +443,16 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
[email protected].1: {}
[email protected]:
[email protected].1:
dependencies:
playwright-core: 1.56.1
detect-libc: 2.1.2
optional: true
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
@@ -353,3 +464,7 @@ snapshots:
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
[email protected]: {}
[email protected]: {}
+68 -4
View File
@@ -25,6 +25,8 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"golang.org/x/sync/errgroup"
)
@@ -34,10 +36,62 @@ func build(ctx context.Context, config *e2eConfig) error {
g, ctx := errgroup.WithContext(ctx)
if !config.noBuild {
switch config.teleportBin {
case filepath.Join(config.repoRoot, "build", "teleport"):
g.Go(func() error {
slog.Info("building teleport")
return runMake(ctx, config.repoRoot, "build/teleport")
})
case filepath.Join(config.repoRoot, "e", "build", "teleport"):
g.Go(func() error {
slog.Info("building teleport (enterprise)")
return runMake(ctx, filepath.Join(config.repoRoot, "e"), "build/teleport")
})
default:
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)
}
}
if sshNode.enabled && !config.noBuild && runtime.GOOS != "linux" {
g.Go(func() error {
slog.Info("building teleport binaries")
if err := runInDir(ctx, config.repoRoot, "make", "binaries"); err != nil {
return fmt.Errorf("make binaries: %w", err)
buildDir := config.repoRoot
if config.teleportBin == filepath.Join(config.repoRoot, "e", "build", "teleport") {
buildDir = filepath.Join(config.repoRoot, "e")
}
slog.Info("cross-compiling teleport for linux (docker node)", "dir", buildDir)
output := filepath.Join(buildDir, "build", "teleport-node")
cmd := exec.CommandContext(ctx, "go", "build",
"-o", output,
"-buildvcs=false",
"./tool/teleport",
)
cmd.Dir = buildDir
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")
}
cmd.Env = env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("cross-compiling teleport for docker node: %w", err)
}
return nil
@@ -52,7 +106,9 @@ func build(ctx context.Context, config *e2eConfig) error {
}
slog.Info("installing playwright browsers")
if err := runInDir(ctx, config.e2eDir, "pnpm", "exec", "playwright", "install", "--with-deps", "chromium"); err != nil {
args := []string{"exec", "playwright", "install", "--no-shell"}
args = append(args, config.browsers...)
if err := runInDir(ctx, config.e2eDir, "pnpm", args...); err != nil {
return fmt.Errorf("playwright install: %w", err)
}
@@ -83,6 +139,14 @@ func build(ctx context.Context, config *e2eConfig) error {
return g.Wait()
}
func runMake(ctx context.Context, dir string, targets ...string) error {
if err := runInDir(ctx, dir, "make", targets...); err != nil {
return fmt.Errorf("make %v: %w", targets, err)
}
return nil
}
func runInDir(ctx context.Context, dir, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
+63 -12
View File
@@ -31,6 +31,8 @@ import (
var sshNode = registerFixture("ssh-node", "start and connect a Teleport SSH node, runs in Docker")
var connect = registerFixture("connect", "build Teleport Connect")
var validBrowsers = []string{"chromium", "firefox", "webkit"}
type e2eFlags struct {
noBuild bool
full bool
@@ -43,6 +45,7 @@ type e2eFlags struct {
tctlBin string
teleportURL string
teleportLogLevel string
browsers []string
testFiles []string
}
@@ -70,6 +73,8 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
flag.StringVar(&f.teleportLogLevel, "teleport-log-level", "INFO", "Teleport log severity (DEBUG, INFO, WARN, ERROR)")
flag.StringVar(&f.licenseFile, "license-file", "", "path to Teleport license file (required for Enterprise features)")
stringArrayFlag(flag.CommandLine, &f.browsers, "browsers", "comma-separated browsers to test: chromium, firefox, webkit (default: chromium locally, all in CI)")
stringFlagWithEnv(flag.CommandLine, &f.teleportBin, "teleport-bin", "TELEPORT_BIN",
filepath.Join(repoRoot, "build", "teleport"), "override teleport binary path")
stringFlagWithEnv(flag.CommandLine, &f.tctlBin, "tctl-bin", "TCTL_BIN",
@@ -82,6 +87,10 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
flag.Parse()
if err := resolveAbsPaths(&f.teleportBin, &f.tctlBin); err != nil {
return nil, 0, err
}
if f.verbose {
logLevel.Set(slog.LevelDebug)
}
@@ -95,6 +104,12 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
return nil, 0, fmt.Errorf("invalid --teleport-log-level %q, must be one of: %s", f.teleportLogLevel, strings.Join(validTeleportLogLevels, ", "))
}
for _, b := range f.browsers {
if !slices.Contains(validBrowsers, b) {
return nil, 0, fmt.Errorf("invalid browser %q, must be one of: %s", b, strings.Join(validBrowsers, ", "))
}
}
e2eDir := filepath.Join(repoRoot, "e2e")
var err error
@@ -111,6 +126,7 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
// Auto-enable Connect if intent is explicit via mode or selected test paths.
if mode == modeBrowseConnect {
connect.enabled = true
f.browsers = []string{}
}
for _, file := range f.testFiles {
slashPath := filepath.ToSlash(file)
@@ -120,6 +136,21 @@ func parseFlags(repoRoot string) (*e2eFlags, runMode, error) {
}
}
// If every specified test file targets connect, skip browser instances.
if len(f.testFiles) > 0 {
allConnect := true
for _, file := range f.testFiles {
slashPath := filepath.ToSlash(file)
if slashPath != "tests/connect" && !strings.HasPrefix(slashPath, "tests/connect/") {
allConnect = false
break
}
}
if allConnect {
f.browsers = []string{}
}
}
return &f, mode, nil
}
@@ -128,21 +159,11 @@ func normalizeTestFiles(e2eDir string, args []string) ([]string, error) {
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)
if err := resolveAbsPaths(&abs); err != nil {
return nil, err
}
rel, err := filepath.Rel(e2eDir, abs)
@@ -156,6 +177,36 @@ func normalizeTestFiles(e2eDir string, args []string) ([]string, error) {
return normalized, nil
}
func resolveAbsPaths(paths ...*string) error {
callerDir := os.Getenv("E2E_CALLER_DIR")
if callerDir == "" {
var err error
callerDir, err = os.Getwd()
if err != nil {
return fmt.Errorf("getting current working directory: %w", err)
}
}
for _, p := range paths {
if *p != "" && !filepath.IsAbs(*p) {
*p = filepath.Join(callerDir, *p)
}
}
return nil
}
func stringArrayFlag(fs *flag.FlagSet, p *[]string, name, usage string) {
fs.Func(name, usage, func(s string) error {
for _, v := range strings.Split(s, ",") {
if v = strings.TrimSpace(v); v != "" {
*p = append(*p, v)
}
}
return nil
})
}
func stringFlagWithEnv(fs *flag.FlagSet, p *string, name, env, fallback, usage string) {
if v := os.Getenv(env); v != "" {
fallback = v
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.25.8
require (
github.com/docker/go-sdk/container v0.1.0-alpha013
github.com/docker/go-sdk/image v0.1.0-alpha013
github.com/lmittmann/tint v1.1.3
github.com/moby/moby/api v1.52.0
github.com/moby/moby/client v0.1.0
@@ -28,6 +27,7 @@ require (
github.com/docker/go-sdk/client v0.1.0-alpha012 // indirect
github.com/docker/go-sdk/config v0.1.0-alpha012 // indirect
github.com/docker/go-sdk/context v0.1.0-alpha012 // indirect
github.com/docker/go-sdk/image v0.1.0-alpha013 // indirect
github.com/docker/go-sdk/network v0.1.0-alpha012 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
+67
View File
@@ -0,0 +1,67 @@
/**
* 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 (
"context"
"fmt"
"log/slog"
)
type browserInstance struct {
browser string
log *slog.Logger
proxyPort int
authPort int
sshPort int
dataDir string
teleportConfigPath string
teleport *teleportInstance
node *dockerNode
}
var browserColors = map[string]string{
"chromium": "\033[36m", // cyan
"firefox": "\033[33m", // yellow
"webkit": "\033[35m", // magenta
"connect": "\033[32m", // green
}
type prefixHandler struct {
slog.Handler
browser string
}
func (h *prefixHandler) Handle(ctx context.Context, r slog.Record) error {
color, ok := browserColors[h.browser]
if !ok {
color = "\033[37m"
}
r.Message = fmt.Sprintf("%s[%s]\033[0m %s", color, h.browser, r.Message)
return h.Handler.Handle(ctx, r)
}
func newBrowserLogger(browser string) *slog.Logger {
return slog.New(&prefixHandler{
Handler: slog.Default().Handler(),
browser: browser,
})
}
+189 -74
View File
@@ -26,10 +26,12 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/lmittmann/tint"
"golang.org/x/sync/errgroup"
)
var logLevel = new(slog.LevelVar)
@@ -41,7 +43,22 @@ func main() {
}))
slog.SetDefault(logger)
err := run()
e2eDir, err := resolveE2EDir()
if err != nil {
slog.Error("failed to resolve e2e directory", "error", err)
os.Exit(1)
}
flags, mode, err := parseFlags(filepath.Dir(e2eDir))
if err != nil {
slog.Error("failed to parse flags", "error", err)
os.Exit(1)
}
_ = os.Remove(filepath.Join(e2eDir, "test-results", ".results.json"))
isCI := os.Getenv("CI") != ""
runErr := run(flags, mode, e2eDir, isCI)
// Reset the terminal before exiting to ensure we aren't left with a messed up terminal if interrupted
if tty, err := os.Open("/dev/tty"); err == nil {
@@ -53,8 +70,10 @@ func main() {
tty.Close()
}
if err != nil {
slog.Error("runner exited with error", "error", err)
printTestSummary(e2eDir)
if runErr != nil {
slog.Error("runner exited with error", "error", runErr)
os.Exit(1)
}
}
@@ -62,57 +81,55 @@ func main() {
type e2eConfig struct {
e2eFlags
isCI bool
dataDir string
repoRoot string
e2eDir string
certsDir string
nodeTeleportVersion string
nodeConfigTemplate string
teleportConfigTemplate string
stateTemplate string
teleportConfigPath string
connectAppDir string
connectTshBinPath string
creds *credentials
proxyPort, authPort, sshPort int
instances []*browserInstance
connectInstance *browserInstance
}
// run sets up the test environment (ports, certs, credentials, teleport instance)
// and hands off to the Playwright runner in whatever mode was requested.
func run() error {
func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
e2eDir, err := resolveE2EDir()
if err != nil {
return fmt.Errorf("failed to resolve e2e directory: %w", err)
}
flags, mode, err := parseFlags(filepath.Dir(e2eDir))
if err != nil {
return fmt.Errorf("failed to parse flags: %w", err)
}
config := &e2eConfig{
e2eFlags: *flags,
isCI: os.Getenv("CI") != "",
dataDir: filepath.Join(e2eDir, "data"),
isCI: isCI,
repoRoot: filepath.Dir(e2eDir),
e2eDir: e2eDir,
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"),
nodeTeleportVersion: envOrDefault("TELEPORT_NODE_VERSION", "18"),
connectAppDir: filepath.Join(filepath.Dir(e2eDir), "web", "packages", "teleterm"),
connectTshBinPath: filepath.Join(filepath.Dir(e2eDir), "build", "tsh-e2e-webauthnmock"),
}
slog.Info("running playwright in mode", "mode", mode)
if flags.browsers == nil {
if config.isCI {
config.browsers = []string{"chromium", "firefox", "webkit"}
} else {
config.browsers = []string{"chromium"}
}
}
if (mode == modeBrowse || mode == modeCodegen) && len(config.browsers) > 1 {
return fmt.Errorf("--%s only supports a single browser, got: %v", mode, config.browsers)
}
slog.Info("running playwright in mode", "mode", mode, "browsers", config.browsers)
slog.Debug("using teleport binary", "path", flags.teleportBin)
slog.Debug("using tctl binary", "path", flags.tctlBin)
@@ -121,21 +138,44 @@ func run() error {
slog.Debug("CI environment detected")
}
portTargets := []*int{&config.proxyPort, &config.authPort}
for _, browser := range config.browsers {
inst := &browserInstance{
browser: browser,
log: newBrowserLogger(browser),
dataDir: filepath.Join(e2eDir, "data", browser),
}
config.instances = append(config.instances, inst)
}
if sshNode.enabled {
portTargets = append(portTargets, &config.sshPort)
if connect.enabled {
config.connectInstance = &browserInstance{
browser: "connect",
log: newBrowserLogger("connect"),
dataDir: filepath.Join(e2eDir, "data", "connect"),
}
}
// Allocate all ports at once to minimize race windows.
var portTargets []*int
for _, inst := range config.instances {
portTargets = append(portTargets, &inst.proxyPort, &inst.authPort)
if sshNode.enabled {
portTargets = append(portTargets, &inst.sshPort)
}
}
if ci := config.connectInstance; ci != nil {
portTargets = append(portTargets, &ci.proxyPort, &ci.authPort)
}
if err := allocatePorts(portTargets...); err != nil {
return fmt.Errorf("failed to allocate ports: %w", err)
}
slog.Debug("running proxy", "port", config.proxyPort)
slog.Debug("running auth server", "port", config.authPort)
if sshNode.enabled {
slog.Debug("running SSH service", "port", config.sshPort)
for _, inst := range config.instances {
inst.log.Debug("allocated ports", "proxy", inst.proxyPort, "auth", inst.authPort, "ssh", inst.sshPort)
}
if ci := config.connectInstance; ci != nil {
ci.log.Debug("allocated ports", "proxy", ci.proxyPort, "auth", ci.authPort)
}
if err := build(ctx, config); err != nil {
@@ -155,9 +195,16 @@ func run() error {
}
if config.teleportURL == "" {
slog.Debug("cleaning data directory", "path", config.dataDir)
if err := os.RemoveAll(config.dataDir); err != nil {
return fmt.Errorf("failed to clean data directory: %w", err)
allInstances := config.instances
if config.connectInstance != nil {
allInstances = append(allInstances, config.connectInstance)
}
for _, inst := range allInstances {
inst.log.Debug("cleaning data directory", "path", inst.dataDir)
if err := os.RemoveAll(inst.dataDir); err != nil {
return fmt.Errorf("failed to clean data directory for %s: %w", inst.browser, err)
}
}
creds, err := generateUserCredentials()
@@ -166,67 +213,138 @@ func run() error {
}
config.creds = creds
// One shared state file used by all instances.
stateFile, err := generateStateFile(config.stateTemplate, creds)
if err != nil {
return fmt.Errorf("failed to generate state file: %w", err)
}
slog.Debug("generated bootstrap state", "path", stateFile)
teleportConfig, err := generateTeleportConfig(config.teleportConfigTemplate, config)
if err != nil {
return fmt.Errorf("failed to generate Teleport config: %w", err)
for _, inst := range allInstances {
outPath := filepath.Join(e2eDir, "config", inst.browser+"-teleport.yaml")
tcfg, err := generateTeleportConfig(config.teleportConfigTemplate, outPath, &TeleportConfig{
DataDir: inst.dataDir,
AuthServerPort: inst.authPort,
ProxyPort: inst.proxyPort,
KeyFilePath: filepath.Join(config.certsDir, keyFileName),
CertFilePath: filepath.Join(config.certsDir, certFileName),
LicenseFile: config.licenseFile,
LogLevel: config.teleportLogLevel,
})
if err != nil {
return fmt.Errorf("failed to generate Teleport config for %s: %w", inst.browser, err)
}
inst.teleportConfigPath = tcfg
inst.log.Debug("generated Teleport config", "path", tcfg)
}
config.teleportConfigPath = teleportConfig
slog.Debug("generated Teleport config", "path", teleportConfig)
g, gctx := errgroup.WithContext(ctx)
for _, inst := range allInstances {
teleport := &teleportInstance{
log: inst.log,
teleportBin: config.teleportBin,
proxyPort: inst.proxyPort,
configPath: inst.teleportConfigPath,
stateFile: stateFile,
}
if config.isCI || config.quiet {
teleport.logFile = filepath.Join(config.e2eDir, "teleport-"+inst.browser+".log")
inst.log.Debug("redirecting Teleport logs to file", "path", teleport.logFile)
}
inst.teleport = teleport
teleport := &teleportInstance{
config: config,
configPath: teleportConfig,
stateFile: stateFile,
g.Go(func() error {
if err := teleport.start(ctx); err != nil {
return fmt.Errorf("failed to start Teleport for %s: %w", inst.browser, err)
}
if err := teleport.waitReady(gctx, 30*time.Second); err != nil {
return fmt.Errorf("Teleport for %s failed to become ready: %w", inst.browser, err)
}
return nil
})
}
if config.isCI || config.quiet {
teleport.logFile = filepath.Join(config.e2eDir, "teleport.log")
slog.Debug("redirecting Teleport logs to file", "path", teleport.logFile)
if err := g.Wait(); err != nil {
for _, inst := range allInstances {
if inst.teleport != nil {
inst.teleport.stop()
}
}
return err
}
if err := teleport.start(ctx); err != nil {
return fmt.Errorf("failed to start Teleport: %w", err)
}
defer teleport.stop()
if err := teleport.waitReady(ctx, 30*time.Second); err != nil {
return fmt.Errorf("failed to wait for Teleport to be ready: %w", err)
}
defer func() {
for _, inst := range allInstances {
if inst.node != nil {
inst.node.stop(context.Background())
}
}
for _, inst := range allInstances {
if inst.teleport != nil {
inst.teleport.stop()
}
}
}()
if sshNode.enabled {
slog.Info("running with SSH node fixture enabled", "node_version", config.nodeTeleportVersion)
slog.Info("running with SSH node fixture enabled")
nodeConfig, err := generateTeleportNodeConfig(config.nodeConfigTemplate, config)
nodeBin := config.teleportBin
if runtime.GOOS != "linux" {
buildDir := config.repoRoot
if config.teleportBin == filepath.Join(config.repoRoot, "e", "build", "teleport") {
buildDir = filepath.Join(config.repoRoot, "e")
}
nodeBin = filepath.Join(buildDir, "build", "teleport-node")
}
dockerHost, err := resolveDockerHost()
if err != nil {
return fmt.Errorf("failed to generate Teleport node config: %w", err)
return fmt.Errorf("resolving docker host: %w", err)
}
slog.Debug("generated Teleport node config", "path", nodeConfig)
for _, inst := range config.instances {
outPath := filepath.Join(e2eDir, "node", inst.browser+"-node.yaml")
nodeConfigPath, err := generateTeleportNodeConfig(config.nodeConfigTemplate, outPath, &TeleportNodeConfig{
AuthServerHost: dockerHost,
AuthServerPort: inst.authPort,
SSHServerPort: inst.sshPort,
})
if err != nil {
return fmt.Errorf("failed to generate node config for %s: %w", inst.browser, err)
}
inst.log.Debug("generated Teleport node config", "path", nodeConfigPath)
node := &dockerNode{
config: config,
imageName: "teleport-e2e-node",
containerName: "teleport-e2e-node",
configPath: nodeConfig,
dockerfilePath: filepath.Join(config.e2eDir, "node", "Dockerfile"),
ctr: nil,
inst.node = &dockerNode{
log: inst.log,
sshPort: inst.sshPort,
tctlBin: config.tctlBin,
teleportConfigPath: inst.teleportConfigPath,
logFilePath: filepath.Join(config.e2eDir, "docker-node-"+inst.browser+".log"),
imageName: nodeImage,
containerName: "teleport-e2e-node-" + inst.browser,
configPath: nodeConfigPath,
teleportBin: nodeBin,
}
}
if err := node.start(ctx); err != nil {
return fmt.Errorf("failed to start docker node: %w", err)
if err := pullImage(ctx, nodeImage); err != nil {
return fmt.Errorf("pulling docker image: %w", err)
}
// using context.Background() here because we want to ensure the node is stopped even if the main context is canceled.
defer node.stop(context.Background())
if err := node.waitJoined(ctx, 30*time.Second); err != nil {
return fmt.Errorf("failed to wait for node to join cluster: %w", err)
g, gctx := errgroup.WithContext(ctx)
for _, inst := range config.instances {
g.Go(func() error {
if err := inst.node.start(gctx); err != nil {
return fmt.Errorf("failed to start docker node for %s: %w", inst.browser, err)
}
if err := inst.node.waitJoined(gctx, 30*time.Second); err != nil {
return fmt.Errorf("docker node for %s failed to join cluster: %w", inst.browser, err)
}
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
}
}
@@ -236,9 +354,6 @@ func run() error {
if sshNode.enabled {
extraProjects = append(extraProjects, "with-ssh-node")
}
if connect.enabled {
extraProjects = append(extraProjects, "connect")
}
pw := &playwrightRunner{
config: config,
+93 -40
View File
@@ -21,51 +21,40 @@ package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/docker/go-sdk/container"
"github.com/docker/go-sdk/image"
apicontainer "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
)
const nodeImage = "debian:bookworm-slim"
type dockerNode struct {
config *e2eConfig
imageName string
containerName string
configPath string
dockerfilePath string
log *slog.Logger
sshPort int
tctlBin string
teleportConfigPath string
logFilePath string
imageName string
containerName string
configPath string
teleportBin string
ctr *container.Container
}
func (d *dockerNode) start(ctx context.Context) error {
if err := d.buildImage(ctx); err != nil {
return err
}
return d.runContainer(ctx)
}
func (d *dockerNode) buildImage(ctx context.Context) error {
slog.Info("building docker SSH node", "version", d.config.nodeTeleportVersion)
if _, err := image.BuildFromDir(ctx, filepath.Dir(d.dockerfilePath), filepath.Base(d.dockerfilePath), d.imageName,
image.WithBuildOptions(client.ImageBuildOptions{
BuildArgs: map[string]*string{"TELEPORT_VERSION": &d.config.nodeTeleportVersion},
}),
); err != nil {
return fmt.Errorf("building docker image: %w", err)
}
return nil
}
func (d *dockerNode) removeStale(ctx context.Context) {
cli, err := client.New(client.WithAPIVersionNegotiation())
if err != nil {
@@ -77,24 +66,39 @@ func (d *dockerNode) removeStale(ctx context.Context) {
}
func (d *dockerNode) runContainer(ctx context.Context) error {
slog.Info("starting docker SSH node")
d.log.Info("starting docker SSH node")
d.removeStale(ctx)
ctr, err := container.Run(ctx,
container.WithImage(d.imageName),
container.WithName(d.containerName),
container.WithExposedPorts(fmt.Sprintf("%d/tcp", d.config.sshPort)),
container.WithFiles(container.File{
HostPath: d.configPath,
ContainerPath: "/etc/teleport/node.yaml",
Mode: 0o644,
container.WithImagePlatform("linux/amd64"),
container.WithPullHandler(func(r io.ReadCloser) error {
_, err := io.Copy(io.Discard, r)
return err
}),
container.WithName(d.containerName),
container.WithEntrypoint("teleport", "start", "--insecure", "-c", "/etc/teleport/node.yaml"),
container.WithExposedPorts(fmt.Sprintf("%d/tcp", d.sshPort)),
container.WithFiles(
container.File{
HostPath: d.teleportBin,
ContainerPath: "/usr/local/bin/teleport",
Mode: 0o755,
},
container.File{
HostPath: d.configPath,
ContainerPath: "/etc/teleport/node.yaml",
Mode: 0o644,
},
),
container.WithHostConfigModifier(func(hc *apicontainer.HostConfig) {
hc.ExtraHosts = []string{"host.docker.internal:host-gateway"}
if os.Getenv("DOCKER_HOST") == "" {
hc.ExtraHosts = []string{"host.docker.internal:host-gateway"}
}
hc.PortBindings = network.PortMap{
network.MustParsePort(fmt.Sprintf("%d/tcp", d.config.sshPort)): []network.PortBinding{
{HostPort: fmt.Sprintf("%d", d.config.sshPort)},
network.MustParsePort(fmt.Sprintf("%d/tcp", d.sshPort)): []network.PortBinding{
{HostPort: fmt.Sprintf("%d", d.sshPort)},
},
}
}),
@@ -110,11 +114,11 @@ func (d *dockerNode) runContainer(ctx context.Context) error {
}
func (d *dockerNode) waitJoined(ctx context.Context, timeout time.Duration) error {
slog.Debug("waiting for docker node to join cluster")
d.log.Debug("waiting for docker node to join cluster")
probe := func(ctx context.Context) (bool, error) {
cmd := exec.CommandContext(ctx, d.config.tctlBin, "nodes", "ls",
"-c", d.config.teleportConfigPath)
cmd := exec.CommandContext(ctx, d.tctlBin, "nodes", "ls",
"-c", d.teleportConfigPath)
out, err := cmd.Output()
if err != nil {
return false, nil
@@ -127,17 +131,66 @@ func (d *dockerNode) waitJoined(ctx context.Context, timeout time.Duration) erro
return fmt.Errorf("docker node failed to join cluster: %w", err)
}
slog.Info("docker SSH node is ready")
d.log.Info("docker SSH node is ready")
return nil
}
func (d *dockerNode) saveLogs(ctx context.Context) {
if d.ctr == nil {
return
}
logPath := d.logFilePath
logs, err := d.ctr.Logs(ctx)
if err != nil {
d.log.Warn("could not get docker node logs", "error", err)
return
}
defer logs.Close()
f, err := os.Create(logPath)
if err != nil {
d.log.Warn("could not create docker node log file", "error", err)
return
}
defer f.Close()
if _, err := io.Copy(f, logs); err != nil {
d.log.Warn("could not write docker node logs", "error", err)
return
}
d.log.Info("saved docker node logs", "path", logPath)
}
func (d *dockerNode) stop(ctx context.Context) {
if d.ctr == nil {
return
}
slog.Info("stopping docker SSH node")
d.log.Info("stopping docker SSH node")
d.saveLogs(ctx)
_ = d.ctr.Terminate(ctx, container.TerminateTimeout(10*time.Second))
}
func pullImage(ctx context.Context, image string) error {
slog.Info("pulling docker image", "image", image)
cli, err := client.New(client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("creating docker client: %w", err)
}
defer cli.Close()
rc, err := cli.ImagePull(ctx, image, client.ImagePullOptions{})
if err != nil {
return fmt.Errorf("pulling image: %w", err)
}
defer rc.Close()
_, err = io.Copy(io.Discard, rc)
return err
}
+128 -62
View File
@@ -28,7 +28,9 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"golang.org/x/sync/errgroup"
)
type playwrightRunner struct {
@@ -36,12 +38,12 @@ type playwrightRunner struct {
extraProjects []string
}
func (p *playwrightRunner) startURL() string {
func (p *playwrightRunner) startURL(inst *browserInstance) string {
if p.config.teleportURL != "" {
return p.config.teleportURL + "/web"
}
return fmt.Sprintf("https://localhost:%d/web", p.config.proxyPort)
return fmt.Sprintf("https://localhost:%d/web", inst.proxyPort)
}
// callerRelativePaths returns paths relative to the caller's working directory
@@ -84,41 +86,112 @@ func (p *playwrightRunner) run(ctx context.Context, mode runMode) error {
}
func (p *playwrightRunner) test(ctx context.Context, debug bool) error {
env, err := p.startEnv(ctx)
if err != nil {
return err
}
if debug {
env = append(env, "PWDEBUG=1")
blobBaseDir := filepath.Join(p.config.e2eDir, "blob-reports")
if err := os.RemoveAll(blobBaseDir); err != nil {
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...)
var extraArgs []string
if p.config.updateSnapshots {
extraArgs = append(extraArgs, "--update-snapshots")
}
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.callerRelativePaths(p.config.testFiles))
return p.pnpm(ctx, args, env)
var g errgroup.Group
for _, inst := range p.config.instances {
g.Go(func() error {
env, err := p.startEnv(inst)
if err != nil {
return fmt.Errorf("building env for %s: %w", inst.browser, err)
}
if debug {
env = append(env, "PWDEBUG=1")
}
env = append(env, "PLAYWRIGHT_BLOB_OUTPUT_FILE="+filepath.Join(blobBaseDir, inst.browser+".zip"))
args := []string{"exec", "playwright", "test"}
args = append(args, extraArgs...)
args = append(args, "--reporter=blob,./scripts/dot-progress-reporter.ts")
for _, proj := range baseProjects {
args = append(args, "--project="+inst.browser+":"+proj)
}
if len(p.config.testFiles) > 0 {
args = append(args, p.config.testFiles...)
}
if len(p.config.testFiles) > 0 {
inst.log.Info("running e2e tests", "files", p.config.testFiles)
} else {
inst.log.Info("running e2e tests", "projects", baseProjects)
}
if err := p.pnpm(ctx, args, env); err != nil {
return fmt.Errorf("playwright tests failed for %s: %w", inst.browser, err)
}
return nil
})
}
projects := append([]string{"authenticated", "unauthenticated"}, p.extraProjects...)
args := append([]string{"exec", "playwright", "test"}, extraArgs...)
for _, project := range projects {
args = append(args, "--project="+project)
if ci := p.config.connectInstance; ci != nil {
g.Go(func() error {
env, err := p.startEnv(ci)
if err != nil {
return fmt.Errorf("building env for connect: %w", err)
}
if debug {
env = append(env, "PWDEBUG=1")
}
env = append(env, "PLAYWRIGHT_BLOB_OUTPUT_FILE="+filepath.Join(blobBaseDir, "connect.zip"))
args := []string{"exec", "playwright", "test"}
args = append(args, extraArgs...)
args = append(args, "--reporter=blob,./scripts/dot-progress-reporter.ts", "--project=connect")
if len(p.config.testFiles) > 0 {
args = append(args, p.config.testFiles...)
}
if len(p.config.testFiles) > 0 {
ci.log.Info("running e2e tests", "files", p.config.testFiles)
} else {
ci.log.Info("running e2e tests", "projects", []string{"connect"})
}
if err := p.pnpm(ctx, args, env); err != nil {
return fmt.Errorf("playwright tests failed for connect: %w", err)
}
return nil
})
}
slog.Info("running e2e tests", "projects", projects)
testErr := g.Wait()
return p.pnpm(ctx, args, env)
slog.Info("merging blob reports")
mergeArgs := []string{"exec", "playwright", "merge-reports", "--config=playwright.config.ts", blobBaseDir}
mergeEnv := os.Environ()
mergeEnv = append(mergeEnv, "FORCE_COLOR=1")
if err := p.pnpmQuiet(ctx, mergeArgs, mergeEnv); err != nil {
slog.Warn("failed to merge reports", "error", err)
if testErr == nil {
return err
}
}
return testErr
}
func (p *playwrightRunner) ui(ctx context.Context) error {
slog.Info("starting playwright in UI mode")
env, err := p.startEnv(ctx)
inst := p.config.instances[0]
env, err := p.startEnv(inst)
if err != nil {
return err
}
@@ -134,13 +207,14 @@ func (p *playwrightRunner) codegen(ctx context.Context) error {
// a Chromium browser with a virtual WebAuthn authenticator pre-loaded so that
// MFA challenges resolve automatically.
func (p *playwrightRunner) openWebAuthenticated(ctx context.Context, playwrightCmd string) error {
env, err := p.startEnv(ctx)
inst := p.config.instances[0]
env, err := p.startEnv(inst)
if err != nil {
return err
}
slog.Debug("running setup project to generate auth state")
if err := p.pnpm(ctx, []string{"exec", "playwright", "test", "--project=setup"}, env); err != nil {
if err := p.pnpm(ctx, []string{"exec", "playwright", "test", "--project=" + inst.browser + ":setup"}, env); err != nil {
return err
}
@@ -149,12 +223,16 @@ func (p *playwrightRunner) openWebAuthenticated(ctx context.Context, playwrightC
return p.pnpm(ctx, []string{
"exec", "tsx", "scripts/open-with-webauthn.ts",
playwrightCmd,
p.startURL(),
p.startURL(inst),
}, env)
}
func (p *playwrightRunner) openConnectAuthenticated(ctx context.Context) error {
env, err := p.startEnv(ctx)
inst := p.config.connectInstance
if inst == nil {
return fmt.Errorf("connect instance not configured (use --with-connect)")
}
env, err := p.startEnv(inst)
if err != nil {
return err
}
@@ -164,39 +242,16 @@ func (p *playwrightRunner) openConnectAuthenticated(ctx context.Context) error {
return p.pnpm(ctx, []string{"exec", "tsx", "scripts/open-connect.ts"}, env)
}
// generateInviteURL runs tctl to create a new user and extracts the invite link from its output.
func (p *playwrightRunner) generateInviteURL(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, p.config.tctlBin, "users", "add", "testuser",
"--roles=access,editor", "-c", p.config.teleportConfigPath)
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
slog.Error("tctl users add failed", "stderr", string(exitErr.Stderr))
}
return "", fmt.Errorf("tctl users add: %w", err)
}
inviteURL := parseInviteURL(string(out))
if inviteURL == "" {
return "", fmt.Errorf("failed to parse invite URL from tctl output: %s", string(out))
}
slog.Debug("generated invite URL", "url", inviteURL)
return inviteURL, nil
}
// startEnv builds the environment variables that Playwright tests need,
// including START_URL, credentials, and the invite URL for signup tests.
func (p *playwrightRunner) startEnv(ctx context.Context) ([]string, error) {
// including START_URL, credentials, and tctl paths for invite URL generation.
func (p *playwrightRunner) startEnv(inst *browserInstance) ([]string, error) {
env := os.Environ()
// Force color output since Playwright's TTY detection won't work
// when stdout/stderr are wrapped by the rewrite writer.
env = append(env, "FORCE_COLOR=1")
if os.Getenv("START_URL") == "" {
env = append(env, "START_URL="+p.startURL())
env = append(env, "START_URL="+p.startURL(inst))
}
if creds := p.config.creds; creds != nil {
@@ -207,11 +262,9 @@ func (p *playwrightRunner) startEnv(ctx context.Context) ([]string, error) {
)
}
inviteURL, err := p.generateInviteURL(ctx)
if err != nil {
return nil, fmt.Errorf("generating invite URL: %w", err)
}
env = append(env, "E2E_INVITE_URL="+inviteURL)
env = append(env, "E2E_TCTL_BIN="+p.config.tctlBin)
env = append(env, "E2E_TELEPORT_CONFIG="+inst.teleportConfigPath)
env = append(env, "E2E_BROWSERS="+strings.Join(p.config.browsers, ","))
env = append(env, "E2E_CONNECT_TSH_BIN="+p.config.connectTshBinPath)
env = append(env, "E2E_CONNECT_APP_DIR="+p.config.connectAppDir)
@@ -219,6 +272,25 @@ func (p *playwrightRunner) startEnv(ctx context.Context) ([]string, error) {
return env, nil
}
func (p *playwrightRunner) pnpmQuiet(ctx context.Context, args []string, env []string) error {
cmd := exec.CommandContext(ctx, "pnpm", args...)
cmd.Dir = p.config.e2eDir
cmd.Env = env
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("command exited with code %d: %w", exitErr.ExitCode(), err)
}
return fmt.Errorf("failed to run command: %w", err)
}
return nil
}
func (p *playwrightRunner) pnpm(ctx context.Context, args []string, env []string) error {
cmd := exec.CommandContext(ctx, "pnpm", args...)
cmd.Dir = p.config.e2eDir
@@ -283,9 +355,3 @@ func (rw rewriteWriter) Write(p []byte) (int, error) {
}
return len(p), nil
}
var inviteURLRe = regexp.MustCompile(`https?://\S+/web/invite/[0-9a-f]+`)
func parseInviteURL(output string) string {
return inviteURLRe.FindString(output)
}
+345
View File
@@ -0,0 +1,345 @@
/**
* 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 (
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
)
// printTestSummary reads the Playwright test results and prints them the same way that Playwright does, so
// we can show the overall test summary at the end after everything has exited and stopped logging.
func printTestSummary(e2eDir string) {
resultsPath := filepath.Join(e2eDir, "test-results", ".results.json")
data, err := os.ReadFile(resultsPath)
if err != nil {
slog.Warn("could not read test results", "path", resultsPath, "error", err)
return
}
var report pwReport
if err := json.Unmarshal(data, &report); err != nil {
slog.Warn("could not parse test results", "error", err)
return
}
failed := collectFailures(report.Suites)
// rewrite the paths relative to the caller directory
pathPrefix := ""
if callerDir := os.Getenv("E2E_CALLER_DIR"); callerDir != "" && callerDir != e2eDir {
if rel, err := filepath.Rel(callerDir, e2eDir); err == nil && rel != "." {
pathPrefix = rel + "/"
}
}
for i := range failed {
failed[i].file = pathPrefix + "tests/" + failed[i].file
}
w := os.Stderr
fmt.Fprintln(w)
if len(failed) > 0 {
for i, f := range failed {
fmt.Fprintln(w, red(formatTestHeader(f, fmt.Sprintf(" %d) ", i+1))))
printFailureErrors(w, f, e2eDir, pathPrefix)
}
fmt.Fprintln(w, red(fmt.Sprintf(" %d failed", len(failed))))
for _, f := range failed {
fmt.Fprintln(w, red(formatTestHeader(f, " ")))
}
}
if report.Stats.Flaky > 0 {
fmt.Fprintln(w, yellow(fmt.Sprintf(" %d flaky", report.Stats.Flaky)))
}
if report.Stats.Skipped > 0 {
fmt.Fprintln(w, yellow(fmt.Sprintf(" %d skipped", report.Stats.Skipped)))
}
if report.Stats.Expected > 0 {
fmt.Fprintf(w, " %s%s\n",
green(fmt.Sprintf("%d passed", report.Stats.Expected)),
dim(fmt.Sprintf(" (%s)", formatDuration(report.Stats.Duration))))
}
showReportCmd := "pnpm show-report"
if pathPrefix != "" {
showReportCmd = fmt.Sprintf("(cd %s && pnpm show-report)", pathPrefix[:len(pathPrefix)-1])
}
fmt.Fprintf(w, "\n To open last HTML report run:\n\n %s\n\n", cyan(showReportCmd))
}
func printFailureErrors(w io.Writer, f pwFailure, e2eDir, pathPrefix string) {
for _, result := range f.results {
if result.Retry > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, dim(separator(fmt.Sprintf(" Retry #%d", result.Retry))))
}
for _, e := range result.Errors {
fmt.Fprintln(w)
if e.Message != "" {
for _, line := range strings.Split(e.Message, "\n") {
fmt.Fprintf(w, " %s\n", line)
}
}
if e.Snippet != "" {
fmt.Fprintln(w)
for _, line := range strings.Split(e.Snippet, "\n") {
fmt.Fprintf(w, " %s\n", line)
}
}
if e.Stack != "" {
fmt.Fprintln(w)
for _, line := range strings.Split(e.Stack, "\n") {
fmt.Fprintf(w, " %s\n", dim(line))
}
}
}
attachNum := 0
var errorContextPath string
for _, a := range result.Attachments {
if strings.HasSuffix(a.Path, "error-context.md") {
errorContextPath = a.Path
continue
}
if a.Path == "" {
continue
}
attachNum++
fmt.Fprintln(w)
fmt.Fprintln(w, dim(separator(fmt.Sprintf(" attachment #%d: %s (%s)", attachNum, a.Name, a.ContentType))))
if rel, err := filepath.Rel(e2eDir, a.Path); err == nil {
fmt.Fprintf(w, " %s\n", dim(pathPrefix+rel))
} else {
fmt.Fprintf(w, " %s\n", dim(a.Path))
}
}
if attachNum > 0 {
fmt.Fprintln(w, dim(separator(" ")))
}
if errorContextPath != "" {
fmt.Fprintln(w)
if rel, err := filepath.Rel(e2eDir, errorContextPath); err == nil {
fmt.Fprintf(w, " %s\n", dim("Error Context: "+pathPrefix+rel))
} else {
fmt.Fprintf(w, " %s\n", dim("Error Context: "+errorContextPath))
}
}
}
fmt.Fprintln(w)
}
type pwReport struct {
Suites []pwSuite `json:"suites"`
Stats pwStats `json:"stats"`
}
type pwStats struct {
Duration float64 `json:"duration"`
Expected int `json:"expected"`
Unexpected int `json:"unexpected"`
Flaky int `json:"flaky"`
Skipped int `json:"skipped"`
}
type pwSuite struct {
Title string `json:"title"`
File string `json:"file"`
Suites []pwSuite `json:"suites"`
Specs []pwSpec `json:"specs"`
}
type pwSpec struct {
Title string `json:"title"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
Tests []pwTest `json:"tests"`
}
type pwTest struct {
ProjectName string `json:"projectName"`
Status string `json:"status"`
Results []pwResult `json:"results"`
}
type pwResult struct {
Status string `json:"status"`
Retry int `json:"retry"`
Errors []pwError `json:"errors"`
Attachments []pwAttachment `json:"attachments"`
}
type pwAttachment struct {
Name string `json:"name"`
ContentType string `json:"contentType"`
Path string `json:"path"`
}
type pwError struct {
Message string `json:"message"`
Snippet string `json:"snippet"`
Stack string `json:"stack"`
}
type pwFailure struct {
title string
file string
line int
column int
projectName string
results []pwResult
}
func collectFailures(suites []pwSuite) []pwFailure {
var failures []pwFailure
for _, suite := range suites {
for _, spec := range suite.Specs {
for _, test := range spec.Tests {
if test.Status == "unexpected" {
failures = append(failures, pwFailure{
title: spec.Title,
file: spec.File,
line: spec.Line,
column: spec.Column,
projectName: test.ProjectName,
results: test.Results,
})
}
}
}
failures = append(failures, collectFailures(suite.Suites)...)
}
return failures
}
func formatTestHeader(f pwFailure, indent string) string {
project := ""
if f.projectName != "" {
project = "[" + f.projectName + "] › "
}
location := fmt.Sprintf("%s:%d:%d", f.file, f.line, f.column)
title := fmt.Sprintf("%s%s%s › %s", indent, project, location, f.title)
return separator(title)
}
func formatDuration(ms float64) string {
if ms < 1000 {
return fmt.Sprintf("%dms", int(ms))
}
seconds := ms / 1000
if seconds < 60 {
return fmt.Sprintf("%.1fs", seconds)
}
minutes := int(seconds) / 60
remaining := int(seconds) % 60
return fmt.Sprintf("%dm %ds", minutes, remaining)
}
func separator(text string) string {
if text != "" {
text += " "
}
const columns = 100
padding := columns - visibleLen(text)
if padding < 0 {
padding = 0
}
return text + dim(strings.Repeat("─", padding))
}
func visibleLen(s string) int {
n := 0
inEsc := false
for _, r := range s {
if r == '\033' {
inEsc = true
continue
}
if inEsc {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
inEsc = false
}
continue
}
n++
}
return n
}
func red(s string) string {
return "\033[31m" + s + "\033[39m"
}
func green(s string) string {
return "\033[32m" + s + "\033[39m"
}
func yellow(s string) string {
return "\033[33m" + s + "\033[39m"
}
func cyan(s string) string {
return "\033[36m" + s + "\033[39m"
}
func dim(s string) string {
return "\033[2m" + s + "\033[22m"
}
+45 -29
View File
@@ -23,7 +23,9 @@ import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -34,10 +36,12 @@ import (
)
type teleportInstance struct {
config *e2eConfig
configPath string
stateFile string
logFile string // empty means stdout/stderr
log *slog.Logger
teleportBin string
proxyPort int
configPath string
stateFile string
logFile string // empty means stdout/stderr
cmd *exec.Cmd
logF *os.File
@@ -46,7 +50,7 @@ type teleportInstance struct {
}
func (t *teleportInstance) start(ctx context.Context) error {
t.cmd = exec.CommandContext(ctx, t.config.teleportBin, "start", "-c", t.configPath, "--bootstrap", t.stateFile)
t.cmd = exec.CommandContext(ctx, t.teleportBin, "start", "-c", t.configPath, "--bootstrap", t.stateFile)
if t.logFile != "" {
f, err := os.Create(t.logFile)
@@ -63,7 +67,7 @@ func (t *teleportInstance) start(ctx context.Context) error {
t.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
slog.Info("starting teleport with bootstrap state")
t.log.Info("starting teleport with bootstrap state")
if err := t.cmd.Start(); err != nil {
return fmt.Errorf("starting teleport: %w", err)
}
@@ -79,7 +83,7 @@ func (t *teleportInstance) start(ctx context.Context) error {
// waitReady polls the proxy's /webapi/ping endpoint until it responds with 200.
func (t *teleportInstance) waitReady(ctx context.Context, timeout time.Duration) error {
slog.Debug("waiting for teleport to be ready")
t.log.Debug("waiting for teleport to be ready")
client := &http.Client{
Timeout: 2 * time.Second,
@@ -96,7 +100,7 @@ func (t *teleportInstance) waitReady(ctx context.Context, timeout time.Duration)
default:
}
resp, err := client.Get(fmt.Sprintf("https://localhost:%d/webapi/ping", t.config.proxyPort))
resp, err := client.Get(fmt.Sprintf("https://localhost:%d/webapi/ping", t.proxyPort))
if err != nil {
return false, nil
}
@@ -110,7 +114,7 @@ func (t *teleportInstance) waitReady(ctx context.Context, timeout time.Duration)
return fmt.Errorf("teleport failed to become ready: %w", err)
}
slog.Info("teleport is ready")
t.log.Info("teleport is ready")
return nil
}
@@ -120,7 +124,7 @@ func (t *teleportInstance) stop() {
return
}
slog.Info("stopping teleport")
t.log.Info("stopping teleport")
select {
case <-t.waitDone:
@@ -131,7 +135,7 @@ func (t *teleportInstance) stop() {
select {
case <-t.waitDone:
case <-time.After(5 * time.Second):
slog.Warn("teleport did not exit gracefully, sending SIGKILL")
t.log.Warn("teleport did not exit gracefully, sending SIGKILL")
_ = syscall.Kill(-t.cmd.Process.Pid, syscall.SIGKILL)
<-t.waitDone
}
@@ -152,32 +156,38 @@ type TeleportConfig struct {
LogLevel string
}
func generateTeleportConfig(templatePath string, config *e2eConfig) (string, error) {
teleportConfig := &TeleportConfig{
DataDir: config.dataDir,
AuthServerPort: config.authPort,
ProxyPort: config.proxyPort,
KeyFilePath: filepath.Join(config.certsDir, keyFileName),
CertFilePath: filepath.Join(config.certsDir, certFileName),
LicenseFile: config.licenseFile,
LogLevel: config.teleportLogLevel,
}
return renderTemplate(templatePath, teleportConfig)
func generateTeleportConfig(templatePath, outPath string, data *TeleportConfig) (string, error) {
return renderTemplateToPath(templatePath, outPath, data)
}
type TeleportNodeConfig struct {
AuthServerHost string
AuthServerPort int
SSHServerPort int
}
func generateTeleportNodeConfig(templatePath string, config *e2eConfig) (string, error) {
nodeConfig := &TeleportNodeConfig{
AuthServerPort: config.authPort,
SSHServerPort: config.sshPort,
func generateTeleportNodeConfig(templatePath, outPath string, data *TeleportNodeConfig) (string, error) {
return renderTemplateToPath(templatePath, outPath, data)
}
func resolveDockerHost() (string, error) {
dockerHost := os.Getenv("DOCKER_HOST")
if dockerHost == "" {
return "host.docker.internal", nil
}
return renderTemplate(templatePath, nodeConfig)
u, err := url.Parse(dockerHost)
if err != nil {
return "", fmt.Errorf("parsing DOCKER_HOST: %w", err)
}
conn, err := net.Dial("udp", u.Host)
if err != nil {
return "", fmt.Errorf("dialing docker host: %w", err)
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil
}
type StateConfig struct {
@@ -197,7 +207,13 @@ func generateStateFile(templatePath string, creds *credentials) (string, error)
}
func renderTemplate(templatePath string, data any) (string, error) {
outPath := strings.TrimSuffix(templatePath, ".tmpl")
return renderTemplateToPath(templatePath, strings.TrimSuffix(templatePath, ".tmpl"), data)
}
func renderTemplateToPath(templatePath, outPath string, data any) (string, error) {
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return "", err
}
tmpl, err := template.ParseFiles(templatePath)
if err != nil {
-7
View File
@@ -63,10 +63,3 @@ func resolveE2EDir() (string, error) {
e2eDir := filepath.Dir(filepath.Dir(exePath))
return e2eDir, nil
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 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/>.
*/
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
const green = (s: string) => `\x1b[32m${s}\x1b[39m`;
const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`;
const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;
/**
* Prints colored progress dots like the built-in dot reporter but skips
* the failure summary at the end. The Go runner prints its own summary
* from the merged JSON report.
*/
class DotProgressReporter implements Reporter {
private column = 0;
onTestEnd(test: TestCase, result: TestResult) {
let char: string;
switch (result.status) {
case 'passed':
char = test.outcome() === 'flaky' ? yellow('\u00b1') : green('\u00b7');
break;
case 'failed':
case 'timedOut':
char = red(result.status === 'timedOut' ? 'T' : 'F');
break;
case 'skipped':
char = yellow('\u00b0');
break;
case 'interrupted':
char = gray('\u00d7');
break;
default:
char = '?';
}
process.stdout.write(char);
this.column++;
if (this.column >= 80) {
process.stdout.write('\n');
this.column = 0;
}
}
onEnd() {
if (this.column > 0) {
process.stdout.write('\n');
}
}
}
export default DotProgressReporter;
+13 -4
View File
@@ -25,7 +25,12 @@
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, type BrowserContext } from '@playwright/test';
import {
chromium,
firefox,
webkit,
type BrowserContext,
} from '@playwright/test';
import { mockWebAuthn } from '../helpers/webauthn';
@@ -54,11 +59,15 @@ if (!mode || !startURL) {
}
const e2eDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const storageStatePath = join(e2eDir, '.auth/user.json');
const browserName = (process.env.E2E_BROWSERS || 'chromium').split(',')[0];
const browserTypes = { chromium, firefox, webkit };
const browserType =
browserTypes[browserName as keyof typeof browserTypes] ?? chromium;
const storageStatePath = join(e2eDir, `.auth/${browserName}-user.json`);
info(`launching Chromium ${dim(`(mode: ${mode})`)}`);
info(`launching ${browserName} ${dim(`(mode: ${mode})`)}`);
const browser = await chromium.launch({ headless: false });
const browser = await browserType.launch({ headless: false });
const context = await browser.newContext({
storageState: storageStatePath,
ignoreHTTPSErrors: true,
+1 -1
View File
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect } from '../../helpers/connect';
import { test, expect } from '@gravitational/e2e/helpers/connect';
test.use({ autoLogin: true });
+8 -8
View File
@@ -19,16 +19,16 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { login } from '../../helpers/login';
import { test as setup } from '../../helpers/test';
import { login } from '@gravitational/e2e/helpers/login';
import { test as setup } from '@gravitational/e2e/helpers/test';
const authFile = join(
dirname(fileURLToPath(import.meta.url)),
'../../.auth/user.json'
);
const authDir = join(dirname(fileURLToPath(import.meta.url)), '../../.auth');
setup('authenticate', async ({ page }) => {
setup('authenticate', async ({ page }, testInfo) => {
await login(page);
await page.context().storageState({ path: authFile });
const browser = testInfo.project.name.split(':')[0];
await page
.context()
.storageState({ path: join(authDir, `${browser}-user.json`) });
});
@@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { expect, test } from '../../../helpers/test';
import { mockWebAuthn } from '../../../helpers/webauthn';
import { expect, test } from '@gravitational/e2e/helpers/test';
import { mockWebAuthn } from '@gravitational/e2e/helpers/webauthn';
test('verify that a user can create and delete an auth connector', async ({
page,
+2 -2
View File
@@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { expect, test } from '../../../helpers/test';
import { mockWebAuthn } from '../../../helpers/webauthn';
import { expect, test } from '@gravitational/e2e/helpers/test';
import { mockWebAuthn } from '@gravitational/e2e/helpers/webauthn';
test('verify that a user can create and delete a role', async ({ page }) => {
await mockWebAuthn(page);
+10 -5
View File
@@ -16,17 +16,20 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { signup } from '../../../helpers/signup';
import { expect, test } from '../../../helpers/test';
import { signup } from '@gravitational/e2e/helpers/signup';
import { deleteUser } from '@gravitational/e2e/helpers/tctl';
import { expect, test } from '@gravitational/e2e/helpers/test';
test('verify that a user can sign up with webauthn and login', async ({
page,
}) => {
await signup(page);
}, testInfo) => {
const username = `testuser-${testInfo.workerIndex}`;
await signup(page, username);
await page.getByRole('button', { name: 'User Menu' }).click();
await page.getByText('Logout').click();
await page.getByRole('textbox', { name: 'Username' }).fill('testuser');
await page.getByRole('textbox', { name: 'Username' }).fill(username);
await page.getByRole('textbox', { name: 'Username' }).press('Tab');
await page.getByRole('textbox', { name: 'Password' }).fill('passwordtest123');
await page
@@ -35,4 +38,6 @@ test('verify that a user can sign up with webauthn and login', async ({
.click();
await expect(page.getByRole('heading', { name: 'Resources' })).toBeVisible();
deleteUser(username);
});
+2 -2
View File
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test } from '../../../helpers/test';
import { test } from '@gravitational/e2e/helpers/test';
test('verify that a user can SSH into a node', async ({
unifiedResourcesPage,
@@ -27,5 +27,5 @@ test('verify that a user can SSH into a node', async ({
await terminal.waitForReady();
await terminal.exec('ls /');
await terminal.expectSnapshot('ls-output.png');
await terminal.waitForText('bin');
});
+5 -1
View File
@@ -6,7 +6,11 @@
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
"skipLibCheck": true,
"paths": {
"@gravitational/e2e/helpers": ["./helpers"],
"@gravitational/e2e/helpers/*": ["./helpers/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
@@ -96,6 +96,7 @@ export default class TtyTerminal implements TerminalSearcher {
convertEol: this._convertEol,
cursorBlink: false,
minimumContrastRatio: 4.5, // minimum for WCAG AA compliance
screenReaderMode: true,
theme: this.options.theme,
allowProposedApi: true, // required for customizing SearchAddon properties
});