diff --git a/.gitignore b/.gitignore index 07b9936295d..7b9a9530880 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ cypress-a11y-report.json /frontend/**/yarn-error.log /frontend/**/dist /frontend/@types/console/generated +/frontend/e2e/.auth/ +/frontend/e2e/.test-config.json +/frontend/test-results/ +/frontend/playwright-report/ /frontend/gui_test_screenshots /frontend/package-lock.json /frontend/po-files diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 9941413fc25..0e36cd165fd 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -12,3 +12,4 @@ Godeps dynamic-demo-plugin .eslintrc.js tsconfig.json +e2e/tsconfig.json diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index e71f4384d5b..f0cf1003c94 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -126,6 +126,18 @@ module.exports = { version: 'detect', }, }, + overrides: [ + { + files: ['e2e/**/*.ts'], + parserOptions: { + project: './e2e/tsconfig.json', + }, + rules: { + 'no-console': 'off', + 'no-empty-pattern': 'off', + }, + }, + ], globals: { process: 'readonly', React: true, diff --git a/frontend/e2e/.env.example b/frontend/e2e/.env.example new file mode 100644 index 00000000000..2d4204d24d4 --- /dev/null +++ b/frontend/e2e/.env.example @@ -0,0 +1,37 @@ +# OpenShift Console Playwright E2E Configuration +# Copy to .env and fill in values for your environment. + +# Console URL to test against (default: http://localhost:9000) +# Separate from BRIDGE_BASE_ADDRESS so you can be oc-login'd to a cluster +# while testing against localhost. +WEB_CONSOLE_URL=http://localhost:9000 + +# K8s API server URL (only needed if no kubeconfig is available) +# When you oc login, the kubeconfig already has the cluster URL. +# CLUSTER_URL=https://api.mycluster.example.com:6443 + +# Username for cluster authentication (default: kubeadmin) +# OPENSHIFT_USERNAME=kubeadmin + +# Required for browser login on remote clusters +BRIDGE_KUBEADMIN_PASSWORD= + +# Optional: htpasswd identity provider for developer user tests +# BRIDGE_HTPASSWD_IDP=my_htpasswd_provider +# BRIDGE_HTPASSWD_USERNAME=testuser +# BRIDGE_HTPASSWD_PASSWORD= + +# Optional: path to kubeconfig (defaults to ~/.kube/config) +# KUBECONFIG= + +# Optional: number of parallel workers (default: auto locally, 1 in CI) +# WORKERS=4 + +# Optional: skip global setup (reuse existing .auth/ files) +# SKIP_GLOBAL_SETUP=true + +# Optional: skip resource cleanup after tests +# SKIP_TEST_CLEANUP=true + +# Optional: debug mode (skip cleanup, keep artifacts, enable video) +# DEBUG=1 diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts new file mode 100644 index 00000000000..a9255d43cf4 --- /dev/null +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -0,0 +1,474 @@ +import * as fs from 'fs'; +import * as https from 'https'; +import * as net from 'net'; +import * as path from 'path'; +import { URL } from 'url'; + +import * as k8s from '@kubernetes/client-node'; + +export interface ClusterAuthConfig { + clusterUrl: string; + username: string; + password: string; + token?: string; +} + +function isNotFound(err: unknown): boolean { + if (typeof err === 'object' && err !== null) { + const statusCode = (err as any).statusCode ?? (err as any).response?.statusCode; + if (statusCode === 404) { + return true; + } + const msg = err instanceof Error ? err.message : String(err); + return msg.includes('404') || msg.includes('not found'); + } + return false; +} + +async function pollUntil( + condition: () => Promise, + timeoutMs: number, + intervalMs = 1_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) { + return true; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +} + +export default class KubernetesClient { + private readonly k8sApi: k8s.CoreV1Api; + private readonly appsApi: k8s.AppsV1Api; + private readonly coApi: k8s.CustomObjectsApi; + private readonly kubeConfig: k8s.KubeConfig; + + private static getProxyUrl(): string | undefined { + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); + } + + private static createProxyAgent(proxyUrl: string): https.Agent { + const proxy = new URL(proxyUrl); + return new https.Agent({ + rejectUnauthorized: false, + createConnection: (options, callback) => { + const proxySocket = net.connect( + { host: proxy.hostname, port: parseInt(proxy.port || '3128', 10) }, + () => { + proxySocket.write( + [ + `CONNECT ${options.host}:${options.port} HTTP/1.1`, + `Host: ${options.host}:${options.port}`, + 'Connection: keep-alive', + '', + '', + ].join('\r\n'), + ); + }, + ); + let responseData = ''; + const onData = (chunk: Buffer) => { + responseData += chunk.toString(); + if (responseData.includes('\r\n\r\n')) { + proxySocket.removeListener('data', onData); + const [statusLine] = responseData.split('\r\n'); + const statusCode = parseInt(statusLine.split(' ')[1], 10); + if (statusCode === 200) { + callback(null, proxySocket); + } else { + proxySocket.destroy(); + callback(new Error(`Proxy CONNECT failed: ${statusCode}`) as any, null as any); + } + } + }; + proxySocket.on('data', onData); + proxySocket.on('error', (err) => { + callback(err as any, null as any); + }); + }, + } as https.AgentOptions); + } + + static async getOAuthToken( + clusterUrl: string, + username: string, + password: string, + ): Promise { + const oauthServerUrl = await KubernetesClient.getOAuthServerUrl(clusterUrl); + return new Promise((resolve, reject) => { + const authHeader = Buffer.from(`${username}:${password}`).toString('base64'); + const tokenUrl = new URL('/oauth/authorize', oauthServerUrl); + tokenUrl.searchParams.set('response_type', 'token'); + tokenUrl.searchParams.set('client_id', 'openshift-challenging-client'); + const proxyUrl = KubernetesClient.getProxyUrl(); + const agent = proxyUrl ? KubernetesClient.createProxyAgent(proxyUrl) : undefined; + const options: https.RequestOptions = { + hostname: tokenUrl.hostname, + port: tokenUrl.port || 443, + path: tokenUrl.pathname + tokenUrl.search, + method: 'GET', + headers: { Authorization: `Basic ${authHeader}`, 'X-CSRF-Token': '1' }, + rejectUnauthorized: false, + agent, + }; + const req = https.request(options, (res) => { + const location = res.headers.location; + if (location && location.includes('access_token=')) { + const match = location.match(/access_token=([^&]+)/); + if (match) { + resolve(match[1]); + return; + } + } + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + reject( + new Error( + `OAuth authentication failed: HTTP ${res.statusCode}. Response: ${body.substring( + 0, + 200, + )}`, + ), + ); + }); + }); + req.on('error', (err) => reject(new Error(`OAuth request failed: ${err.message}`))); + req.end(); + }); + } + + private static async getOAuthServerUrl(clusterUrl: string): Promise { + return new Promise((resolve) => { + const url = new URL('/.well-known/oauth-authorization-server', clusterUrl); + const proxyUrl = KubernetesClient.getProxyUrl(); + const agent = proxyUrl ? KubernetesClient.createProxyAgent(proxyUrl) : undefined; + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'GET', + rejectUnauthorized: false, + agent, + }; + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(body).issuer || clusterUrl); + } catch { + resolve(clusterUrl); + } + }); + }); + req.on('error', () => resolve(clusterUrl)); + req.end(); + }); + } + + static async generateKubeconfig( + clusterUrl: string, + username: string, + password: string, + outputPath: string, + ): Promise { + const token = await KubernetesClient.getOAuthToken(clusterUrl, username, password); + const kubeconfigYaml = [ + 'apiVersion: v1', + 'kind: Config', + 'clusters:', + ' - name: cluster', + ' cluster:', + ` server: ${clusterUrl}`, + ' insecure-skip-tls-verify: true', + 'contexts:', + ' - name: context', + ' context:', + ' cluster: cluster', + ' user: user', + 'current-context: context', + 'users:', + ' - name: user', + ' user:', + ` token: ${token}`, + '', + ].join('\n'); + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(outputPath, kubeconfigYaml, { encoding: 'utf8', mode: 0o600 }); + return outputPath; + } + + constructor(config: ClusterAuthConfig, kubeConfigPath?: string) { + this.kubeConfig = new k8s.KubeConfig(); + const effectivePath = kubeConfigPath || this.tryDiscoverKubeConfig(); + if (effectivePath && fs.existsSync(effectivePath)) { + this.kubeConfig.loadFromFile(effectivePath); + } else { + // Try default kubeconfig (~/.kube/config from oc login), then token fallback + try { + this.kubeConfig.loadFromDefault(); + } catch { + if (config.token && config.clusterUrl) { + this.kubeConfig.loadFromOptions({ + clusters: [{ name: 'cluster', server: config.clusterUrl, skipTLSVerify: true }], + contexts: [{ cluster: 'cluster', name: 'context', user: 'user' }], + currentContext: 'context', + users: [{ name: 'user', token: config.token }], + }); + } else { + throw new Error( + 'No kubeconfig found and no token/clusterUrl provided. Run "oc login" or set KUBECONFIG.', + ); + } + } + } + + const proxyUrl = KubernetesClient.getProxyUrl(); + if (proxyUrl && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + + this.k8sApi = this.kubeConfig.makeApiClient(k8s.CoreV1Api); + this.coApi = this.kubeConfig.makeApiClient(k8s.CustomObjectsApi); + this.appsApi = this.kubeConfig.makeApiClient(k8s.AppsV1Api); + } + + private tryDiscoverKubeConfig(): string | undefined { + const kubeConfigDir = path.join(process.cwd(), '.kubeconfigs'); + const configPath = path.join(kubeConfigDir, 'test-config'); + if (fs.existsSync(configPath)) { + return configPath; + } + return undefined; + } + + get kc(): k8s.KubeConfig { + return this.kubeConfig; + } + get coreV1Api(): k8s.CoreV1Api { + return this.k8sApi; + } + get customObjectsApi(): k8s.CustomObjectsApi { + return this.coApi; + } + get appsV1Api(): k8s.AppsV1Api { + return this.appsApi; + } + + getCurrentUserToken(): string | undefined { + try { + return this.kubeConfig.getCurrentUser()?.token; + } catch { + return undefined; + } + } + + async verifyAuthentication(): Promise { + await this.k8sApi.listNamespace({ limit: 1 }); + return true; + } + + async createNamespace(name: string, labels?: Record): Promise { + try { + await this.k8sApi.readNamespace({ name }); + return; // already exists + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + await this.k8sApi.createNamespace({ + body: { + metadata: { name, labels: { ...labels, 'openshift.io/run-level': '0' } }, + }, + }); + } + + async deleteNamespace(name: string): Promise { + try { + await this.k8sApi.deleteNamespace({ name }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async waitForNamespaceReady(name: string, timeoutMs = 30_000): Promise { + return pollUntil( + async () => { + try { + const ns = await this.k8sApi.readNamespace({ name }); + return ns?.status?.phase === 'Active'; + } catch { + return false; + } + }, + timeoutMs, + 1_000, + ); + } + + async waitForNamespaceDeleted(name: string, timeoutMs = 120_000): Promise { + return pollUntil( + async () => { + try { + await this.k8sApi.readNamespace({ name }); + return false; // still exists + } catch (err) { + if (isNotFound(err)) { + return true; + } // gone + throw err; // unexpected error — don't silently swallow + } + }, + timeoutMs, + 2_000, + ); + } + + async setupConsoleUserSettings(username = 'kubeadmin', defaultNamespace?: string): Promise { + const namespace = 'openshift-console-user-settings'; + const configMapName = `user-settings-${username}`; + const patchData: Record = { + 'console.guidedTour': JSON.stringify({ + admin: { completed: true }, + dev: { completed: true }, + }), + }; + if (defaultNamespace) { + patchData['console.lastNamespace'] = defaultNamespace; + } + try { + await this.patchConfigMap(configMapName, namespace, patchData); + } catch { + // ConfigMap may not exist yet — that's fine, tour will be dismissed in browser + } + } + + async patchConfigMap( + name: string, + namespace: string, + patchData: Record, + ): Promise { + const existing = await this.k8sApi.readNamespacedConfigMap({ name, namespace }); + const existingData = (existing as any)?.data || {}; + const mergedData = { ...existingData, ...patchData }; + await this.k8sApi.patchNamespacedConfigMap({ + name, + namespace, + body: { data: mergedData }, + contentType: k8s.PatchStrategy.MergePatch, + } as any); + } + + async deleteConfigMap(name: string, namespace: string): Promise { + try { + await this.k8sApi.deleteNamespacedConfigMap({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async deleteSecret(name: string, namespace: string): Promise { + try { + await this.k8sApi.deleteNamespacedSecret({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async createCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + body: Record, + ): Promise { + const response = await this.coApi.createNamespacedCustomObject({ + body, + group, + namespace, + plural, + version, + }); + return response; + } + + async deleteCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + ): Promise { + try { + await this.coApi.deleteNamespacedCustomObject({ group, name, namespace, plural, version }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async getCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + ): Promise { + const response = await this.coApi.getNamespacedCustomObject({ + group, + name, + namespace, + plural, + version, + }); + return response; + } + + async listCustomResources( + group: string, + version: string, + namespace: string, + plural: string, + ): Promise { + try { + const response = await this.coApi.listNamespacedCustomObject({ + group, + namespace, + plural, + version, + }); + return (response as any)?.items || []; + } catch { + return []; + } + } + + async getPods(namespace: string): Promise { + const response = await this.k8sApi.listNamespacedPod({ namespace }); + return response.items || []; + } +} diff --git a/frontend/e2e/fixtures/cleanup-fixture.ts b/frontend/e2e/fixtures/cleanup-fixture.ts new file mode 100644 index 00000000000..e7b9fb0f463 --- /dev/null +++ b/frontend/e2e/fixtures/cleanup-fixture.ts @@ -0,0 +1,174 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import KubernetesClient from '../clients/kubernetes-client'; + +export interface TrackedResource { + name: string; + namespace?: string; + apiGroup: string; + apiVersion: string; + plural: string; + type: string; +} + +export interface CleanupFixture { + track(resource: TrackedResource): void; + trackNamespace(name: string): void; + trackCustomResource( + name: string, + namespace: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ): void; + readonly count: number; + executeCleanup(): Promise; + shouldSkipCleanup(): boolean; +} + +export function createCleanupFixture(testName: string): CleanupFixture { + const resources: TrackedResource[] = []; + const skipCleanup = + process.env.SKIP_TEST_CLEANUP === 'true' || + process.env.DEBUG === '1' || + process.env.DEBUG === 'true'; + + function getClient(): KubernetesClient | null { + try { + const configPath = path.resolve(__dirname, '..', '.test-config.json'); + let kubeConfigPath: string | undefined; + let authToken: string | undefined; + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + kubeConfigPath = config.kubeConfigPath; + authToken = config.authToken; + } + return new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + token: authToken, + }, + kubeConfigPath, + ); + } catch (err) { + console.warn( + `[Cleanup] Failed to create K8s client: ${err instanceof Error ? err.message : err}`, + ); + return null; + } + } + + return { + track(resource: TrackedResource) { + resources.push(resource); + }, + + trackNamespace(name: string) { + resources.push({ + name, + apiGroup: '', + apiVersion: 'v1', + plural: 'namespaces', + type: 'Namespace', + }); + }, + + trackCustomResource( + name: string, + namespace: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ) { + resources.push({ + name, + namespace, + apiGroup, + apiVersion, + plural, + type: type || plural, + }); + }, + + get count() { + return resources.length; + }, + + shouldSkipCleanup() { + return skipCleanup; + }, + + async executeCleanup() { + if (skipCleanup || resources.length === 0) { + return; + } + + const client = getClient(); + if (!client) { + console.warn(`[Cleanup] No K8s client available for "${testName}"`); + return; + } + + const namespaces = resources.filter((r) => r.type === 'Namespace'); + const others = resources.filter((r) => r.type !== 'Namespace'); + + // Delete non-namespace resources first + for (const resource of others) { + try { + if (resource.apiGroup === '' && resource.namespace) { + switch (resource.type) { + case 'ConfigMap': + await client.deleteConfigMap(resource.name, resource.namespace); + break; + case 'Secret': + await client.deleteSecret(resource.name, resource.namespace); + break; + default: + console.warn( + `[Cleanup] Unhandled core resource type ${resource.type} "${resource.name}" — skipping`, + ); + break; + } + } else if (resource.namespace) { + await client.deleteCustomResource( + resource.apiGroup, + resource.apiVersion, + resource.namespace, + resource.plural, + resource.name, + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('404') && !msg.includes('not found')) { + console.warn(`[Cleanup] Failed to delete ${resource.type} ${resource.name}: ${msg}`); + } + } + } + + // Then delete namespaces + for (const ns of namespaces) { + try { + await client.deleteNamespace(ns.name); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('404') && !msg.includes('not found')) { + console.warn(`[Cleanup] Failed to delete namespace ${ns.name}: ${msg}`); + } + } + } + + // Wait for namespaces to terminate + for (const ns of namespaces) { + await client.waitForNamespaceDeleted(ns.name, 60_000).catch(() => { + console.warn(`[Cleanup] Namespace ${ns.name} did not terminate within timeout`); + }); + } + }, + }; +} diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts new file mode 100644 index 00000000000..f57b43de4ea --- /dev/null +++ b/frontend/e2e/fixtures/index.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { test as base, expect } from '@playwright/test'; + +import KubernetesClient from '../clients/kubernetes-client'; + +import type { CleanupFixture } from './cleanup-fixture'; +import { createCleanupFixture } from './cleanup-fixture'; + +export interface SharedTestConfig { + testNamespace: string; + authToken?: string; + kubeConfigPath?: string; +} + +type TestFixtures = { + cleanup: CleanupFixture; +}; + +type WorkerFixtures = { + testConfig: SharedTestConfig; + k8sClient: KubernetesClient; +}; + +export const test = base.extend({ + testConfig: [ + async ({}, use) => { + const configPath = path.resolve(__dirname, '..', '.test-config.json'); + let config: SharedTestConfig = { + testNamespace: 'default', + }; + if (fs.existsSync(configPath)) { + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // fall through with defaults + } + } + await use(config); + }, + { scope: 'worker' }, + ], + + k8sClient: [ + async ({ testConfig }, use) => { + const client = new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + token: testConfig.authToken, + }, + testConfig.kubeConfigPath, + ); + await use(client); + }, + { scope: 'worker' }, + ], + + cleanup: async ({}, use, testInfo) => { + const testName = testInfo.titlePath.join(' > '); + const fixture = createCleanupFixture(testName); + try { + await use(fixture); + } finally { + if (!fixture.shouldSkipCleanup() && fixture.count > 0) { + try { + await fixture.executeCleanup(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Cleanup] Failed for "${testName}": ${msg}`); + } + } + } + }, +}); + +export { expect }; +export type { CleanupFixture }; diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts new file mode 100644 index 00000000000..760a165aa6f --- /dev/null +++ b/frontend/e2e/global.setup.ts @@ -0,0 +1,168 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { FullConfig } from '@playwright/test'; +import { chromium } from '@playwright/test'; + +import KubernetesClient from './clients/kubernetes-client'; + +const STORAGE_STATE_DIR = path.resolve(__dirname, '.auth'); +const CONFIG_FILE = path.resolve(__dirname, '.test-config.json'); + +interface LoginOptions { + baseURL: string; + provider: string; + username: string; + password: string; + storageStatePath: string; + config: FullConfig; +} + +async function performBrowserLogin(opts: LoginOptions): Promise { + const browser = await chromium.launch({ + args: ['--ignore-certificate-errors', '--disable-dev-shm-usage', '--no-sandbox'], + }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + try { + await page.goto(opts.baseURL, { timeout: 90_000, waitUntil: 'domcontentloaded' }); + + // Check if auth is disabled (local dev) + const authDisabled = await page + .evaluate(() => (window as any).SERVER_FLAGS?.authDisabled) + .catch(() => false); + + if (authDisabled) { + console.log(`✅ Auth disabled — saving storage state for ${opts.provider}`); + await page.context().storageState({ path: opts.storageStatePath }); + fs.chmodSync(opts.storageStatePath, 0o600); + return; + } + + // Wait for login page + await page.waitForSelector('[data-test-id="login"], #inputUsername', { timeout: 30_000 }); + + // Click provider button if visible + const providerButton = page.getByText(opts.provider, { exact: true }); + if ((await providerButton.count()) > 0) { + await providerButton.click(); + } + + // Fill credentials and submit + await page.locator('#inputUsername').fill(opts.username); + await page.locator('#inputPassword').fill(opts.password); + await page.locator('button[type="submit"]').click(); + + // Wait for console to load (user dropdown indicates successful login) + await page.waitForSelector('[data-test="user-dropdown-toggle"]', { timeout: 60_000 }); + console.log(`✅ Logged in as ${opts.username}`); + + // Save storage state + await page.context().storageState({ path: opts.storageStatePath }); + fs.chmodSync(opts.storageStatePath, 0o600); + } finally { + await browser.close(); + } +} + +async function globalSetup(config: FullConfig) { + if (process.env.SKIP_GLOBAL_SETUP === 'true') { + console.log('⏭️ Skipping global setup (SKIP_GLOBAL_SETUP=true)'); + return; + } + + // Remove stale config from previous runs + if (fs.existsSync(CONFIG_FILE)) { + fs.unlinkSync(CONFIG_FILE); + } + + console.log('🚀 Setting up test environment...'); + + const baseURL = + process.env.WEB_CONSOLE_URL || + config.projects.find((p) => p.name !== 'auth-setup')?.use?.baseURL || + 'http://localhost:9000'; + const username = process.env.OPENSHIFT_USERNAME || 'kubeadmin'; + const password = process.env.BRIDGE_KUBEADMIN_PASSWORD || ''; + const testNamespace = `console-e2e-${Date.now()}`; + + // --- Phase 1: Auth --- + let k8sClient: KubernetesClient | null = null; + let clusterAvailable = false; + + try { + k8sClient = new KubernetesClient({ + clusterUrl: process.env.CLUSTER_URL || '', + username, + password, + }); + await k8sClient.verifyAuthentication(); + clusterAvailable = true; + console.log('✅ Cluster authentication verified'); + } catch (err) { + console.log('⚠️ No cluster access — running in local no-auth mode'); + k8sClient = null; + } + + // --- Phase 2: Cluster setup --- + if (clusterAvailable && k8sClient) { + try { + await k8sClient.createNamespace(testNamespace); + await k8sClient.waitForNamespaceReady(testNamespace); + console.log(`✅ Test namespace created: ${testNamespace}`); + } catch (err) { + console.warn(`⚠️ Failed to create test namespace: ${err}`); + } + + try { + await k8sClient.setupConsoleUserSettings(username, testNamespace); + console.log('✅ Console user settings configured (guided tour dismissed)'); + } catch { + // Non-critical — tour will be dismissed in browser if needed + } + + // Save config for worker fixtures + const testConfig = { + testNamespace, + authToken: k8sClient.getCurrentUserToken(), + kubeConfigPath: process.env.KUBECONFIG, + }; + fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true, mode: 0o700 }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(testConfig, null, 2), { + encoding: 'utf8', + mode: 0o600, + }); + } + + // --- Phase 3: Browser login --- + fs.mkdirSync(STORAGE_STATE_DIR, { recursive: true, mode: 0o700 }); + + await performBrowserLogin({ + baseURL, + provider: 'kube:admin', + username, + password, + storageStatePath: path.join(STORAGE_STATE_DIR, 'kubeadmin.json'), + config, + }); + + // Optional: htpasswd user login + const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME; + const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD; + if (htpasswdUser && htpasswdPass) { + const htpasswdIdp = process.env.BRIDGE_HTPASSWD_IDP || htpasswdUser; + await performBrowserLogin({ + baseURL, + provider: htpasswdIdp, + username: htpasswdUser, + password: htpasswdPass, + storageStatePath: path.join(STORAGE_STATE_DIR, 'developer.json'), + config, + }); + } + + console.log('🏁 Global setup complete'); +} + +export default globalSetup; diff --git a/frontend/e2e/global.teardown.ts b/frontend/e2e/global.teardown.ts new file mode 100644 index 00000000000..a0a414d7bde --- /dev/null +++ b/frontend/e2e/global.teardown.ts @@ -0,0 +1,62 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import KubernetesClient from './clients/kubernetes-client'; + +const CONFIG_FILE = path.resolve(__dirname, '.test-config.json'); + +async function globalTeardown() { + if (process.env.DEBUG === '1' || process.env.DEBUG === 'true') { + console.log('🐛 Debug mode — skipping teardown'); + return; + } + + console.log('🧹 Cleaning up test environment...'); + + // Read test config to get namespace + let testNamespace: string | undefined; + let kubeConfigPath: string | undefined; + let authToken: string | undefined; + + if (fs.existsSync(CONFIG_FILE)) { + try { + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); + testNamespace = config.testNamespace; + kubeConfigPath = config.kubeConfigPath; + authToken = config.authToken; + } catch { + // config file corrupted — skip cleanup + } + } + + if (!testNamespace) { + console.log('No test namespace to clean up'); + return; + } + + try { + const client = new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + token: authToken, + }, + kubeConfigPath, + ); + + await client.deleteNamespace(testNamespace); + const deleted = await client.waitForNamespaceDeleted(testNamespace, 120_000); + if (deleted) { + console.log(`✅ Namespace ${testNamespace} deleted`); + } else { + console.warn(`⚠️ Namespace ${testNamespace} still terminating after 120s`); + } + } catch (err) { + console.warn(`⚠️ Failed to clean up namespace ${testNamespace}: ${err}`); + } + + console.log('🏁 Teardown complete'); +} + +export default globalTeardown; diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts new file mode 100644 index 00000000000..1e575eccbc8 --- /dev/null +++ b/frontend/e2e/pages/base-page.ts @@ -0,0 +1,97 @@ +import type { Locator, Page } from '@playwright/test'; + +export default abstract class BasePage { + constructor(public readonly page: Page) {} + + private readonly loadingIndicators = [ + '.pf-v6-c-spinner', + '.pf-v5-c-spinner', + '.pf-c-spinner', + '.co-m-loader', + '[data-test="loading-indicator"]', + '[data-test="loading-box"]', + '.loading-skeleton', + '.skeleton-catalog--grid', + '[class*="skeleton"]', + ]; + + protected async waitForLoadingComplete(timeoutMs = 5_000): Promise { + const loadingSelector = this.loadingIndicators.join(', '); + const loadingElements = this.page.locator(loadingSelector); + try { + const count = await loadingElements.count().catch(() => 0); + if (count > 0) { + await loadingElements.first().waitFor({ state: 'hidden', timeout: timeoutMs }); + } + } catch { + // Loading indicators may have already disappeared — continue + } + } + + protected async goTo(url: string): Promise { + await this.page.goto(url, { timeout: 90_000 }); + await this.waitForLoadingComplete(); + } + + protected locator( + selector: string, + options?: { + has?: Locator; + hasNot?: Locator; + hasNotText?: RegExp | string; + hasText?: RegExp | string; + }, + ): Locator { + return this.page.locator(selector, options); + } + + protected async robustClick( + locator: Locator, + options: { + timeout?: number; + retries?: number; + retryDelay?: number; + force?: boolean; + } = {}, + ): Promise { + const { timeout = 30_000, retries = 3, retryDelay = 1_000, force = false } = options; + let lastError: Error | null = null; + const attemptTimeout = timeout / retries; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await this.waitForLoadingComplete(Math.min(attemptTimeout / 4, 3_000)); + await locator.waitFor({ state: 'visible', timeout: attemptTimeout }); + await locator.scrollIntoViewIfNeeded({ timeout: attemptTimeout / 3 }); + + try { + await locator.click({ force, timeout: attemptTimeout }); + return; + } catch (clickError) { + const msg = clickError instanceof Error ? clickError.message : String(clickError); + if (attempt < retries && (msg.includes('intercept') || msg.includes('not visible'))) { + await locator.click({ force: true, timeout: attemptTimeout }); + return; + } + throw clickError; + } + } catch (error) { + lastError = error; + if (attempt < retries && retryDelay > 100) { + await this.page.waitForTimeout(retryDelay); + } + } + } + throw new Error(`robustClick failed after ${retries} attempts: ${lastError?.message}`); + } + + async navigateToTab(locator: Locator, timeoutMs = 60_000): Promise { + await this.robustClick(locator, { timeout: timeoutMs }); + await this.waitForLoadingComplete(); + } + + async clickButtonByText(buttonText: string): Promise { + const button = this.locator('button', { hasText: buttonText }); + await this.robustClick(button); + } +} diff --git a/frontend/e2e/tests/smoke/smoke-test.spec.ts b/frontend/e2e/tests/smoke/smoke-test.spec.ts new file mode 100644 index 00000000000..bcd3ff088ea --- /dev/null +++ b/frontend/e2e/tests/smoke/smoke-test.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '../../fixtures'; + +test('console loads after setup', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('body')).not.toBeEmpty(); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 00000000000..58af195ae2a --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "node", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "noUnusedLocals": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/frontend/package.json b/frontend/package.json index 8c41d94edc6..b7b93f0504f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,22 @@ "test-cypress-topology": "cd packages/topology/integration-tests && yarn run test-cypress", "test-cypress-topology-headless": "cd packages/topology/integration-tests && yarn run test-cypress-headless", "test-cypress-topology-nightly": "cd packages/topology/integration-tests && yarn run test-cypress-headless-all", + "test-playwright": "playwright test", + "test-playwright-headed": "playwright test --headed", + "test-playwright-debug": "playwright test --debug", + "test-playwright-ui": "playwright test --ui", + "test-playwright-smoke": "playwright test --project=smoke", + "test-playwright-console": "playwright test --project=console", + "test-playwright-dev-console": "playwright test --project=dev-console", + "test-playwright-helm": "playwright test --project=helm", + "test-playwright-knative": "playwright test --project=knative", + "test-playwright-olm": "playwright test --project=olm", + "test-playwright-shipwright": "playwright test --project=shipwright", + "test-playwright-telemetry": "playwright test --project=telemetry", + "test-playwright-topology": "playwright test --project=topology", + "test-playwright-webterminal": "playwright test --project=webterminal", + "test-playwright-admin": "playwright test --project=smoke --project=console --project=dev-console --project=helm --project=knative --project=olm --project=shipwright --project=telemetry --project=topology --project=webterminal", + "test-playwright-developer": "playwright test --project=dev-console-developer --project=shipwright-developer --project=topology-developer", "test-puppeteer-csp": "yarn ts-node ./test-puppeteer-csp.ts", "cypress-merge": "mochawesome-merge ./gui_test_screenshots/cypress_report*.json > ./gui_test_screenshots/cypress.json", "cypress-generate": "marge -o ./gui_test_screenshots/ -f cypress-report -t 'OpenShift Console Cypress Test Results' -p 'OpenShift Cypress Test Results' --showPassed false --assetsDir ./gui_test_screenshots/cypress/assets ./gui_test_screenshots/cypress.json", @@ -67,7 +83,7 @@ "export-pos": "./i18n-scripts/export-pos.sh", "memsource-upload": "./i18n-scripts/memsource-upload.sh", "memsource-download": "./i18n-scripts/memsource-download.sh", - "prepare-husky": "cd .. && husky install frontend/.husky" + "prepare-husky": "[ \"${OPENSHIFT_CI:-}\" = true ] || (cd .. && husky install frontend/.husky)" }, "jest": { "moduleFileExtensions": [ @@ -107,11 +123,13 @@ "testPathIgnorePatterns": [ "/node_modules/", "/public/dist", - "/.*/integration-tests" + "/.*/integration-tests", + "/e2e" ], "modulePathIgnorePatterns": [ "/public/dist", - "/.*/integration-tests" + "/.*/integration-tests", + "/e2e" ], "testRegex": ".*\\.spec\\.(ts|tsx|js|jsx)$", "testEnvironmentOptions": { @@ -230,6 +248,8 @@ "@graphql-codegen/typescript": "^1.15.1", "@graphql-codegen/typescript-graphql-files-modules": "^1.15.1", "@graphql-codegen/typescript-operations": "^1.15.1", + "@kubernetes/client-node": "^1.4.0", + "@playwright/test": "^1.59.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@swc/core": "^1.15.18", "@swc/jest": "^0.2.39", @@ -241,6 +261,7 @@ "@types/glob": "7.x", "@types/immutable": "3.x", "@types/jest": "^30.0.0", + "@types/js-yaml": "^3.12.7", "@types/json-schema": "^7.0.7", "@types/lodash-es": "^4.17.12", "@types/node": "22.x", @@ -263,6 +284,7 @@ "cypress-cucumber-preprocessor": "^4.3.1", "cypress-jest-adapter": "^0.1.1", "cypress-multi-reporters": "^2.0.5", + "dotenv": "^17.4.2", "esbuild-loader": "^4.4.2", "file-loader": "6.2.0", "find-up": "4.x", diff --git a/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts b/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts index 4fd1f44cab8..3e54bcb4b93 100644 --- a/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts +++ b/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts @@ -1,11 +1,12 @@ import { saveAs } from 'file-saver'; import { safeLoad } from 'js-yaml'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; export const downloadYaml = (data: BlobPart) => { const blob = new Blob([data], { type: 'text/yaml;charset=utf-8' }); let filename = 'k8s-object.yaml'; try { - const obj = safeLoad(data); + const obj = safeLoad(String(data)) as K8sResourceKind; if (obj.kind) { filename = `${obj.kind.toLowerCase()}-${obj.metadata.name}.yaml`; } diff --git a/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts b/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts index 1c160e09e21..f6f50186537 100644 --- a/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts +++ b/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts @@ -1,5 +1,5 @@ import { Map as ImmutableMap } from 'immutable'; -import YAML from 'js-yaml'; +import * as YAML from 'js-yaml'; import * as _ from 'lodash'; import { useTranslation } from 'react-i18next'; import { PodDisruptionBudgetModel } from '@console/app/src/models'; diff --git a/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx b/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx index ac67081b176..6703e1680db 100644 --- a/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx +++ b/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx @@ -46,7 +46,7 @@ const EditDeployment: FC = ({ heading, resource, namespace, const resourceType = getResourcesType(resource); if (values.editorType === EditorType.YAML) { try { - deploymentRes = safeLoad(values.yamlData); + deploymentRes = safeLoad(values.yamlData) as K8sResourceKind; if (!deploymentRes?.metadata?.namespace) { deploymentRes.metadata.namespace = namespace; } diff --git a/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx b/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx index a1de47c9a90..c529e02d6d2 100644 --- a/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx +++ b/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx @@ -52,7 +52,7 @@ const useHelmCharts: ExtensionHook = ({ .then(async (res) => { if (mounted) { const yaml = await res.text(); - const json = safeLoad(yaml); + const json = safeLoad(yaml) as { entries: HelmChartEntries }; setHelmCharts(json.entries); } }) diff --git a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx index 1453b045587..4e36f8c4d92 100644 --- a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx @@ -91,7 +91,7 @@ const CreateHelmChartRepository: FC = ({ if (values.editorType === EditorType.YAML) { try { - HelmChartRepositoryRes = safeLoad(values.yamlData); + HelmChartRepositoryRes = safeLoad(values.yamlData) as HelmChartRepositoryType; if ( HelmChartRepositoryRes && HelmChartRepositoryRes.kind === 'ProjectHelmChartRepository' && diff --git a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx index ef43ca4a71c..5b19c5caab8 100644 --- a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx @@ -125,7 +125,7 @@ const HelmChartVersionDropdown: FC = ({ try { const response = await coFetch(`/api/helm/charts/index.yaml?namespace=${namespace}`); const yaml = await response.text(); - json = safeLoad(yaml); + json = safeLoad(yaml) as { entries: HelmChartEntries }; } catch { if (ignore) return; } diff --git a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx index 60ad8b2477c..d98fd3ce704 100644 --- a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx @@ -167,7 +167,7 @@ const HelmInstallUpgradePage: FC = () => { } } else if (yamlData) { try { - valuesObj = safeLoad(yamlData); + valuesObj = safeLoad(yamlData) as any; } catch (err) { actions.setStatus({ submitError: t('helm-plugin~Invalid YAML - {{errorText}}', { errorText: err.toString() }), diff --git a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx index 81677aa01e8..116e4d7b9b7 100644 --- a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx @@ -136,7 +136,7 @@ const HelmURLChartInstallPage: FunctionComponent = () => { } } else if (yamlData) { try { - valuesObj = safeLoad(yamlData); + valuesObj = safeLoad(yamlData) as Record; } catch (err) { actions.setStatus({ submitError: t('helm-plugin~Invalid YAML - {{errorText}}', { errorText: err.toString() }), diff --git a/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx b/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx index 436ad405b67..db5536ef310 100644 --- a/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx +++ b/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx @@ -37,13 +37,13 @@ const AddBroker: FC = ({ namespace, selectedApplication }) => { const createResources = ( formValues: AddBrokerFormYamlValues, actions: FormikHelpers, - ): Promise => { + ): Promise => { let broker: K8sResourceKind; if (formValues.editorType === EditorType.Form) { broker = convertFormToBrokerYaml(formValues.formData); } else { try { - broker = safeLoad(formValues.yamlData); + broker = safeLoad(formValues.yamlData) as K8sResourceKind; if (!broker.metadata?.namespace) { broker.metadata.namespace = formValues.formData.project.name; } diff --git a/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts b/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts index 1ad4a387a77..a5ab4203e82 100644 --- a/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts +++ b/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts @@ -167,7 +167,7 @@ export const useDefaultChannelConfiguration = (namespace: string): [string, bool ); let defaultConfiguredChannel = EVENTING_IMC_KIND; if (configMap && defaultConfiguredChannelLoaded) { - const cfg = safeLoad(configMap.data?.['default-ch-config']); + const cfg = safeLoad(configMap.data?.['default-ch-config']) as Record; defaultConfiguredChannel = _.hasIn(cfg?.namespaceDefaults, namespace) ? cfg?.namespaceDefaults[namespace].kind diff --git a/frontend/packages/vsphere-plugin/src/components/persist.ts b/frontend/packages/vsphere-plugin/src/components/persist.ts index 790210ec652..428e0da3850 100644 --- a/frontend/packages/vsphere-plugin/src/components/persist.ts +++ b/frontend/packages/vsphere-plugin/src/components/persist.ts @@ -132,7 +132,7 @@ const updateYamlFormat = ( ): string => { let cmCfg: ProviderCM; try { - cmCfg = safeLoad(cloudProviderConfig.data.config); + cmCfg = safeLoad(cloudProviderConfig.data.config) as ProviderCM; } catch (e) { throw new PersistError( t('Failed to parse cloud provider config {{cm}}', { cm: cloudProviderConfig.metadata.name }), diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000000..8e074c1fed9 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,106 @@ +import * as path from 'path'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: path.resolve(__dirname, 'e2e', '.env') }); + +import { defineConfig, devices } from '@playwright/test'; + +const isCI = !!process.env.OPENSHIFT_CI || !!process.env.CI; +const isDebug = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; +const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; + +const adminStorageState = path.resolve(__dirname, 'e2e', '.auth', 'kubeadmin.json'); +const developerStorageState = path.resolve(__dirname, 'e2e', '.auth', 'developer.json'); +const hasDeveloper = !!process.env.BRIDGE_HTPASSWD_USERNAME; + +const packages = [ + 'smoke', + 'console', + 'dev-console', + 'helm', + 'knative', + 'olm', + 'shipwright', + 'telemetry', + 'topology', + 'webterminal', +]; + +// Packages that also have developer-persona tests +const devPackages = ['dev-console', 'shipwright', 'topology']; + +const chromeArgs = [ + '--ignore-certificate-errors', + '--start-maximized', + '--window-size=1920,1080', + '--disable-dev-shm-usage', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-background-networking', + '--disable-client-side-phishing-detection', + '--disable-default-apps', + '--disable-extensions', + '--disable-popup-blocking', + '--disable-sync', + '--disable-translate', + '--no-first-run', +]; + +export default defineConfig({ + globalSetup: path.resolve(__dirname, 'e2e', 'global.setup.ts'), + globalTeardown: path.resolve(__dirname, 'e2e', 'global.teardown.ts'), + testDir: './e2e/tests', + testMatch: '**/*.spec.ts', + forbidOnly: isCI, + retries: isCI ? 1 : 0, + timeout: 120_000, + reporter: isCI + ? [ + ['dot'], + ['junit', { outputFile: path.resolve(__dirname, 'test-results', 'junit-results.xml') }], + ] + : [['list']], + + expect: { + timeout: 40_000, + }, + + use: { + testIdAttribute: 'data-test', + baseURL, + actionTimeout: 60_000, + navigationTimeout: 90_000, + trace: isCI ? 'on-first-retry' : 'retain-on-failure', + screenshot: 'only-on-failure', + video: isDebug ? 'on' : 'retain-on-failure', + viewport: { width: 1920, height: 1080 }, + ignoreHTTPSErrors: true, + launchOptions: { + args: chromeArgs, + }, + }, + + workers: process.env.WORKERS ? parseInt(process.env.WORKERS, 10) : isCI ? 1 : undefined, + + projects: [ + ...packages.map((pkg) => ({ + name: pkg, + testDir: path.resolve(__dirname, 'e2e', 'tests', pkg), + testIgnore: '**/developer/**', + use: { + ...devices['Desktop Chrome'], + storageState: adminStorageState, + }, + })), + ...(hasDeveloper + ? devPackages.map((pkg) => ({ + name: `${pkg}-developer`, + testDir: path.resolve(__dirname, 'e2e', 'tests', pkg, 'developer'), + use: { + ...devices['Desktop Chrome'], + storageState: developerStorageState, + }, + })) + : []), + ], +}); diff --git a/frontend/public/components/edit-yaml.tsx b/frontend/public/components/edit-yaml.tsx index 11918bc469f..d3ca6a1668b 100644 --- a/frontend/public/components/edit-yaml.tsx +++ b/frontend/public/components/edit-yaml.tsx @@ -76,7 +76,7 @@ const generateObjToLoad = ( ) => { const sampleObj: K8sResourceKind = safeLoad( yaml ? yaml : getYAMLTemplates(templateExtensions).getIn([kind, id]), - ); + ) as K8sResourceKind; if (_.has(sampleObj.metadata, 'namespace')) { sampleObj.metadata.namespace = namespace; } @@ -336,7 +336,7 @@ const EditYAMLInner: FC = (props) => { const getResourceKindfromYAML = useCallback( (yaml) => { try { - const obj = safeLoad(yaml); + const obj = safeLoad(yaml) as Record; return getModel(obj)?.kind; } catch (e) { return 'unknown'; @@ -378,7 +378,7 @@ const EditYAMLInner: FC = (props) => { return false; } try { - safeLoad(str); + safeLoad(str) as Record; return true; } catch { return false; @@ -595,7 +595,7 @@ const EditYAMLInner: FC = (props) => { } try { - obj = safeLoad(editorMounted && getEditor()?.getValue()); + obj = safeLoad(editorMounted && getEditor()?.getValue()) as Record; } catch (e) { handleError(t('public~Error parsing YAML: {{e}}', { e })); return; diff --git a/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx b/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx index 4e3a596e0a8..2ec36059dca 100644 --- a/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx +++ b/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx @@ -44,7 +44,7 @@ export const getAlertmanagerConfig = ( ): { config: AlertmanagerConfig; errorMessage?: string } => { const parsedAlertmanagerYAML = getAlertmanagerYAML(secret); try { - const config = safeLoad(parsedAlertmanagerYAML.yaml); + const config = safeLoad(parsedAlertmanagerYAML.yaml) as AlertmanagerConfig; return { config, errorMessage: parsedAlertmanagerYAML.errorMessage }; } catch (e) { return { config: null, errorMessage: `Error loading alertmanager.yaml: ${e}` }; @@ -55,7 +55,7 @@ export const patchAlertmanagerConfig = ( secret: K8sResourceKind, yaml: object | string, ): Promise => { - const yamlString = _.isObject(yaml) ? safeDump(yaml) : yaml; + const yamlString = _.isObject(yaml) ? safeDump(yaml) : String(yaml); const yamlEncodedString = Base64.encode(yamlString); const patch = [{ op: 'replace', path: '/data/alertmanager.yaml', value: yamlEncodedString }]; return k8sPatch(SecretModel, secret, patch); diff --git a/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx b/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx index e4fd7abcd3a..45f8021f950 100644 --- a/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx +++ b/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx @@ -543,7 +543,7 @@ const ReceiverWrapper = memo(({ obj, ...props }) => { setLoadError({ message: 'alertmanager.v2.status.config.original not found.' }); } else { try { - const { global } = safeLoad(originalAlertmanagerConfigJSON); + const { global } = safeLoad(originalAlertmanagerConfigJSON) as Record; setAlertmanagerGlobals(global); setLoaded(true); } catch (error) { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4e8a132b4e7..4296de751e2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -43,7 +43,8 @@ "**/node_modules", "public/dist", "packages/console-dynamic-plugin-sdk/scripts", - "**/integration-tests" + "**/integration-tests", + "e2e" ], "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.json"] } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9a3414f6d43..c14626facc0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3226,6 +3226,30 @@ __metadata: languageName: node linkType: hard +"@kubernetes/client-node@npm:^1.4.0": + version: 1.4.0 + resolution: "@kubernetes/client-node@npm:1.4.0" + dependencies: + "@types/js-yaml": "npm:^4.0.1" + "@types/node": "npm:^24.0.0" + "@types/node-fetch": "npm:^2.6.13" + "@types/stream-buffers": "npm:^3.0.3" + form-data: "npm:^4.0.0" + hpagent: "npm:^1.2.0" + isomorphic-ws: "npm:^5.0.0" + js-yaml: "npm:^4.1.0" + jsonpath-plus: "npm:^10.3.0" + node-fetch: "npm:^2.7.0" + openid-client: "npm:^6.1.3" + rfc4648: "npm:^1.3.0" + socks-proxy-agent: "npm:^8.0.4" + stream-buffers: "npm:^3.0.2" + tar-fs: "npm:^3.0.9" + ws: "npm:^8.18.2" + checksum: 10c0/060bd78ee976c3af65319c2d0881adc25981d295d5ff585dad31b68dcf91c9b85f8f2c6ce6a43771cf6276a2fd3146421304f75467060183f5cf5e87bae7ff17 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.5 resolution: "@leichtgewicht/ip-codec@npm:2.0.5" @@ -3764,6 +3788,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.59.1": + version: 1.59.1 + resolution: "@playwright/test@npm:1.59.1" + dependencies: + playwright: "npm:1.59.1" + bin: + playwright: cli.js + checksum: 10c0/8c2d94a860d3c254a0b114df2f888ad0a0e9310f45b6059bd5d4da196d965cadf6922267cef0881cfa9784d4bef6d78363d2c2d94caa64be67ff644c41162137 + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:^0.6.2": version: 0.6.2 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.6.2" @@ -4809,6 +4844,20 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:^3.12.7": + version: 3.12.10 + resolution: "@types/js-yaml@npm:3.12.10" + checksum: 10c0/50ac81eb199342a18c9dfbb53ebddef27e0bc40e39de74b357dff820589fe9a752d7d146f4a2b9dd831e3ed3225d5e593c28be1aceaf122a8ce1c320b1bd08e9 + languageName: node + linkType: hard + +"@types/js-yaml@npm:^4.0.1": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211 + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -4871,6 +4920,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.13": + version: 2.6.13 + resolution: "@types/node-fetch@npm:2.6.13" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.4" + checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309 + languageName: node + linkType: hard + "@types/node-forge@npm:^1.3.0": version: 1.3.11 resolution: "@types/node-forge@npm:1.3.11" @@ -4880,7 +4939,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:22.x, @types/node@npm:>=13.7.0, @types/node@npm:>=6": +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:>=6, @types/node@npm:^24.0.0": + version: 24.12.2 + resolution: "@types/node@npm:24.12.2" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/710050c42f89075c4479e4e1e4c2532486b0c41b1e2a8a13ad88641c88b88cdaea87414e19224f30028719737bd70e327edcaa184d50e86b9418941edd7eb02b + languageName: node + linkType: hard + +"@types/node@npm:22.x": version: 22.15.15 resolution: "@types/node@npm:22.15.15" dependencies: @@ -5058,6 +5126,15 @@ __metadata: languageName: node linkType: hard +"@types/stream-buffers@npm:^3.0.3": + version: 3.0.8 + resolution: "@types/stream-buffers@npm:3.0.8" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/049b69c25c2f0a147f86c7048325885cd97ffcdb09eeff9decacd38ab93cef549db8524321b77548daa1ae0c70a3a21e4c4b518ea2c16bb50a037b13121c517a + languageName: node + linkType: hard + "@types/symlink-or-copy@npm:^1.2.0": version: 1.2.0 resolution: "@types/symlink-or-copy@npm:1.2.0" @@ -5960,14 +6037,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.3, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.7.0": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" + version: 6.15.0 + resolution: "ajv@npm:6.15.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 languageName: node linkType: hard @@ -10210,6 +10287,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^17.4.2": + version: 17.4.2 + resolution: "dotenv@npm:17.4.2" + checksum: 10c0/164f8e77a646c8446867d5b588d26ea6005c8ea7c5eb41cf926f6113d23f2191355f6e0cfd95ea9bab98394a5b0a3f1e51a8399711b666fe55cc7b0bd745f942 + languageName: node + linkType: hard + "dotenv@npm:^4.0.0": version: 4.0.0 resolution: "dotenv@npm:4.0.0" @@ -10400,12 +10484,12 @@ __metadata: linkType: hard "enhanced-resolve@npm:^5.17.1, enhanced-resolve@npm:^5.17.4": - version: 5.20.1 - resolution: "enhanced-resolve@npm:5.20.1" + version: 5.21.0 + resolution: "enhanced-resolve@npm:5.21.0" dependencies: graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.3.0" - checksum: 10c0/c6503ee1b2d725843e047e774445ecb12b779aa52db25d11ebe18d4b3adc148d3d993d2038b3d0c38ad836c9c4b3930fbc55df42f72b44785e2f94e5530eda69 + tapable: "npm:^2.3.3" + checksum: 10c0/8d25b9eb7cbaaf6bac7ca52cefb6aa8a723a3cea754aa3c52f269bdae3b6d5f3219fadbaf4362ed7d53f027e0b83bfbeb4c646640123cf62e6dbe52f28604c77 languageName: node linkType: hard @@ -12062,18 +12146,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:~2.3.2": - version: 2.3.3 - resolution: "form-data@npm:2.3.3" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.6" - mime-types: "npm:^2.1.12" - checksum: 10c0/706ef1e5649286b6a61e5bb87993a9842807fd8f149cd2548ee807ea4fb882247bdf7f6e64ac4720029c0cd5c80343de0e22eee1dc9e9882e12db9cc7bc016a4 - languageName: node - linkType: hard - -"form-data@npm:~4.0.4": +"form-data@npm:^4.0.0, form-data@npm:^4.0.4, form-data@npm:~4.0.4": version: 4.0.5 resolution: "form-data@npm:4.0.5" dependencies: @@ -12086,6 +12159,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:~2.3.2": + version: 2.3.3 + resolution: "form-data@npm:2.3.3" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.6" + mime-types: "npm:^2.1.12" + checksum: 10c0/706ef1e5649286b6a61e5bb87993a9842807fd8f149cd2548ee807ea4fb882247bdf7f6e64ac4720029c0cd5c80343de0e22eee1dc9e9882e12db9cc7bc016a4 + languageName: node + linkType: hard + "formik@npm:^2.1.5, formik@npm:^2.4.5": version: 2.4.6 resolution: "formik@npm:2.4.6" @@ -12254,6 +12338,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^2.3.3, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -12274,6 +12368,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -13118,6 +13221,13 @@ __metadata: languageName: node linkType: hard +"hpagent@npm:^1.2.0": + version: 1.2.0 + resolution: "hpagent@npm:1.2.0" + checksum: 10c0/505ef42e5e067dba701ea21e7df9fa73f6f5080e59d53680829827d34cd7040f1ecf7c3c8391abe9df4eb4682ef4a4321608836b5b70a61b88c1b3a03d77510b + languageName: node + linkType: hard + "html-encoding-sniffer@npm:^4.0.0": version: 4.0.0 resolution: "html-encoding-sniffer@npm:4.0.0" @@ -14485,6 +14595,15 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:^5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: 10c0/a058ac8b5e6efe9e46252cb0bc67fd325005d7216451d1a51238bc62d7da8486f828ef017df54ddf742e0fffcbe4b1bcc2a66cc115b027ed0180334cd18df252 + languageName: node + linkType: hard + "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -15153,6 +15272,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.2.2": + version: 6.2.2 + resolution: "jose@npm:6.2.2" + checksum: 10c0/201f4776d77eccd339de99fb3ba940fdf03db15e64be7a99b511e53c232e3f3818e3f21b95223d62f99315a2ab76b4251cedd94e067de56893e45273a8d2151b + languageName: node + linkType: hard + "jquery@npm:^3.4.0": version: 3.5.1 resolution: "jquery@npm:3.5.1" @@ -17445,7 +17571,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:2.6.1, node-fetch@npm:^2.3.0": +"node-fetch@npm:2.6.1": version: 2.6.1 resolution: "node-fetch@npm:2.6.1" checksum: 10c0/c58586d121782df045681e29608f940be90c7d8c4cada29957c148cfcc5e2d81d74b690cf10ee6879ed055da7ea821450a74ff43f3bde651cf6c8a5f34a77e2a @@ -17462,6 +17588,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.3.0, node-fetch@npm:^2.7.0": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + "node-forge@npm:^1": version: 1.3.1 resolution: "node-forge@npm:1.3.1" @@ -17647,6 +17787,13 @@ __metadata: languageName: node linkType: hard +"oauth4webapi@npm:^3.8.5": + version: 3.8.5 + resolution: "oauth4webapi@npm:3.8.5" + checksum: 10c0/688142b30f2243813721bfa4ab879aa0056636b19a3d7964d46b11b967199ab8f74f3771225f71ec766821d410add950475cf1afcfe26a9640cd1c0a1de8e423 + languageName: node + linkType: hard + "object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -17869,6 +18016,16 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^6.1.3": + version: 6.8.3 + resolution: "openid-client@npm:6.8.3" + dependencies: + jose: "npm:^6.2.2" + oauth4webapi: "npm:^3.8.5" + checksum: 10c0/3765b5d3be0f5d7443ccc9f711a3c2176452505436f069037e1b03adb710626a08c7242666d59809585272261402a1ab9795fe89696f714463af145bacc50406 + languageName: node + linkType: hard + "openshift-console@workspace:.": version: 0.0.0-use.local resolution: "openshift-console@workspace:." @@ -17879,6 +18036,7 @@ __metadata: "@graphql-codegen/typescript": "npm:^1.15.1" "@graphql-codegen/typescript-graphql-files-modules": "npm:^1.15.1" "@graphql-codegen/typescript-operations": "npm:^1.15.1" + "@kubernetes/client-node": "npm:^1.4.0" "@openshift/dynamic-plugin-sdk": "npm:^8.0.0" "@openshift/dynamic-plugin-sdk-webpack": "npm:^5.1.1" "@patternfly/patternfly": "npm:~6.4.0" @@ -17899,6 +18057,7 @@ __metadata: "@patternfly/react-topology": "npm:~6.4.0" "@patternfly/react-user-feedback": "npm:~6.2.0" "@patternfly/react-virtualized-extension": "npm:~6.2.0" + "@playwright/test": "npm:^1.59.1" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.6.2" "@rjsf/core": "npm:^4.2.3" "@swc/core": "npm:^1.15.18" @@ -17911,6 +18070,7 @@ __metadata: "@types/glob": "npm:7.x" "@types/immutable": "npm:3.x" "@types/jest": "npm:^30.0.0" + "@types/js-yaml": "npm:^3.12.7" "@types/json-schema": "npm:^7.0.7" "@types/lodash-es": "npm:^4.17.12" "@types/node": "npm:22.x" @@ -17942,6 +18102,7 @@ __metadata: cypress-jest-adapter: "npm:^0.1.1" cypress-multi-reporters: "npm:^2.0.5" dagre: "npm:^0.8.5" + dotenv: "npm:^17.4.2" esbuild-loader: "npm:^4.4.2" file-loader: "npm:6.2.0" file-saver: "npm:1.3.x" @@ -18688,6 +18849,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.59.1": + version: 1.59.1 + resolution: "playwright-core@npm:1.59.1" + bin: + playwright-core: cli.js + checksum: 10c0/d41a74d9681ce3beb3d5239e9ed577710b4ad099a6ca2476219c6599d51e9cb4b80bd72ed82c528da6a5d929c18ae3b872cf02bb83f78fa1c2cb9199c501abee + languageName: node + linkType: hard + +"playwright@npm:1.59.1": + version: 1.59.1 + resolution: "playwright@npm:1.59.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.59.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/dfe38396e616e5c4f98825ce90037bb96e477c5a2bd9258a24854f8ce72a8a41427b19098863866f85aa0216e70287dd537c4438d761aca93995e31ae099c533 + languageName: node + linkType: hard + "please-upgrade-node@npm:^3.2.0": version: 3.2.0 resolution: "please-upgrade-node@npm:3.2.0" @@ -20159,6 +20344,13 @@ __metadata: languageName: node linkType: hard +"rfc4648@npm:^1.3.0": + version: 1.5.4 + resolution: "rfc4648@npm:1.5.4" + checksum: 10c0/8683e82ed9c3cb23844720d04eaeee12025146bfdfdf250b1cce80d56e16c6431530ba3033cbb0e7ca3a25223107847f14c6cac11a255ea7d219dc7ba11cd43d + languageName: node + linkType: hard + "rimraf@npm:^2.5.4": version: 2.7.1 resolution: "rimraf@npm:2.7.1" @@ -20934,7 +21126,7 @@ __metadata: languageName: node linkType: hard -"socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.5": +"socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.4, socks-proxy-agent@npm:^8.0.5": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" dependencies: @@ -21295,6 +21487,13 @@ __metadata: languageName: node linkType: hard +"stream-buffers@npm:^3.0.2": + version: 3.0.3 + resolution: "stream-buffers@npm:3.0.3" + checksum: 10c0/d052e6344fba340b27dfbe8d6568f600b7f81fdc57b2659e82c8d58a3ef855a4852c56736b1078a511a7f4458db96ee89b11c42c96d116b9073a99deb29a6f05 + languageName: node + linkType: hard + "stream-combiner2@npm:^1.1.1": version: 1.1.1 resolution: "stream-combiner2@npm:1.1.1" @@ -21777,16 +21976,16 @@ __metadata: languageName: node linkType: hard -"tapable@npm:^2.0.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0": - version: 2.3.0 - resolution: "tapable@npm:2.3.0" - checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 +"tapable@npm:^2.0.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0, tapable@npm:^2.3.3": + version: 2.3.3 + resolution: "tapable@npm:2.3.3" + checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 languageName: node linkType: hard -"tar-fs@npm:^3.0.6": - version: 3.0.8 - resolution: "tar-fs@npm:3.0.8" +"tar-fs@npm:^3.0.6, tar-fs@npm:^3.0.9": + version: 3.1.2 + resolution: "tar-fs@npm:3.1.2" dependencies: bare-fs: "npm:^4.0.1" bare-path: "npm:^3.0.0" @@ -21797,7 +21996,7 @@ __metadata: optional: true bare-path: optional: true - checksum: 10c0/b70bb2ad0490ab13b48edd10bd648bb54c52b681981cdcdc3aa4517e98ad94c94659ddca1925872ee658d781b9fcdd2b1c808050647f06b1bca157dd2fcae038 + checksum: 10c0/9dcbbbef9cdfc27f47651fe679f15952a6a8e6b3c9761c4bf3f416ace41cf462fb6292519bd3e041cadfcc0b89043a6bdecb46ff19f770b6864b77dcde7bad46 languageName: node linkType: hard @@ -22243,6 +22442,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + "tree-dump@npm:^1.0.1": version: 1.0.2 resolution: "tree-dump@npm:1.0.2" @@ -22703,6 +22909,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "undici@npm:^5.4.0": version: 5.28.4 resolution: "undici@npm:5.28.4" @@ -23858,6 +24071,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + "webidl-conversions@npm:^4.0.2": version: 4.0.2 resolution: "webidl-conversions@npm:4.0.2" @@ -24125,6 +24345,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + "whatwg-url@npm:^7.0.0": version: 7.0.0 resolution: "whatwg-url@npm:7.0.0" @@ -24355,9 +24585,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0": - version: 8.18.0 - resolution: "ws@npm:8.18.0" +"ws@npm:^8.18.0, ws@npm:^8.18.2": + version: 8.20.0 + resolution: "ws@npm:8.20.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -24366,7 +24596,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/25eb33aff17edcb90721ed6b0eb250976328533ad3cd1a28a274bd263682e7296a6591ff1436d6cbc50fa67463158b062f9d1122013b361cec99a05f84680e06 + checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 languageName: node linkType: hard