Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 17 additions & 26 deletions frontend/e2e/clients/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,9 @@ export default class KubernetesClient {
const existing = await this.k8sApi.readNamespacedConfigMap({ name, namespace });
const existingData = (existing as any)?.data || {};
const mergedData = { ...existingData, ...patchData };
await this.mergePatchResource(
`/api/v1/namespaces/${namespace}/configmaps/${name}`,
{ data: mergedData },
);
await this.mergePatchResource(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
data: mergedData,
});
}

async createConfigMap(
Expand Down Expand Up @@ -455,21 +454,19 @@ export default class KubernetesClient {
namespace: string,
annotations: Record<string, string | null>,
): Promise<void> {
await this.mergePatchResource(
`/api/v1/namespaces/${namespace}/configmaps/${name}`,
{ metadata: { annotations } },
);
await this.mergePatchResource(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
metadata: { annotations },
});
}

async labelConfigMap(
name: string,
namespace: string,
labels: Record<string, string | null>,
): Promise<void> {
await this.mergePatchResource(
`/api/v1/namespaces/${namespace}/configmaps/${name}`,
{ metadata: { labels } },
);
await this.mergePatchResource(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
metadata: { labels },
});
}

async deleteConfigMap(name: string, namespace: string): Promise<void> {
Expand Down Expand Up @@ -640,11 +637,7 @@ export default class KubernetesClient {
}
}

