mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
+2
-2
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Generated
+129
-14
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user