mirror of
https://github.com/quay/quay.git
synced 2026-01-27 18:42:52 +03:00
* test(web): migrate superuser-org-actions.cy.ts to Playwright Migrate Cypress E2E tests for superuser organization actions to Playwright following the project's MIGRATION.md guidelines. Changes: - Add new test file: playwright/e2e/superuser/org-actions.spec.ts - Consolidate 12 Cypress tests into 5 focused Playwright tests - Use real API data instead of mocked fixtures - Auto-cleanup via TestApi fixture Test coverage: - Superuser sees actions column and options menu for organizations - Regular user does not see organization options menu - Superuser can rename organization - Superuser can delete organization - Superuser can take ownership of organization Skipped from migration: - Quota menu tests (already covered by quota.spec.ts) - Fresh login requirement tests (low value, complex to mock) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(web): set superuser feature tag Signed-off-by: Brady Pratt <bpratt@redhat.com> * test(web): migrate superuser-messages.cy.ts to Playwright Migrate superuser messages tests from Cypress to Playwright, consolidating 14 original tests into 6 focused, value-add tests. Tests cover: - Non-superuser redirect to organization page - Full CRUD flow: create, view, and delete messages via UI - Error state when API fails to load messages - Loading spinner during message fetch - Read-only superuser can access and view messages - Read-only superuser sees disabled create/delete actions Infrastructure additions: - Add message() method to TestApi with auto-cleanup - Add CreatedMessage interface for type safety - Add SUPERUSERS_FULL_ACCESS feature tag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(web): migrate superuser-user-management.cy.ts to Playwright Consolidates 29 Cypress tests into 11 Playwright tests covering superuser user management functionality. Changes: - Add CreatedUser interface and user() method to TestApi for user creation with auto-cleanup - Add createUserAsSuperuser() to API client using superuser endpoint - Add QuayAuthType and skipUnlessAuthType() helper for auth-type conditional tests - Create user-management.spec.ts with consolidated tests Tests cover: - Create user via UI (Database/AppToken auth only) - User access control based on user type - Change email and password (Database auth only) - Toggle user status (disable/enable) - Delete user - Take ownership (convert user to org) - Fresh login error handling with mocked responses - Send recovery email (MAILING feature) - Auth type visibility Key patterns: - Uses search to find users in paginated list - page.route() only for error scenarios per MIGRATION.md - skipUnlessAuthType() for auth-dependent tests - @feature:SUPERUSERS_FULL_ACCESS tag for all tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test(web): delete more migrated cypress tests Signed-off-by: Brady Pratt <bpratt@redhat.com> * test(web): migrate superuser-framework Cypress test to Playwright Consolidates 7 Cypress tests into 4 Playwright tests covering: - Superuser navigation to all superuser pages - Navigation section visibility and expansion - Organizations table Settings column and actions menu - Regular user restrictions and redirects Uses real superuserPage/authenticatedPage fixtures instead of mocking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test(web): migrate superuser-service-keys Cypress test to Playwright Consolidates 17 Cypress tests into 5 Playwright tests: - non-superuser redirect to organization page - superuser CRUD lifecycle (create, view, search, update, delete) - error handling when create fails - read-only superuser permission restrictions - bulk select and delete operations Adds service key API methods to Playwright test utilities: - getServiceKeys, createServiceKey, updateServiceKey, deleteServiceKey - TestApi.serviceKey() with auto-cleanup support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(web): migrate superuser-change-log Cypress test to Playwright Migrate superuser-change-log.cy.ts to Playwright with test consolidation: - 7 original tests reduced to 2 focused tests - Access control tests already covered by framework.spec.ts - Loading spinner and empty state tests skipped (low value) - Uses real API calls except for error state (acceptable mock) - No PatternFly class dependencies, uses role-based selectors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(web): migrate superuser-usage-logs Cypress test to Playwright - Consolidate 7 Cypress tests into 2 Playwright tests - Access control tests already covered by framework.spec.ts - Add data-testid="usage-logs-table" to UsageLogsTable component - Tests verify: page header, date pickers, chart toggle, table loading, and filter functionality - Use structural assertions for parallel test safety 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Brady Pratt <bpratt@redhat.com> * test(web): remove unneeded comments Signed-off-by: Brady Pratt <bpratt@redhat.com> --------- Signed-off-by: Brady Pratt <bpratt@redhat.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
863 lines
21 KiB
TypeScript
863 lines
21 KiB
TypeScript
/**
|
|
* Playwright Custom Fixtures
|
|
*
|
|
* Provides pre-authenticated contexts for different user roles.
|
|
* Tests can extend these fixtures to get logged-in sessions.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* import { test, expect } from '../fixtures';
|
|
*
|
|
* test('can view organization', async ({ authenticatedPage }) => {
|
|
* await authenticatedPage.goto('/organization');
|
|
* await expect(authenticatedPage.getByText('Organizations')).toBeVisible();
|
|
* });
|
|
*
|
|
* test('superuser can manage users', async ({ superuserPage }) => {
|
|
* await superuserPage.goto('/superuser');
|
|
* await expect(superuserPage.getByText('Users')).toBeVisible();
|
|
* });
|
|
* ```
|
|
*/
|
|
|
|
import {
|
|
test as base,
|
|
expect,
|
|
Page,
|
|
APIRequestContext,
|
|
BrowserContext,
|
|
} from '@playwright/test';
|
|
import {TEST_USERS} from './global-setup';
|
|
import {API_URL} from './utils/config';
|
|
import {
|
|
ApiClient,
|
|
PrototypeRole,
|
|
RepositoryVisibility,
|
|
ServiceKey,
|
|
TeamRole,
|
|
} from './utils/api';
|
|
|
|
// ============================================================================
|
|
// TestApi: Auto-cleanup API client for tests
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Cleanup action to run after test completes
|
|
*/
|
|
type CleanupAction = () => Promise<void>;
|
|
|
|
/**
|
|
* Created organization info
|
|
*/
|
|
export interface CreatedOrg {
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
/**
|
|
* Created repository info
|
|
*/
|
|
export interface CreatedRepo {
|
|
namespace: string;
|
|
name: string;
|
|
fullName: string;
|
|
}
|
|
|
|
/**
|
|
* Created team info
|
|
*/
|
|
export interface CreatedTeam {
|
|
orgName: string;
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Created robot info
|
|
*/
|
|
export interface CreatedRobot {
|
|
orgName: string;
|
|
shortname: string;
|
|
fullName: string;
|
|
}
|
|
|
|
/**
|
|
* Created message info
|
|
*/
|
|
export interface CreatedMessage {
|
|
uuid: string;
|
|
content: string;
|
|
severity: 'info' | 'warning' | 'error';
|
|
}
|
|
|
|
/**
|
|
* Created user info
|
|
*/
|
|
export interface CreatedUser {
|
|
username: string;
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
/**
|
|
* Created service key info
|
|
*/
|
|
export interface CreatedServiceKey {
|
|
kid: string;
|
|
service: string;
|
|
name?: string;
|
|
expiration?: number;
|
|
}
|
|
|
|
/**
|
|
* API client with auto-cleanup tracking.
|
|
*
|
|
* All resources created via this client are automatically
|
|
* cleaned up when the test completes (even on failure).
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* test('my test', async ({api}) => {
|
|
* const org = await api.organization();
|
|
* const repo = await api.repository(org.name);
|
|
* // After test: auto-deletes repo, then org (reverse order)
|
|
* });
|
|
* ```
|
|
*/
|
|
export class TestApi {
|
|
private client: ApiClient;
|
|
private cleanupStack: CleanupAction[] = [];
|
|
|
|
constructor(client: ApiClient) {
|
|
this.client = client;
|
|
}
|
|
|
|
/**
|
|
* Access the underlying ApiClient for operations
|
|
* that don't need auto-cleanup tracking
|
|
*/
|
|
get raw(): ApiClient {
|
|
return this.client;
|
|
}
|
|
|
|
/**
|
|
* Create a unique organization.
|
|
* Automatically deleted after test.
|
|
*/
|
|
async organization(namePrefix = 'org'): Promise<CreatedOrg> {
|
|
const name = uniqueName(namePrefix);
|
|
const email = `${name}@example.com`;
|
|
|
|
await this.client.createOrganization(name, email);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteOrganization(name);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {name, email};
|
|
}
|
|
|
|
/**
|
|
* Create a unique repository.
|
|
* Automatically deleted after test.
|
|
*
|
|
* @param namespace - Organization or username (defaults to test user)
|
|
*/
|
|
async repository(
|
|
namespace?: string,
|
|
namePrefix = 'repo',
|
|
visibility: RepositoryVisibility = 'private',
|
|
): Promise<CreatedRepo> {
|
|
const ns = namespace ?? TEST_USERS.user.username;
|
|
const name = uniqueName(namePrefix);
|
|
|
|
await this.client.createRepository(ns, name, visibility);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteRepository(ns, name);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {
|
|
namespace: ns,
|
|
name,
|
|
fullName: `${ns}/${name}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a repository with an exact name (supports multi-segment names like "release/installer").
|
|
* Automatically deleted after test.
|
|
*
|
|
* @param namespace - Organization or username
|
|
* @param name - Exact repository name (can contain "/" for multi-segment)
|
|
* @param visibility - Repository visibility (default: private)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Create a multi-segment repository
|
|
* const repo = await api.repositoryWithName('myorg', 'release/installer');
|
|
* ```
|
|
*/
|
|
async repositoryWithName(
|
|
namespace: string,
|
|
name: string,
|
|
visibility: RepositoryVisibility = 'private',
|
|
): Promise<CreatedRepo> {
|
|
await this.client.createRepository(namespace, name, visibility);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteRepository(namespace, name);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {
|
|
namespace,
|
|
name,
|
|
fullName: `${namespace}/${name}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a team in an organization.
|
|
* Automatically deleted after test.
|
|
*/
|
|
async team(
|
|
orgName: string,
|
|
namePrefix = 'team',
|
|
role: TeamRole = 'member',
|
|
): Promise<CreatedTeam> {
|
|
const name = uniqueName(namePrefix);
|
|
|
|
await this.client.createTeam(orgName, name, role);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteTeam(orgName, name);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {orgName, name};
|
|
}
|
|
|
|
/**
|
|
* Create a robot account in an organization.
|
|
* Automatically deleted after test.
|
|
*/
|
|
async robot(
|
|
orgName: string,
|
|
namePrefix = 'bot',
|
|
description = '',
|
|
): Promise<CreatedRobot> {
|
|
// Robot names can't have dashes, only underscores
|
|
const shortname = uniqueName(namePrefix).replace(/-/g, '_');
|
|
|
|
await this.client.createRobot(orgName, shortname, description);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteRobot(orgName, shortname);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {
|
|
orgName,
|
|
shortname,
|
|
fullName: `${orgName}+${shortname}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set repository to MIRROR state.
|
|
* (No cleanup needed - deleting repo handles it)
|
|
*/
|
|
async setMirrorState(namespace: string, repoName: string): Promise<void> {
|
|
await this.client.changeRepositoryState(namespace, repoName, 'MIRROR');
|
|
}
|
|
|
|
/**
|
|
* Create a default permission (prototype).
|
|
* Automatically deleted after test.
|
|
*/
|
|
async prototype(
|
|
orgName: string,
|
|
role: PrototypeRole,
|
|
delegate: {name: string; kind: 'user' | 'team'},
|
|
activatingUser?: {name: string},
|
|
): Promise<{id: string}> {
|
|
const result = await this.client.createPrototype(
|
|
orgName,
|
|
role,
|
|
delegate,
|
|
activatingUser,
|
|
);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deletePrototype(orgName, result.id);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Add a permission to a repository.
|
|
* Automatically deleted after test.
|
|
*
|
|
* @param namespace - Organization or username that owns the repository
|
|
* @param repoName - Repository name
|
|
* @param entityType - Type of entity ('user' for users/robots, 'team' for teams)
|
|
* @param entityName - Name of the entity (username, robot fullName like "org+bot", or team name)
|
|
* @param role - Permission level ('read', 'write', or 'admin')
|
|
*/
|
|
async repositoryPermission(
|
|
namespace: string,
|
|
repoName: string,
|
|
entityType: 'user' | 'team',
|
|
entityName: string,
|
|
role: PrototypeRole = 'read',
|
|
): Promise<{
|
|
namespace: string;
|
|
repoName: string;
|
|
entityType: 'user' | 'team';
|
|
entityName: string;
|
|
}> {
|
|
await this.client.addRepositoryPermission(
|
|
namespace,
|
|
repoName,
|
|
entityType,
|
|
entityName,
|
|
role,
|
|
);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteRepositoryPermission(
|
|
namespace,
|
|
repoName,
|
|
entityType,
|
|
entityName,
|
|
);
|
|
} catch {
|
|
/* ignore cleanup errors - permission may already be deleted */
|
|
}
|
|
});
|
|
|
|
return {namespace, repoName, entityType, entityName};
|
|
}
|
|
|
|
/**
|
|
* Create a notification for a repository.
|
|
* Automatically deleted after test.
|
|
*/
|
|
async notification(
|
|
namespace: string,
|
|
repoName: string,
|
|
event: string,
|
|
method: string,
|
|
config: Record<string, unknown>,
|
|
title?: string,
|
|
): Promise<{uuid: string; namespace: string; repoName: string}> {
|
|
const result = await this.client.createRepositoryNotification(
|
|
namespace,
|
|
repoName,
|
|
event,
|
|
method,
|
|
config,
|
|
{},
|
|
title,
|
|
);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteRepositoryNotification(
|
|
namespace,
|
|
repoName,
|
|
result.uuid,
|
|
);
|
|
} catch {
|
|
/* ignore cleanup errors - notification may already be deleted */
|
|
}
|
|
});
|
|
|
|
return {uuid: result.uuid, namespace, repoName};
|
|
}
|
|
|
|
/**
|
|
* Create a global message.
|
|
* Automatically deleted after test.
|
|
* (Superuser only)
|
|
*/
|
|
async message(
|
|
content: string,
|
|
severity: 'info' | 'warning' | 'error' = 'info',
|
|
): Promise<CreatedMessage> {
|
|
const result = await this.client.createMessage(content, severity);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteMessage(result.uuid);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {
|
|
uuid: result.uuid,
|
|
content: result.content,
|
|
severity: result.severity,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a user as superuser.
|
|
* Automatically deleted after test.
|
|
* (Superuser only - uses superuser API for creation and deletion)
|
|
*
|
|
* Note: The password is auto-generated by the server and returned.
|
|
*/
|
|
async user(namePrefix = 'user'): Promise<CreatedUser> {
|
|
const username = uniqueName(namePrefix);
|
|
const email = `${username}@example.com`;
|
|
|
|
const result = await this.client.createUserAsSuperuser(username, email);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteUser(username);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {username, email, password: result.password};
|
|
}
|
|
|
|
/**
|
|
* Create a service key.
|
|
* Automatically deleted after test.
|
|
* (Superuser only)
|
|
*
|
|
* @param service - Service name (must match [a-z0-9_]+ pattern)
|
|
* @param name - Optional friendly name for the key
|
|
* @param expiration - Optional expiration timestamp (unix epoch seconds)
|
|
*/
|
|
async serviceKey(
|
|
service: string,
|
|
name?: string,
|
|
expiration?: number,
|
|
): Promise<CreatedServiceKey> {
|
|
const result: ServiceKey = await this.client.createServiceKey(
|
|
service,
|
|
name,
|
|
expiration,
|
|
);
|
|
|
|
this.cleanupStack.push(async () => {
|
|
try {
|
|
await this.client.deleteServiceKey(result.kid);
|
|
} catch {
|
|
/* ignore cleanup errors */
|
|
}
|
|
});
|
|
|
|
return {
|
|
kid: result.kid,
|
|
service: result.service,
|
|
name: result.name,
|
|
expiration:
|
|
typeof result.expiration_date === 'number'
|
|
? result.expiration_date
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run all cleanup actions in reverse order.
|
|
* Called automatically by fixture teardown.
|
|
*/
|
|
async cleanup(): Promise<void> {
|
|
// Run in reverse order (LIFO)
|
|
let action = this.cleanupStack.pop();
|
|
while (action) {
|
|
await action();
|
|
action = this.cleanupStack.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Quay Config Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Known Quay feature flags that can be enabled/disabled
|
|
*/
|
|
export type QuayFeature =
|
|
| 'BILLING'
|
|
| 'QUOTA_MANAGEMENT'
|
|
| 'EDIT_QUOTA'
|
|
| 'AUTO_PRUNE'
|
|
| 'PROXY_CACHE'
|
|
| 'REPO_MIRROR'
|
|
| 'SECURITY_SCANNER'
|
|
| 'CHANGE_TAG_EXPIRATION'
|
|
| 'USER_METADATA'
|
|
| 'MAILING'
|
|
| 'IMAGE_EXPIRY_TRIGGER'
|
|
| 'SUPERUSERS_FULL_ACCESS';
|
|
|
|
/**
|
|
* Quay configuration from /config endpoint
|
|
*/
|
|
export interface QuayConfig {
|
|
features: Partial<Record<QuayFeature, boolean>>;
|
|
config: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Helper to skip tests when required features are not enabled.
|
|
* Returns a tuple that can be spread into test.skip()
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* test('requires billing', async ({ quayConfig }) => {
|
|
* test.skip(...skipUnlessFeature(quayConfig, 'BILLING'));
|
|
* // test code...
|
|
* });
|
|
*
|
|
* test('requires multiple features', async ({ quayConfig }) => {
|
|
* test.skip(...skipUnlessFeature(quayConfig, 'QUOTA_MANAGEMENT', 'EDIT_QUOTA'));
|
|
* // test code...
|
|
* });
|
|
* ```
|
|
*/
|
|
export function skipUnlessFeature(
|
|
config: QuayConfig | null,
|
|
...features: QuayFeature[]
|
|
): [boolean, string] {
|
|
const missing = features.filter((f) => !config?.features?.[f]);
|
|
if (missing.length === 0) return [false, ''];
|
|
return [true, `Required feature(s) not enabled: ${missing.join(', ')}`];
|
|
}
|
|
|
|
/**
|
|
* Known Quay authentication types
|
|
*/
|
|
export type QuayAuthType =
|
|
| 'Database'
|
|
| 'LDAP'
|
|
| 'OIDC'
|
|
| 'AppToken'
|
|
| 'Keystone';
|
|
|
|
/**
|
|
* Helper to skip tests when the auth type is not one of the allowed types.
|
|
* Returns a tuple that can be spread into test.skip()
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* test('requires Database auth', async ({ quayConfig }) => {
|
|
* test.skip(...skipUnlessAuthType(quayConfig, 'Database'));
|
|
* // test code...
|
|
* });
|
|
*
|
|
* test('requires Database or AppToken', async ({ quayConfig }) => {
|
|
* test.skip(...skipUnlessAuthType(quayConfig, 'Database', 'AppToken'));
|
|
* // test code...
|
|
* });
|
|
* ```
|
|
*/
|
|
export function skipUnlessAuthType(
|
|
config: QuayConfig | null,
|
|
...allowedTypes: QuayAuthType[]
|
|
): [boolean, string] {
|
|
const authType = config?.config?.AUTHENTICATION_TYPE as string | undefined;
|
|
if (!authType) return [true, 'Auth type not available in config'];
|
|
if (allowedTypes.includes(authType as QuayAuthType)) return [false, ''];
|
|
return [
|
|
true,
|
|
`Auth type '${authType}' not in allowed types: ${allowedTypes.join(', ')}`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Login a user and return the API client (with cached CSRF token)
|
|
*/
|
|
async function loginUser(
|
|
request: APIRequestContext,
|
|
username: string,
|
|
password: string,
|
|
): Promise<ApiClient> {
|
|
const api = new ApiClient(request);
|
|
await api.signIn(username, password);
|
|
return api;
|
|
}
|
|
|
|
/**
|
|
* Extended test fixtures providing authenticated contexts
|
|
*/
|
|
type TestFixtures = {
|
|
// CSRF token for API calls (after login)
|
|
csrfToken: string;
|
|
|
|
// Pre-authenticated page as regular user
|
|
authenticatedPage: Page;
|
|
|
|
// Pre-authenticated page as superuser
|
|
superuserPage: Page;
|
|
|
|
// Pre-authenticated page as readonly user
|
|
readonlyPage: Page;
|
|
|
|
// Pre-authenticated API request context as regular user
|
|
authenticatedRequest: APIRequestContext;
|
|
|
|
// Pre-authenticated API request context as superuser
|
|
superuserRequest: APIRequestContext;
|
|
|
|
// Quay configuration (features, config settings)
|
|
quayConfig: QuayConfig;
|
|
|
|
// API client for regular user with auto-cleanup
|
|
api: TestApi;
|
|
|
|
// API client for superuser with auto-cleanup
|
|
superuserApi: TestApi;
|
|
|
|
// Auto-fixture: skips tests based on @feature: tags (runs automatically)
|
|
_autoSkipByFeature: void;
|
|
};
|
|
|
|
/**
|
|
* Worker fixtures (shared across tests in same worker)
|
|
*/
|
|
type WorkerFixtures = {
|
|
// Browser context with regular user auth
|
|
userContext: BrowserContext;
|
|
|
|
// Browser context with superuser auth
|
|
superuserContext: BrowserContext;
|
|
|
|
// Browser context with readonly user auth
|
|
readonlyContext: BrowserContext;
|
|
|
|
// Cached Quay config (fetched once per worker)
|
|
cachedQuayConfig: QuayConfig;
|
|
};
|
|
|
|
/**
|
|
* Extended test with custom fixtures
|
|
*/
|
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|
// =========================================================================
|
|
// Worker-scoped fixtures (created once per worker)
|
|
// =========================================================================
|
|
|
|
userContext: [
|
|
async ({browser}, use) => {
|
|
const context = await browser.newContext();
|
|
const request = context.request;
|
|
|
|
// Login as regular user
|
|
await loginUser(
|
|
request,
|
|
TEST_USERS.user.username,
|
|
TEST_USERS.user.password,
|
|
);
|
|
|
|
await use(context);
|
|
await context.close();
|
|
},
|
|
{scope: 'worker'},
|
|
],
|
|
|
|
superuserContext: [
|
|
async ({browser}, use) => {
|
|
const context = await browser.newContext();
|
|
const request = context.request;
|
|
|
|
// Login as admin (superuser)
|
|
await loginUser(
|
|
request,
|
|
TEST_USERS.admin.username,
|
|
TEST_USERS.admin.password,
|
|
);
|
|
|
|
await use(context);
|
|
await context.close();
|
|
},
|
|
{scope: 'worker'},
|
|
],
|
|
|
|
readonlyContext: [
|
|
async ({browser}, use) => {
|
|
const context = await browser.newContext();
|
|
const request = context.request;
|
|
|
|
// Login as readonly user
|
|
await loginUser(
|
|
request,
|
|
TEST_USERS.readonly.username,
|
|
TEST_USERS.readonly.password,
|
|
);
|
|
|
|
await use(context);
|
|
await context.close();
|
|
},
|
|
{scope: 'worker'},
|
|
],
|
|
|
|
cachedQuayConfig: [
|
|
async ({browser}, use) => {
|
|
// Create a temporary context just to fetch config
|
|
const context = await browser.newContext();
|
|
const response = await context.request.get(`${API_URL}/config`);
|
|
if (!response.ok()) {
|
|
await context.close();
|
|
throw new Error(`Failed to fetch Quay config: ${response.status()}`);
|
|
}
|
|
const config = (await response.json()) as QuayConfig;
|
|
await context.close();
|
|
await use(config);
|
|
},
|
|
{scope: 'worker'},
|
|
],
|
|
|
|
// =========================================================================
|
|
// Test-scoped fixtures (created fresh for each test)
|
|
// =========================================================================
|
|
|
|
csrfToken: async ({request}, use) => {
|
|
const api = await loginUser(
|
|
request,
|
|
TEST_USERS.user.username,
|
|
TEST_USERS.user.password,
|
|
);
|
|
const token = await api.getToken();
|
|
await use(token);
|
|
},
|
|
|
|
authenticatedPage: async ({userContext}, use) => {
|
|
const page = await userContext.newPage();
|
|
await use(page);
|
|
await page.close();
|
|
},
|
|
|
|
superuserPage: async ({superuserContext}, use) => {
|
|
const page = await superuserContext.newPage();
|
|
await use(page);
|
|
await page.close();
|
|
},
|
|
|
|
readonlyPage: async ({readonlyContext}, use) => {
|
|
const page = await readonlyContext.newPage();
|
|
await use(page);
|
|
await page.close();
|
|
},
|
|
|
|
authenticatedRequest: async ({userContext}, use) => {
|
|
await use(userContext.request);
|
|
},
|
|
|
|
superuserRequest: async ({superuserContext}, use) => {
|
|
await use(superuserContext.request);
|
|
},
|
|
|
|
quayConfig: async ({cachedQuayConfig}, use) => {
|
|
await use(cachedQuayConfig);
|
|
},
|
|
|
|
api: async ({authenticatedRequest}, use) => {
|
|
const client = new ApiClient(authenticatedRequest);
|
|
const testApi = new TestApi(client);
|
|
await use(testApi);
|
|
await testApi.cleanup();
|
|
},
|
|
|
|
superuserApi: async ({superuserRequest}, use) => {
|
|
const client = new ApiClient(superuserRequest);
|
|
const testApi = new TestApi(client);
|
|
await use(testApi);
|
|
await testApi.cleanup();
|
|
},
|
|
|
|
// =========================================================================
|
|
// Auto-fixture: Skip tests based on @feature: tags
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Automatically skip tests that have @feature:X tags when those
|
|
* features are not enabled in Quay config.
|
|
*
|
|
* This eliminates the need for manual `test.skip(...skipUnlessFeature(...))`
|
|
* calls in each test. Just add the tag to the describe block:
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* test.describe('Repository Mirroring', {tag: ['@feature:REPO_MIRROR']}, () => {
|
|
* test('creates mirror', async ({authenticatedPage}) => {
|
|
* // Auto-skipped if REPO_MIRROR is not enabled - no manual skip needed!
|
|
* });
|
|
* });
|
|
* ```
|
|
*/
|
|
_autoSkipByFeature: [
|
|
async ({quayConfig}, use, testInfo) => {
|
|
// Extract feature names from @feature:X tags
|
|
const featureTags = testInfo.tags
|
|
.filter((tag) => tag.startsWith('@feature:'))
|
|
.map((tag) => tag.replace('@feature:', '') as QuayFeature);
|
|
|
|
if (featureTags.length > 0) {
|
|
const [shouldSkip, reason] = skipUnlessFeature(
|
|
quayConfig,
|
|
...featureTags,
|
|
);
|
|
testInfo.skip(shouldSkip, reason);
|
|
}
|
|
|
|
await use();
|
|
},
|
|
{auto: true},
|
|
],
|
|
});
|
|
|
|
// Re-export expect for convenience
|
|
export {expect};
|
|
|
|
/**
|
|
* Utility to generate unique names for test resources
|
|
*/
|
|
export function uniqueName(prefix: string): string {
|
|
return `${prefix}-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.substring(2, 8)}`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mailpit: Re-export from utils for backward compatibility
|
|
// ============================================================================
|
|
|
|
export {
|
|
mailpit,
|
|
MailpitMessage,
|
|
MailpitMessagesResponse,
|
|
} from './utils/mailpit';
|