async patchDeployment(
name: string,
namespace: string,
patch: object,
): Promise<unknown> {
async patchDeployment(name: string, namespace: string, patch: object): Promise<unknown> {
return this.appsApi.patchNamespacedDeployment({
name,
namespace,
Expand Down Expand Up @@ -687,7 +680,6 @@ export default class KubernetesClient {
});
}


async waitForDeploymentReady(
name: string,
namespace: string,
Expand All @@ -702,9 +694,7 @@ export default class KubernetesClient {
return (
status?.availableReplicas === desired &&
status?.updatedReplicas === desired &&
(status?.conditions ?? []).some(
(c) => c.type === 'Available' && c.status === 'True',
)
(status?.conditions ?? []).some((c) => c.type === 'Available' && c.status === 'True')
);
} catch {
return false;
Expand Down Expand Up @@ -748,9 +738,11 @@ export default class KubernetesClient {
const state = cs.state?.waiting
? `Waiting: ${cs.state.waiting.reason} - ${cs.state.waiting.message ?? ''}`
: cs.state?.terminated
? `Terminated: ${cs.state.terminated.reason}`
: 'Running';
lines.push(` container ${cs.name}: ready=${cs.ready}, restarts=${cs.restartCount}, ${state}`);
? `Terminated: ${cs.state.terminated.reason}`
: 'Running';
lines.push(
` container ${cs.name}: ready=${cs.ready}, restarts=${cs.restartCount}, ${state}`,
);
}
try {
const events = await this.k8sApi.listNamespacedEvent({
Expand All @@ -760,8 +752,7 @@ export default class KubernetesClient {
const recent = events.items
.sort(
(a, b) =>
new Date(b.lastTimestamp ?? 0).getTime() -
new Date(a.lastTimestamp ?? 0).getTime(),
new Date(b.lastTimestamp ?? 0).getTime() - new Date(a.lastTimestamp ?? 0).getTime(),
)
.slice(0, 10);
for (const ev of recent) {
Expand Down
69 changes: 69 additions & 0 deletions frontend/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import * as path from 'path';
import { test as base, expect } from '@playwright/test';

import KubernetesClient from '../clients/kubernetes-client';
import { loginFromEnv } from '../setup/login-helper';

import type { CleanupFixture } from './cleanup-fixture';
import { createCleanupFixture } from './cleanup-fixture';

// URLs the console redirects to when a shared storageState session expires or is
// invalidated (e.g. by a console rollout in another spec). Matches the OAuth
// server and the console's own login route.
const OAUTH_REDIRECT_RE = /\/oauth\/|oauth-openshift|\/auth\/login\b/;

export interface SharedTestConfig {
testNamespace: string;
authToken?: string;
Expand All @@ -24,6 +30,69 @@ type WorkerFixtures = {
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
// Override the built-in `page` fixture to self-heal lost sessions. When any
// navigation is bounced to the OAuth login page — during warmup or mid-test —
// re-authenticate the current persona and retry the original target so the
// caller transparently lands on the page it asked for. loginFromEnv returns
// quickly when the OAuth SSO cookie is still valid (the flow auto-completes)
// and resubmits credentials when it isn't. Persona is derived from the project
// name, matching the storageState mapping in playwright.config.ts.
//
// Tests that assert on session/auth behavior directly (e.g. session
// persistence across pod restarts) must opt out with a
// `{ type: 'no-auto-reauth' }` annotation, otherwise transparent recovery
// would mask the very failure they check for.
page: async ({ page }, use, testInfo) => {
if (testInfo.annotations.some((a) => a.type === 'no-auto-reauth')) {
await use(page);
return;
}
const persona = testInfo.project.name.endsWith('-developer') ? 'developer' : 'admin';
const originalGoto = page.goto.bind(page);
let recovering = false;

const recoverIfRedirectedToLogin = async (): Promise<boolean> => {
// Guard against re-entrancy: loginFromEnv navigates internally, and those
// navigations flow back through this override.
if (recovering || !OAUTH_REDIRECT_RE.test(page.url())) {
return false;
}
recovering = true;
try {
await loginFromEnv(page, persona);
} finally {
recovering = false;
}
return true;
};

page.goto = async (url, options) => {
const response = await originalGoto(url, options);
// The console redirects to the OAuth login page client-side, a beat after
// the initial document loads, so `page.url()` can still read the target
// right after goto resolves. Wait for auth to settle before deciding: the
// console boots with a `co-auth-pending` class on <html> and removes it
// once its authenticated bootstrap fetch succeeds (see public/components/
// app.tsx); a 401 instead redirects to OAuth. Race that class dropping
// against the OAuth redirect so we neither miss the redirect nor stall the
// happy path.
if (!recovering) {
// eslint-disable-next-line no-restricted-syntax -- waiting for state, no action follows
const authSettled = page
.locator('html:not(.co-auth-pending)')
.waitFor({ state: 'attached', timeout: 30_000 });
const redirectedToLogin = page.waitForURL(OAUTH_REDIRECT_RE, { timeout: 30_000 });
await Promise.race([authSettled.catch(() => {}), redirectedToLogin.catch(() => {})]);
}
if (await recoverIfRedirectedToLogin()) {
return originalGoto(url, options);
}
return response;
};

await use(page);
},

testConfig: [
async ({}, use) => {
const configPath = path.resolve(import.meta.dirname, '..', '.test-config.json');
Expand Down
29 changes: 21 additions & 8 deletions frontend/e2e/mocks/operator-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ const activePhases = (): { name: string; startDate: string; endDate: string }[]
extendedStart.setDate(extendedStart.getDate() + 1);
const extendedEnd = new Date(now.getFullYear() + 3, 11, 31);
return [
{ name: 'Maintenance support', startDate: toDateStr(maintenanceStart), endDate: toDateStr(maintenanceEnd) },
{ name: 'Extended life cycle support', startDate: toDateStr(extendedStart), endDate: toDateStr(extendedEnd) },
{
name: 'Maintenance support',
startDate: toDateStr(maintenanceStart),
endDate: toDateStr(maintenanceEnd),
},
{
name: 'Extended life cycle support',
startDate: toDateStr(extendedStart),
endDate: toDateStr(extendedEnd),
},
];
};

Expand All @@ -33,8 +41,16 @@ const expiredPhases = (): { name: string; startDate: string; endDate: string }[]
extendedStart.setDate(extendedStart.getDate() + 1);
const extendedEnd = new Date(now.getFullYear() - 1, 11, 31);
return [
{ name: 'Maintenance support', startDate: toDateStr(maintenanceStart), endDate: toDateStr(maintenanceEnd) },
{ name: 'Extended life cycle support', startDate: toDateStr(extendedStart), endDate: toDateStr(extendedEnd) },
{
name: 'Maintenance support',
startDate: toDateStr(maintenanceStart),
endDate: toDateStr(maintenanceEnd),
},
{
name: 'Extended life cycle support',
startDate: toDateStr(extendedStart),
endDate: toDateStr(extendedEnd),
},
];
};

Expand Down Expand Up @@ -70,10 +86,7 @@ export const makeLifecycleSelfSupport = (
],
});

export const makeLifecycleIncompatible = (
packageName: string,
version: string,
): LifecycleData => ({
export const makeLifecycleIncompatible = (packageName: string, version: string): LifecycleData => ({
package: packageName,
schema: LIFECYCLE_SCHEMA,
versions: [
Expand Down
27 changes: 23 additions & 4 deletions frontend/e2e/mocks/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,18 @@ export const provisionersMap: Record<string, Parameter[]> = {
{ name: 'Availability zone', id: 'availability', values: 'lalitpur' },
],
'kubernetes.io/azure-file': [
{ name: 'SKU name', id: 'skuName', hintText: 'Azure storage account SKU tier', values: 'sample-name' },
{ name: 'Location', id: 'location', hintText: 'Azure storage account name', values: 'bhaktapur' },
{
name: 'SKU name',
id: 'skuName',
hintText: 'Azure storage account SKU tier',
values: 'sample-name',
},
{
name: 'Location',
id: 'location',
hintText: 'Azure storage account name',
values: 'bhaktapur',
},
{
name: 'Azure storage account name',
id: 'storageAccount',
Expand All @@ -115,7 +125,12 @@ export const provisionersMap: Record<string, Parameter[]> = {
},
],
'kubernetes.io/azure-disk': [
{ name: 'Storage account type', id: 'storageaccounttype', hintText: 'Storage account type', values: 'tester' },
{
name: 'Storage account type',
id: 'storageaccounttype',
hintText: 'Storage account type',
values: 'tester',
},
{ name: 'Account kind', id: 'kind', values: ['shared', 'dedicated', 'managed'] },
],
'kubernetes.io/quobyte': [
Expand All @@ -128,7 +143,11 @@ export const provisionersMap: Record<string, Parameter[]> = {
{ name: 'Quobyte tenant', id: 'quobyteTenant', values: 'tester' },
],
'kubernetes.io/vsphere-volume': [
{ name: 'Disk format', id: 'diskformat', values: ['thin', 'zeroed thick', 'eager zeroed thick'] },
{
name: 'Disk format',
id: 'diskformat',
values: ['thin', 'zeroed thick', 'eager zeroed thick'],
},
{ name: 'Datastore', id: 'datastore', values: 'store-thin' },
],
'kubernetes.io/portworx-volume': [
Expand Down
4 changes: 3 additions & 1 deletion frontend/e2e/pages/alertmanager-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ export function getGlobalsAndReceiverConfig(
} {
const parsed = yaml.load(yamlContent);
const config: AlertmanagerConfig =
typeof parsed === 'object' && parsed !== null ? (parsed as AlertmanagerConfig) : ({} as AlertmanagerConfig);
typeof parsed === 'object' && parsed !== null
? (parsed as AlertmanagerConfig)
: ({} as AlertmanagerConfig);
const receiver: AlertmanagerReceiver | undefined = config.receivers?.find(
(r) => r.name === receiverName,
);
Expand Down
29 changes: 20 additions & 9 deletions frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,25 @@ export async function setEditorContent(page: Page, content: string): Promise<voi
await page.waitForFunction(() => (window as any).monaco?.editor?.getModels()?.[0], {
timeout: 10_000,
});
await page.evaluate((text) => {
(window as any).monaco.editor.getModels()[0].setValue(text);
}, content);
// Monaco can swap its model during initialisation, silently dropping an early
// setValue and leaving the editor empty — which then submits an empty
// definition. Set and verify with retries so the content is guaranteed to
// stick before the caller proceeds.
await expect(async () => {
await page.evaluate((text) => {
(window as any).monaco.editor.getModels()[0].setValue(text);
}, content);
const value = await page.evaluate(() =>
(window as any).monaco.editor.getModels()[0].getValue(),
);
expect(value.trim()).toBe(content.trim());
}).toPass({ timeout: 15_000, intervals: [300, 700, 1500] });
}

export async function warmupSPA(page: Page): Promise<void> {
// Session recovery on OAuth redirect is handled by the guarded `page` fixture
// (e2e/fixtures/index.ts), which re-authenticates on any navigation — during
// warmup or mid-test — that gets bounced to the login page.
await expect(async () => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#page-sidebar')).toBeVisible({ timeout: 30_000 });
Expand All @@ -51,8 +64,7 @@ export async function ensureDeveloperPerspective(
): Promise<boolean> {
const toggle = page.getByTestId('perspective-switcher-toggle');
await expect(toggle).toBeVisible();
const isSinglePerspective =
(await toggle.getAttribute('id')) === 'only-one-perspective';
const isSinglePerspective = (await toggle.getAttribute('id')) === 'only-one-perspective';
if (isSinglePerspective) {
await k8sClient.customObjectsApi.patchClusterCustomObject({
group: 'operator.openshift.io',
Expand Down Expand Up @@ -187,10 +199,9 @@ export default abstract class BasePage {
}

async waitForEditorReady(): Promise<void> {
await this.page.waitForFunction(
() => !!(window as any).monaco?.editor?.getModels()?.[0],
{ timeout: 30_000 },
);
await this.page.waitForFunction(() => !!(window as any).monaco?.editor?.getModels()?.[0], {
timeout: 30_000,
});
}

async getEditorContent(): Promise<string> {
Expand Down
Loading