diff --git a/workspaces/x2a/package.json b/workspaces/x2a/package.json index 243348c088..21761194c2 100644 --- a/workspaces/x2a/package.json +++ b/workspaces/x2a/package.json @@ -20,7 +20,10 @@ "clean": "backstage-cli repo clean", "test": "backstage-cli repo test", "test:all": "yarn openapi-generate && yarn prettier:check && yarn lint:all && backstage-cli repo test --coverage", - "test:e2e": "echo Skipping until we have tests: playwright test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", "fix": "backstage-cli repo fix", "lint": "backstage-cli repo lint --since origin/main", "lint:all": "backstage-cli repo lint", diff --git a/workspaces/x2a/packages/app/e2e-tests/app.test.ts b/workspaces/x2a/packages/app/e2e-tests/app.test.ts index ca25d125bf..97708a5d16 100644 --- a/workspaces/x2a/packages/app/e2e-tests/app.test.ts +++ b/workspaces/x2a/packages/app/e2e-tests/app.test.ts @@ -15,20 +15,20 @@ */ import { test, expect } from '@playwright/test'; +import { performGuestLogin } from './fixtures/auth'; -// To be implemented later -test('noop test', async () => { - expect(true).toBe(true); -}); +const devMode = !process.env.PLAYWRIGHT_URL; -/* test('App should render the welcome page', async ({ page }) => { - await page.goto('/'); - - const enterButton = page.getByRole('button', { name: 'Enter' }); - await expect(enterButton).toBeVisible(); - await enterButton.click(); + await performGuestLogin(page); - await expect(page.getByText('My Company Catalog')).toBeVisible(); + if (devMode) { + await expect( + page.getByRole('heading', { name: 'Red Hat Catalog' }), + ).toBeVisible({ timeout: 10000 }); + } else { + await expect( + page.getByRole('heading', { name: 'Welcome back!' }), + ).toBeVisible({ timeout: 10000 }); + } }); -*/ diff --git a/workspaces/x2a/packages/app/e2e-tests/conversion-flow.test.ts b/workspaces/x2a/packages/app/e2e-tests/conversion-flow.test.ts new file mode 100644 index 0000000000..6b6e1d5bec --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/conversion-flow.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { X2AnsiblePage } from './pages/X2AnsiblePage'; + +test.describe('X2Ansible - Conversion Flow @live', () => { + let x2aPage: X2AnsiblePage; + + test.beforeEach(async ({ page }) => { + x2aPage = new X2AnsiblePage(page); + }); + + test.describe('Navigation and Page Load', () => { + test('should navigate to X2A page and display Conversion Hub', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + }); + + test('should navigate to X2A page via sidebar', async () => { + await x2aPage.navigateFromSidebar(); + await x2aPage.verifyConversionHubPage(); + }); + + test('should display Start first conversion button', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + + const startFirst = x2aPage.page.getByRole('button', { + name: /start first conversion/i, + }); + const newProject = x2aPage.page.getByRole('button', { + name: /new project/i, + }); + await expect(startFirst.or(newProject).first()).toBeVisible({ + timeout: 10000, + }); + }); + }); + + test.describe('Template Scaffolder', () => { + test('should load the scaffolder template when starting a conversion', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.clickStartFirstConversion(); + await x2aPage.verifyTemplateFormLoaded(); + }); + + test('should display all required form fields in step 1', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.clickStartFirstConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + await expect(x2aPage.page.getByLabel('Name')).toBeVisible(); + await expect(x2aPage.page.getByLabel('Description')).toBeVisible(); + await expect(x2aPage.page.getByLabel('Abbreviation')).toBeVisible(); + await expect(x2aPage.page.getByLabel('Owned by group')).toBeVisible(); + }); + + test('should have Next button on step 1', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.clickStartFirstConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + await expect( + x2aPage.page.getByRole('button', { name: 'Next' }), + ).toBeVisible(); + }); + }); + + test.describe('Happy Path - Full Conversion Wizard', () => { + test('should complete the full conversion wizard', async () => { + test.setTimeout(180000); + + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + await x2aPage.clickStartFirstConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + // Step 1: Job name and description + await x2aPage.fillProjectName('chef-examples-e2e-test'); + await x2aPage.fillDescription( + 'E2E test conversion of Chef examples repo', + ); + await x2aPage.fillAbbreviation('x2a'); + await x2aPage.fillOwnedByGroup('guests'); + + await x2aPage.clickNext(); + + // Step 2: Source and target repositories + await x2aPage.verifyRepositoryStepVisible(); + await x2aPage.dismissGitHubLoginDialog(); + + await x2aPage.fillSourceRepoOwner('chef'); + await x2aPage.fillSourceRepoName('chef-examples'); + + await x2aPage.clickNext(); + + // Step 3: Conversion parameters (optional prompt) + await x2aPage.dismissGitHubLoginDialog(); + await x2aPage.verifyConversionParamsVisible(); + await x2aPage.clickNext(); + + // Step 4: Review + await x2aPage.verifyReviewStepVisible(); + + await expect( + x2aPage.page.getByText('chef-examples-e2e-test'), + ).toBeVisible(); + + await x2aPage.clickCreate(); + + await x2aPage.verifyTaskSubmitted(); + }); + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/edit-project.test.ts b/workspaces/x2a/packages/app/e2e-tests/edit-project.test.ts new file mode 100644 index 0000000000..326852f97c --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/edit-project.test.ts @@ -0,0 +1,484 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * FLPATH-4211: Edit Project feature + * + * Tests the PATCH /projects/:projectId endpoint added in rhdh-plugins#3130. + * Verifies that project name, ownedBy, and description can be updated via API. + */ + +import { test, expect, request } from '@playwright/test'; +import { X2AnsiblePage } from './pages/X2AnsiblePage'; +import { performLogin } from './fixtures/auth'; + +const SOURCE_REPO = + process.env.X2A_SOURCE_REPO || 'https://github.com/chef/chef-examples.git'; +const TARGET_REPO = + process.env.X2A_TARGET_REPO || + 'https://github.com/rhdh-orchestrator-test/x2a-e2e-target.git'; + +let guestToken = ''; + +async function getGuestToken(baseURL: string): Promise { + if (guestToken) return guestToken; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post('/api/auth/guest/refresh'); + const data = await resp.json(); + await ctx.dispose(); + guestToken = data?.backstageIdentity?.token ?? ''; + return guestToken; +} + +async function apiHeaders(baseURL: string) { + const token = await getGuestToken(baseURL); + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; +} + +async function createProject(baseURL: string, suffix: string) { + const headers = await apiHeaders(baseURL); + const name = `x2a-edit-e2e-${suffix}-${Date.now()}`; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post('/api/x2a/projects', { + headers, + data: { + name, + abbreviation: 'x2a', + description: `FLPATH-4211 edit project test: ${suffix}`, + sourceRepoUrl: SOURCE_REPO, + targetRepoUrl: TARGET_REPO, + sourceRepoBranch: 'main', + targetRepoBranch: 'main', + }, + }); + expect(resp.ok(), `Failed to create project: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function getProject(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.get(`/api/x2a/projects/${projectId}`, { headers }); + expect(resp.ok(), `Failed to get project: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function patchProject( + baseURL: string, + projectId: string, + body: Record, +) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.patch(`/api/x2a/projects/${projectId}`, { + headers, + data: body, + }); + const data = resp.ok() ? await resp.json() : null; + const status = resp.status(); + await ctx.dispose(); + return { status, data }; +} + +async function deleteProject(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + await ctx.delete(`/api/x2a/projects/${projectId}`, { headers }); + await ctx.dispose(); +} + +test.describe('X2Ansible - FLPATH-4211 Edit Project @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL || 'http://localhost:3000'; + const createdProjects: string[] = []; + + test.afterAll(async () => { + for (const pid of createdProjects) { + await deleteProject(baseURL, pid).catch(() => {}); + } + }); + + test('should update project name via PATCH', async () => { + const project = await createProject(baseURL, 'rename'); + createdProjects.push(project.id); + // eslint-disable-next-line no-console + console.log(`Created project: ${project.name} (${project.id})`); + + const newName = `x2a-edit-renamed-${Date.now()}`; + const { status, data } = await patchProject(baseURL, project.id, { + name: newName, + }); + // eslint-disable-next-line no-console + console.log(`PATCH name response: ${status}`); + expect(status).toBe(200); + expect(data.name).toBe(newName); + + const fetched = await getProject(baseURL, project.id); + expect(fetched.name).toBe(newName); + // eslint-disable-next-line no-console + console.log(`Verified project name updated to: ${fetched.name}`); + }); + + test('should update project description via PATCH', async () => { + const project = await createProject(baseURL, 'desc'); + createdProjects.push(project.id); + + const newDesc = 'Updated description for FLPATH-4211 test'; + const { status, data } = await patchProject(baseURL, project.id, { + description: newDesc, + }); + // eslint-disable-next-line no-console + console.log(`PATCH description response: ${status}`); + expect(status).toBe(200); + expect(data.description).toBe(newDesc); + + const fetched = await getProject(baseURL, project.id); + expect(fetched.description).toBe(newDesc); + // eslint-disable-next-line no-console + console.log(`Verified description updated`); + }); + + test('should update project ownedBy via PATCH', async () => { + const project = await createProject(baseURL, 'owner'); + createdProjects.push(project.id); + + const newOwner = 'user:default/test-user'; + const { status, data } = await patchProject(baseURL, project.id, { + ownedBy: newOwner, + }); + // eslint-disable-next-line no-console + console.log(`PATCH ownedBy response: ${status}`); + expect(status).toBe(200); + expect(data.ownedBy).toBe(newOwner); + + const fetched = await getProject(baseURL, project.id); + expect(fetched.ownedBy).toBe(newOwner); + // eslint-disable-next-line no-console + console.log(`Verified ownedBy updated to: ${fetched.ownedBy}`); + }); + + test('should update multiple fields at once via PATCH', async () => { + const project = await createProject(baseURL, 'multi'); + createdProjects.push(project.id); + + const updates = { + name: `x2a-edit-multi-${Date.now()}`, + description: 'Multi-field update test', + ownedBy: 'user:default/multi-test', + }; + const { status, data } = await patchProject(baseURL, project.id, updates); + // eslint-disable-next-line no-console + console.log(`PATCH multi-field response: ${status}`); + expect(status).toBe(200); + expect(data.name).toBe(updates.name); + expect(data.description).toBe(updates.description); + expect(data.ownedBy).toBe(updates.ownedBy); + + const fetched = await getProject(baseURL, project.id); + expect(fetched.name).toBe(updates.name); + expect(fetched.description).toBe(updates.description); + expect(fetched.ownedBy).toBe(updates.ownedBy); + // eslint-disable-next-line no-console + console.log('Verified all fields updated in single PATCH'); + }); + + test('should return 400 for empty PATCH body', async () => { + const project = await createProject(baseURL, 'empty'); + createdProjects.push(project.id); + + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.patch(`/api/x2a/projects/${project.id}`, { + headers, + data: {}, + }); + // eslint-disable-next-line no-console + console.log(`PATCH empty body response: ${resp.status()}`); + expect(resp.status()).toBe(400); + await ctx.dispose(); + }); + + test('should return 404 for PATCH on non-existent project', async () => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + const { status } = await patchProject(baseURL, fakeId, { + name: 'ghost', + }); + // eslint-disable-next-line no-console + console.log(`PATCH non-existent project response: ${status}`); + expect(status).toBe(404); + }); + + test('should preserve unchanged fields after PATCH', async () => { + const project = await createProject(baseURL, 'preserve'); + createdProjects.push(project.id); + + const original = await getProject(baseURL, project.id); + const originalDesc = original.description; + const originalOwner = original.ownedBy; + + const newName = `x2a-edit-preserve-${Date.now()}`; + await patchProject(baseURL, project.id, { name: newName }); + + const fetched = await getProject(baseURL, project.id); + expect(fetched.name).toBe(newName); + expect(fetched.description).toBe(originalDesc); + expect(fetched.ownedBy).toBe(originalOwner); + // eslint-disable-next-line no-console + console.log('Verified unchanged fields preserved after partial PATCH'); + }); + + test('should not change dirName when project name is updated', async () => { + const project = await createProject(baseURL, 'dirname'); + createdProjects.push(project.id); + + const original = await getProject(baseURL, project.id); + const originalDirName = original.dirName; + // eslint-disable-next-line no-console + console.log(`Original dirName: ${originalDirName}`); + + const newName = `x2a-edit-dirname-changed-${Date.now()}`; + await patchProject(baseURL, project.id, { name: newName }); + + const fetched = await getProject(baseURL, project.id); + expect(fetched.name).toBe(newName); + expect(fetched.dirName).toBe(originalDirName); + // eslint-disable-next-line no-console + console.log( + `Verified dirName unchanged (${fetched.dirName}) after name update`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// UI Tests: Edit Project Dialog +// --------------------------------------------------------------------------- + +test.describe.serial('X2Ansible - FLPATH-4211 Edit Project UI @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL || 'http://localhost:3000'; + let projectId = ''; + let projectName = ''; + + test.afterAll(async () => { + if (projectId) { + await deleteProject(baseURL, projectId).catch(() => {}); + } + }); + + test('Setup: create a project via API for UI tests', async () => { + const project = await createProject(baseURL, 'ui'); + projectId = project.id; + projectName = project.name; + // eslint-disable-next-line no-console + console.log(`Created project for UI tests: ${projectName} (${projectId})`); + }); + + test('should display edit button on project details page', async ({ + page, + }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await expect(editButton).toBeVisible({ timeout: 15000 }); + // eslint-disable-next-line no-console + console.log('Edit button visible on project details page'); + }); + + test('should open edit dialog and show current values', async ({ page }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await expect(dialog.getByText('Edit project')).toBeVisible(); + + const nameField = dialog.locator('input').first(); + await expect(nameField).toHaveValue(projectName); + + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Update' })).toBeVisible(); + + // eslint-disable-next-line no-console + console.log('Edit dialog opened with correct current values'); + }); + + test('should update project name via UI dialog', async ({ page }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + const newName = `x2a-ui-renamed-${Date.now()}`; + const nameField = dialog.locator('input').first(); + await nameField.clear(); + await nameField.fill(newName); + + const updateButton = dialog.getByRole('button', { name: 'Update' }); + await updateButton.click(); + + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + await expect(page.getByText(newName)).toBeVisible({ timeout: 10000 }); + projectName = newName; + + const fetched = await getProject(baseURL, projectId); + expect(fetched.name).toBe(newName); + // eslint-disable-next-line no-console + console.log(`UI: project name updated to ${newName}`); + }); + + test('should update description via UI dialog', async ({ page }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + const newDesc = 'UI-updated description for FLPATH-4211'; + const descField = dialog.locator('textarea').first(); + await descField.clear(); + await descField.fill(newDesc); + + const updateButton = dialog.getByRole('button', { name: 'Update' }); + await updateButton.click(); + + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + await expect(page.getByText(newDesc)).toBeVisible({ timeout: 10000 }); + + const fetched = await getProject(baseURL, projectId); + expect(fetched.description).toBe(newDesc); + // eslint-disable-next-line no-console + console.log(`UI: description updated`); + }); + + test('should cancel edit dialog without saving changes', async ({ page }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + const nameField = dialog.locator('input').first(); + await nameField.clear(); + await nameField.fill('this-should-not-be-saved'); + + const cancelButton = dialog.getByRole('button', { name: 'Cancel' }); + await cancelButton.click(); + + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + const fetched = await getProject(baseURL, projectId); + expect(fetched.name).toBe(projectName); + // eslint-disable-next-line no-console + console.log('UI: cancel discarded changes correctly'); + }); + + test('should disable Update button when no changes made', async ({ + page, + }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + const updateButton = dialog.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeDisabled(); + + // eslint-disable-next-line no-console + console.log('UI: Update button correctly disabled when no changes'); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + }); + + test('should disable Update button when name is empty', async ({ page }) => { + await performLogin(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const editButton = page.getByRole('button', { name: /edit/i }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + const nameField = dialog.locator('input').first(); + await nameField.clear(); + + const updateButton = dialog.getByRole('button', { name: 'Update' }); + await expect(updateButton).toBeDisabled(); + + // eslint-disable-next-line no-console + console.log('UI: Update button correctly disabled when name is empty'); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/fixtures/auth.ts b/workspaces/x2a/packages/app/e2e-tests/fixtures/auth.ts new file mode 100644 index 0000000000..ee5a9b0cca --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/fixtures/auth.ts @@ -0,0 +1,53 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Page } from '@playwright/test'; + +export async function performGuestLogin(page: Page) { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + + const enterButton = page.locator('button:has-text("Enter")'); + await enterButton.waitFor({ state: 'visible', timeout: 15000 }); + await enterButton.click(); + + await page + .locator('nav') + .first() + .waitFor({ state: 'visible', timeout: 60000 }); +} + +export async function performLogin( + page: Page, + _username?: string, + _password?: string, +) { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + + const nav = page.locator('nav').first(); + const enterButton = page.locator('button:has-text("Enter")'); + + try { + await enterButton.waitFor({ state: 'visible', timeout: 15000 }); + await enterButton.click(); + } catch { + if (await nav.isVisible()) return; + throw new Error('Neither Enter button nor nav appeared within timeout'); + } + + await nav.waitFor({ state: 'visible', timeout: 60000 }); +} diff --git a/workspaces/x2a/packages/app/e2e-tests/navigation.test.ts b/workspaces/x2a/packages/app/e2e-tests/navigation.test.ts new file mode 100644 index 0000000000..0bce26e87b --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/navigation.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { X2AnsiblePage } from './pages/X2AnsiblePage'; + +test.describe('X2Ansible - Navigation @live', () => { + let x2aPage: X2AnsiblePage; + + test.beforeEach(async ({ page }) => { + x2aPage = new X2AnsiblePage(page); + }); + + test('should navigate to X2A page via direct URL', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + }); + + test('should navigate to X2A via sidebar link', async () => { + await x2aPage.navigateFromSidebar(); + await x2aPage.verifyConversionHubPage(); + }); + + test('should show correct URL when on X2A page', async () => { + await x2aPage.navigateToX2AByUrl(); + expect(x2aPage.page.url()).toContain('/x2a'); + }); + + test('should navigate to scaffolder and back', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + + await x2aPage.clickStartFirstConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + await x2aPage.page.goBack(); + await x2aPage.waitForPageLoad(); + await x2aPage.verifyConversionHubPage(); + }); + + test('should navigate through wizard steps and back', async () => { + await x2aPage.navigateToX2AByUrl(); + await x2aPage.clickStartFirstConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + await x2aPage.fillProjectName('nav-test'); + await x2aPage.fillOwnedByGroup('guests'); + await x2aPage.clickNext(); + await x2aPage.verifyRepositoryStepVisible(); + await x2aPage.dismissGitHubLoginDialog(); + + await x2aPage.clickBack(); + await x2aPage.verifyTemplateFormLoaded(); + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/pages/X2AnsiblePage.ts b/workspaces/x2a/packages/app/e2e-tests/pages/X2AnsiblePage.ts new file mode 100644 index 0000000000..bb317f6972 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/pages/X2AnsiblePage.ts @@ -0,0 +1,382 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Page, expect } from '@playwright/test'; +import { performLogin } from '../fixtures/auth'; + +export class X2AnsiblePage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async login() { + await performLogin(this.page); + } + + async navigateToX2A() { + await this.login(); + await this.page.locator('nav a[href*="x2a"]').click(); + await this.waitForPageLoad(); + } + + async navigateToX2AByUrl() { + await this.login(); + await this.page.goto('/x2a'); + await this.waitForPageLoad(); + } + + async navigateFromSidebar() { + await this.login(); + const x2aLink = this.page.locator('nav a[href*="x2a"], nav [href*="x2a"]'); + await expect(x2aLink.first()).toBeVisible({ timeout: 10000 }); + await x2aLink.first().click(); + await this.waitForPageLoad(); + } + + async waitForPageLoad() { + await this.page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + await this.page.waitForTimeout(2000); + } + + async verifyConversionHubPage() { + const heading = this.page.getByText('Conversion Hub'); + await expect(heading).toBeVisible({ timeout: 15000 }); + } + + async clickStartConversion() { + const startFirst = this.page.getByRole('button', { + name: /start first conversion/i, + }); + const newProject = this.page.getByRole('button', { + name: /new project/i, + }); + const button = startFirst.or(newProject); + await expect(button.first()).toBeVisible({ timeout: 10000 }); + await button.first().click(); + await this.waitForPageLoad(); + } + + /** @deprecated use clickStartConversion() */ + async clickStartFirstConversion() { + return this.clickStartConversion(); + } + + async verifyTemplateFormLoaded() { + await expect( + this.page.getByText('Chef-to-Ansible Conversion Project'), + ).toBeVisible({ timeout: 15000 }); + } + + // --- Step 1: Job name and description --- + + async fillProjectName(name: string) { + const field = this.page.getByLabel('Name'); + await expect(field).toBeVisible({ timeout: 5000 }); + await field.fill(name); + } + + async fillDescription(description: string) { + const field = this.page.getByLabel('Description'); + await expect(field).toBeVisible({ timeout: 5000 }); + await field.fill(description); + } + + async fillAbbreviation(abbreviation: string) { + const field = this.page.getByLabel('Abbreviation'); + await expect(field).toBeVisible({ timeout: 5000 }); + await field.clear(); + await field.fill(abbreviation); + } + + async fillOwnedByGroup(group: string) { + const field = this.page.getByLabel('Owned by group'); + await expect(field).toBeVisible({ timeout: 5000 }); + await field.fill(group); + } + + async clickNext() { + const nextButton = this.page.getByRole('button', { name: 'Next' }); + const reviewButton = this.page.getByRole('button', { name: 'Review' }); + const button = nextButton.or(reviewButton); + await expect(button.first()).toBeVisible({ timeout: 10000 }); + await button.first().click(); + await this.page.waitForTimeout(2000); + } + + async clickBack() { + const button = this.page.getByRole('button', { name: 'Back' }); + await expect(button).toBeVisible({ timeout: 5000 }); + await button.click(); + await this.page.waitForTimeout(1000); + } + + // --- Step 2: Source and target repositories --- + + async verifyRepositoryStepVisible() { + await expect( + this.page.getByText('Conversion source repository', { exact: true }), + ).toBeVisible({ timeout: 10000 }); + } + + async dismissGitHubLoginDialog() { + const dialog = this.page.locator('[role="dialog"]'); + const isDialogVisible = await dialog + .first() + .isVisible({ timeout: 5000 }) + .catch(() => false); + if (isDialogVisible) { + const dismissBtn = this.page.locator( + '[role="dialog"] button:has-text("Reject All"), ' + + '[role="dialog"] button:has-text("Close"), ' + + '[role="dialog"] button:has-text("Cancel"), ' + + '[role="dialog"] button[aria-label="Close"]', + ); + if ( + await dismissBtn + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await dismissBtn.first().click(); + } else { + await this.page.keyboard.press('Escape'); + } + await this.page.waitForTimeout(1000); + } + + const popup = this.page + .context() + .pages() + .find(p => p !== this.page); + if (popup && !popup.isClosed()) { + await popup.close(); + await this.page.waitForTimeout(500); + } + } + + async handleGitHubLoginDialog() { + const loginButton = this.page.getByRole('button', { + name: /Log in/i, + }); + if (!(await loginButton.isVisible({ timeout: 5000 }).catch(() => false))) { + return; + } + const popupPromise = this.page.waitForEvent('popup', { timeout: 15000 }); + await loginButton.click(); + const popup = await popupPromise.catch(() => null); + if (!popup) return; + + try { + await popup.waitForEvent('close', { timeout: 3000 }); + } catch { + if (!popup.isClosed()) { + const authorizeButton = popup.locator('button:has-text("Authorize")'); + if ( + await authorizeButton + .first() + .isVisible({ timeout: 5000 }) + .catch(() => false) + ) { + await popup + .evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const auth = buttons.find( + b => + b.textContent?.includes('Authorize') && + !b.textContent?.includes('Cancel'), + ); + auth?.click(); + }) + .catch(() => {}); + } + if (!popup.isClosed()) { + await popup.waitForEvent('close', { timeout: 30000 }).catch(() => {}); + } + } + } + await this.page.waitForTimeout(1000); + } + + async fillSourceRepoOwner(owner: string) { + const combobox = this.page + .locator('div', { hasText: /^Owner/ }) + .locator('input[role="textbox"], input') + .first(); + await expect(combobox).toBeVisible({ timeout: 5000 }); + await combobox.fill(owner); + } + + async fillSourceRepoName(repo: string) { + const combobox = this.page + .locator('div', { hasText: /^Repository/ }) + .locator('input[role="textbox"], input') + .first(); + await expect(combobox).toBeVisible({ timeout: 5000 }); + await combobox.fill(repo); + } + + async fillSourceRepoBranch(branch: string) { + const field = this.page.getByLabel('Conversion source repository branch'); + if (await field.isVisible().catch(() => false)) { + await field.clear(); + await field.fill(branch); + } + } + + // --- Step 3: Conversion parameters --- + + async verifyConversionParamsVisible() { + await expect( + this.page.getByText('User prompt', { exact: true }), + ).toBeVisible({ timeout: 10000 }); + } + + async fillUserPrompt(prompt: string) { + const textarea = this.page.getByLabel('User prompt'); + await textarea.fill(prompt); + } + + // --- Review step --- + + async verifyReviewStepVisible() { + await expect(this.page.getByText('Review')).toBeVisible({ + timeout: 10000, + }); + } + + async clickCreate() { + const button = this.page.getByRole('button', { name: 'Create' }); + await expect(button).toBeVisible({ timeout: 5000 }); + await button.click(); + } + + // --- Output / Results --- + + async verifyTaskSubmitted() { + await expect( + this.page.getByText(/Run of.*Chef-to-Ansible Conversion Project/), + ).toBeVisible({ timeout: 30000 }); + } + + async verifyTaskSucceeded() { + const manageLink = this.page.getByRole('link', { + name: 'Manage the project', + }); + await expect(manageLink).toBeVisible({ timeout: 120000 }); + } + + async verifyTaskError(expectedError?: string) { + if (expectedError) { + await expect(this.page.getByText(expectedError)).toBeVisible({ + timeout: 30000, + }); + } else { + await expect(this.page.locator('alert')).toBeVisible({ timeout: 30000 }); + } + } + + async getStepCount(): Promise { + const steps = this.page.locator('[class*="MuiStep"]'); + return steps.count(); + } + + // --- Module Page (phase execution) --- + + async navigateToModulePage(projectId: string, moduleId: string) { + await this.login(); + await this.page.goto(`/x2a/projects/${projectId}/modules/${moduleId}`); + await this.waitForPageLoad(); + await this.dismissGitHubLoginDialog(); + } + + async clickPhaseTab(phase: 'Analyze' | 'Migrate' | 'Publish') { + const tab = this.page.locator(`[role="tab"]:has-text("${phase}")`); + await expect(tab).toBeVisible({ timeout: 10000 }); + const isDisabled = await tab.getAttribute('aria-disabled'); + if (isDisabled === 'true') { + throw new Error( + `${phase} tab is disabled — prerequisite phase not complete`, + ); + } + await tab.click(); + await this.page.waitForTimeout(500); + } + + async clickRunPhaseButton(buttonText: string) { + const button = this.page.getByRole('button', { name: buttonText }); + await expect(button).toBeVisible({ timeout: 10000 }); + await button.click(); + await this.page.waitForTimeout(2000); + } + + async waitForPhaseStatus( + phase: 'Analyze' | 'Migrate' | 'Publish', + expectedStatus: string, + timeoutMs = 420000, + ) { + await this.clickPhaseTab(phase); + await this.page.waitForTimeout(1000); + const statusLocator = this.page + .getByText(new RegExp(expectedStatus, 'i')) + .first(); + try { + await expect(statusLocator).toBeVisible({ timeout: timeoutMs }); + } catch { + const body = await this.page.content(); + const snippet = body.slice(0, 2000); + // eslint-disable-next-line no-console + console.log( + `waitForPhaseStatus('${phase}', '${expectedStatus}') failed. Page snippet:\n${snippet}`, + ); + throw new Error( + `Phase ${phase}: expected "${expectedStatus}" not found on page`, + ); + } + } + + async getPhaseStatus( + phase: 'Analyze' | 'Migrate' | 'Publish', + ): Promise { + await this.clickPhaseTab(phase); + await this.page.waitForTimeout(1000); + const chip = this.page + .locator( + '[class*="Chip"]:visible, [class*="chip"]:visible, [class*="status"]:visible', + ) + .first(); + return (await chip.textContent()) ?? 'unknown'; + } + + async runAnalyze() { + await this.clickPhaseTab('Analyze'); + await this.clickRunPhaseButton('Create module migration plan'); + await this.dismissGitHubLoginDialog(); + } + + async runMigrate() { + await this.clickPhaseTab('Migrate'); + await this.clickRunPhaseButton('Migrate module sources'); + await this.dismissGitHubLoginDialog(); + } + + async runPublish() { + await this.clickPhaseTab('Publish'); + await this.clickRunPhaseButton('Publish to target repository'); + await this.dismissGitHubLoginDialog(); + } +} diff --git a/workspaces/x2a/packages/app/e2e-tests/pipeline-phases.test.ts b/workspaces/x2a/packages/app/e2e-tests/pipeline-phases.test.ts new file mode 100644 index 0000000000..f8ec199041 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/pipeline-phases.test.ts @@ -0,0 +1,411 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, request } from '@playwright/test'; +import { X2AnsiblePage } from './pages/X2AnsiblePage'; +import { performLogin } from './fixtures/auth'; + +const POLL_INTERVAL = 10_000; +const INIT_TIMEOUT = 300_000; +const PHASE_TIMEOUT = 420_000; +const SOURCE_REPO = + process.env.X2A_SOURCE_REPO || 'https://github.com/chef/chef-examples.git'; +const TARGET_REPO = + process.env.X2A_TARGET_REPO || + 'https://github.com/rhdh-orchestrator-test/x2a-e2e-target.git'; + +interface ProjectState { + projectId: string; + projectName: string; + moduleId: string; + moduleName: string; + token: string; +} + +const state: ProjectState = { + projectId: '', + projectName: '', + moduleId: '', + moduleName: '', + token: '', +}; + +async function getGuestToken(baseURL: string): Promise { + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post('/api/auth/guest/refresh'); + const data = await resp.json(); + await ctx.dispose(); + return data?.backstageIdentity?.token ?? ''; +} + +async function apiHeaders(baseURL: string) { + if (!state.token) { + state.token = await getGuestToken(baseURL); + } + return { + Authorization: `Bearer ${state.token}`, + 'Content-Type': 'application/json', + }; +} + +async function createProject(baseURL: string) { + const headers = await apiHeaders(baseURL); + const name = `x2a-ui-e2e-${Date.now()}`; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post('/api/x2a/projects', { + headers, + data: { + name, + abbreviation: 'x2a', + description: `UI E2E pipeline test: ${name}`, + sourceRepoUrl: SOURCE_REPO, + targetRepoUrl: TARGET_REPO, + sourceRepoBranch: 'main', + targetRepoBranch: 'main', + }, + }); + expect(resp.ok()).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function triggerInit(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ghToken = process.env.GITHUB_TOKEN || 'placeholder'; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post(`/api/x2a/projects/${projectId}/run`, { + headers, + data: { + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }); + expect(resp.ok()).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function pollProjectState( + baseURL: string, + projectId: string, + timeoutMs: number, +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const deadline = Date.now() + timeoutMs; + let lastState = ''; + while (Date.now() < deadline) { + const resp = await ctx.get(`/api/x2a/projects/${projectId}`, { headers }); + const data = await resp.json(); + lastState = data?.status?.state ?? 'unknown'; + if (['success', 'initialized', 'failed', 'error'].includes(lastState)) { + await ctx.dispose(); + return lastState; + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + throw new Error( + `Project ${projectId} did not reach terminal state within ${timeoutMs}ms (last: ${lastState})`, + ); +} + +async function getModules(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.get(`/api/x2a/projects/${projectId}/modules`, { + headers, + }); + expect(resp.ok()).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function triggerModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', +) { + const headers = await apiHeaders(baseURL); + const ghToken = process.env.GITHUB_TOKEN || 'placeholder'; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post( + `/api/x2a/projects/${projectId}/modules/${moduleId}/run`, + { + headers, + data: { + phase, + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }, + ); + expect( + resp.ok(), + `Failed to trigger ${phase}: ${resp.status()}`, + ).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function pollModuleStatus( + baseURL: string, + projectId: string, + moduleId: string, + timeoutMs: number, +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const deadline = Date.now() + timeoutMs; + let lastStatus = ''; + while (Date.now() < deadline) { + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + if (resp.ok()) { + const data = await resp.json(); + lastStatus = data?.status ?? 'unknown'; + if (['success', 'failed', 'error'].includes(lastStatus)) { + await ctx.dispose(); + return lastStatus; + } + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + throw new Error( + `Module did not reach terminal state within ${timeoutMs}ms (last: ${lastStatus})`, + ); +} + +async function deleteProject(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + await ctx.delete(`/api/x2a/projects/${projectId}`, { headers }); + await ctx.dispose(); +} + +test.describe.serial('X2Ansible - Pipeline Phases @live', () => { + let x2aPage: X2AnsiblePage; + const baseURL = process.env.PLAYWRIGHT_URL || 'http://localhost:3000'; + + test.beforeEach(async ({ page }) => { + x2aPage = new X2AnsiblePage(page); + }); + + test.afterAll(async () => { + if (state.projectId) { + await deleteProject(baseURL, state.projectId).catch(() => {}); + } + }); + + test('Phase 1: Create project and trigger init via API', async () => { + test.setTimeout(INIT_TIMEOUT + 60_000); + + const project = await createProject(baseURL); + state.projectId = project.id; + state.projectName = project.name; + // eslint-disable-next-line no-console + console.log(`Created project: ${project.name} (${project.id})`); + + const initData = await triggerInit(baseURL, project.id); + // eslint-disable-next-line no-console + console.log(`Init triggered: jobId=${initData.jobId}`); + + const finalState = await pollProjectState( + baseURL, + project.id, + INIT_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Init completed with state: ${finalState}`); + + expect( + ['success', 'initialized'].includes(finalState), + `Init did not succeed, state=${finalState}`, + ).toBeTruthy(); + + const modules = await getModules(baseURL, project.id); + // eslint-disable-next-line no-console + console.log(`Discovered ${modules.length} modules`); + expect(modules.length).toBeGreaterThan(0); + state.moduleId = modules[0].id; + state.moduleName = modules[0].name; + // eslint-disable-next-line no-console + console.log( + `Discovered ${modules.length} modules. Using: ${modules[0].name} (${modules[0].id})`, + ); + }); + + test('Phase 2: Run Analyze and verify via UI', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect( + state.moduleId, + 'Module ID not set — init phase may have failed', + ).toBeTruthy(); + + // eslint-disable-next-line no-console + console.log('Triggering Analyze phase via API...'); + const result = await triggerModulePhase( + baseURL, + state.projectId, + state.moduleId, + 'analyze', + ); + // eslint-disable-next-line no-console + console.log(`Analyze triggered: jobId=${result.jobId}`); + + const status = await pollModuleStatus( + baseURL, + state.projectId, + state.moduleId, + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Analyze API status: ${status}`); + expect(status, `Analyze failed with status=${status}`).toBe('success'); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + await x2aPage.waitForPhaseStatus('Analyze', 'Success', 30_000); + // eslint-disable-next-line no-console + console.log('Analyze phase verified in UI'); + }); + + test('Phase 3: Run Migrate and verify via UI', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect(state.moduleId, 'Module ID not set').toBeTruthy(); + + // eslint-disable-next-line no-console + console.log('Triggering Migrate phase via API...'); + const result = await triggerModulePhase( + baseURL, + state.projectId, + state.moduleId, + 'migrate', + ); + // eslint-disable-next-line no-console + console.log(`Migrate triggered: jobId=${result.jobId}`); + + const status = await pollModuleStatus( + baseURL, + state.projectId, + state.moduleId, + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Migrate API status: ${status}`); + expect(status, `Migrate failed with status=${status}`).toBe('success'); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + await x2aPage.waitForPhaseStatus('Migrate', 'Success', 30_000); + // eslint-disable-next-line no-console + console.log('Migrate phase verified in UI'); + }); + + test('Phase 4: Run Publish and verify via UI', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect(state.moduleId, 'Module ID not set').toBeTruthy(); + + // eslint-disable-next-line no-console + console.log('Triggering Publish phase via API...'); + const result = await triggerModulePhase( + baseURL, + state.projectId, + state.moduleId, + 'publish', + ); + // eslint-disable-next-line no-console + console.log(`Publish triggered: jobId=${result.jobId}`); + + const status = await pollModuleStatus( + baseURL, + state.projectId, + state.moduleId, + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Publish API status: ${status}`); + expect(status, `Publish failed with status=${status}`).toBe('success'); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + await x2aPage.waitForPhaseStatus('Publish', 'Success', 30_000); + // eslint-disable-next-line no-console + console.log('Publish phase verified in UI'); + }); + + test('Phase 5: Verify all phases completed', async ({ page }) => { + test.setTimeout(60_000); + expect(state.moduleId, 'Module ID not set').toBeTruthy(); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + + const analyzeStatus = await x2aPage.getPhaseStatus('Analyze'); + // eslint-disable-next-line no-console + console.log(`Analyze status: ${analyzeStatus}`); + expect(analyzeStatus).toContain('Success'); + + const migrateStatus = await x2aPage.getPhaseStatus('Migrate'); + // eslint-disable-next-line no-console + console.log(`Migrate status: ${migrateStatus}`); + expect(migrateStatus).toContain('Success'); + + const publishStatus = await x2aPage.getPhaseStatus('Publish'); + // eslint-disable-next-line no-console + console.log(`Publish status: ${publishStatus}`); + expect(publishStatus).toContain('Success'); + + // eslint-disable-next-line no-console + console.log('All pipeline phases verified successfully'); + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/source-dir-resolution.test.ts b/workspaces/x2a/packages/app/e2e-tests/source-dir-resolution.test.ts new file mode 100644 index 0000000000..d2211e4554 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/source-dir-resolution.test.ts @@ -0,0 +1,416 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * FLPATH-4215: Source dir resolution when source_dir is "." + * + * Uses chef-examples-metadata repo which has a cookbook at the repo root + * (metadata.rb, recipes/, attributes/) so x2a discovers a module with + * sourcePath="." — the exact scenario the fix (x2a-convertor#194) addresses. + * + * Before the fix, the export agent failed to resolve paths for the migration + * plan context when source_dir was ".". The fix adds source-path frontmatter + * to migration plans and passes the high-level plan to the export agent. + */ + +import { test, expect, request } from '@playwright/test'; +import { X2AnsiblePage } from './pages/X2AnsiblePage'; +import { performLogin } from './fixtures/auth'; + +const POLL_INTERVAL = 10_000; +const INIT_TIMEOUT = 300_000; +const PHASE_TIMEOUT = 420_000; +const SOURCE_REPO_METADATA = + process.env.X2A_SOURCE_REPO_METADATA || + 'https://github.com/x2ansible/chef-examples-metadata.git'; +const TARGET_REPO = + process.env.X2A_TARGET_REPO || + 'https://github.com/rhdh-orchestrator-test/x2a-e2e-target.git'; + +interface ProjectState { + projectId: string; + projectName: string; + moduleId: string; + moduleName: string; + modulePath: string; + token: string; +} + +const state: ProjectState = { + projectId: '', + projectName: '', + moduleId: '', + moduleName: '', + modulePath: '', + token: '', +}; + +async function getGuestToken(baseURL: string): Promise { + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post('/api/auth/guest/refresh'); + const data = await resp.json(); + await ctx.dispose(); + return data?.backstageIdentity?.token ?? ''; +} + +async function apiHeaders(baseURL: string) { + if (!state.token) { + state.token = await getGuestToken(baseURL); + } + return { + Authorization: `Bearer ${state.token}`, + 'Content-Type': 'application/json', + }; +} + +async function createProject(baseURL: string) { + const headers = await apiHeaders(baseURL); + const name = `x2a-srcdir-e2e-${Date.now()}`; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post('/api/x2a/projects', { + headers, + data: { + name, + abbreviation: 'x2a', + description: `FLPATH-4215: source_dir=. resolution test: ${name}`, + sourceRepoUrl: SOURCE_REPO_METADATA, + targetRepoUrl: TARGET_REPO, + sourceRepoBranch: 'main', + targetRepoBranch: 'main', + }, + }); + expect(resp.ok()).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function triggerInit(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ghToken = process.env.GITHUB_TOKEN || 'placeholder'; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post(`/api/x2a/projects/${projectId}/run`, { + headers, + data: { + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }); + expect(resp.ok()).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function pollProjectState( + baseURL: string, + projectId: string, + timeoutMs: number, +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const deadline = Date.now() + timeoutMs; + let lastState = ''; + while (Date.now() < deadline) { + const resp = await ctx.get(`/api/x2a/projects/${projectId}`, { headers }); + const data = await resp.json(); + lastState = data?.status?.state ?? 'unknown'; + if (['success', 'initialized', 'failed', 'error'].includes(lastState)) { + await ctx.dispose(); + return lastState; + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + throw new Error( + `Project ${projectId} did not reach terminal state within ${timeoutMs}ms (last: ${lastState})`, + ); +} + +async function getModules(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.get(`/api/x2a/projects/${projectId}/modules`, { + headers, + }); + expect(resp.ok()).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function triggerModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', +) { + const headers = await apiHeaders(baseURL); + const ghToken = process.env.GITHUB_TOKEN || 'placeholder'; + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const resp = await ctx.post( + `/api/x2a/projects/${projectId}/modules/${moduleId}/run`, + { + headers, + data: { + phase, + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }, + ); + expect( + resp.ok(), + `Failed to trigger ${phase}: ${resp.status()}`, + ).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function pollModuleStatus( + baseURL: string, + projectId: string, + moduleId: string, + timeoutMs: number, +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const deadline = Date.now() + timeoutMs; + let lastStatus = ''; + while (Date.now() < deadline) { + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + if (resp.ok()) { + const data = await resp.json(); + lastStatus = data?.status ?? 'unknown'; + if (['success', 'failed', 'error'].includes(lastStatus)) { + await ctx.dispose(); + return lastStatus; + } + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + throw new Error( + `Module did not reach terminal state within ${timeoutMs}ms (last: ${lastStatus})`, + ); +} + +async function deleteProject(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + await ctx.delete(`/api/x2a/projects/${projectId}`, { headers }); + await ctx.dispose(); +} + +test.describe + .serial('X2Ansible - FLPATH-4215 Source Dir Resolution @live', () => { + let x2aPage: X2AnsiblePage; + const baseURL = process.env.PLAYWRIGHT_URL || 'http://localhost:3000'; + + test.beforeEach(async ({ page }) => { + x2aPage = new X2AnsiblePage(page); + }); + + test.afterAll(async () => { + if (state.projectId) { + await deleteProject(baseURL, state.projectId).catch(() => {}); + } + }); + + test('Phase 1: Create project from root-level cookbook repo and init', async () => { + test.setTimeout(INIT_TIMEOUT + 60_000); + + const project = await createProject(baseURL); + state.projectId = project.id; + state.projectName = project.name; + // eslint-disable-next-line no-console + console.log( + `FLPATH-4215: Created project from chef-examples-metadata: ${project.name} (${project.id})`, + ); + + const initData = await triggerInit(baseURL, project.id); + // eslint-disable-next-line no-console + console.log(`Init triggered: jobId=${initData.jobId}`); + + const finalState = await pollProjectState( + baseURL, + project.id, + INIT_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Init completed with state: ${finalState}`); + + expect( + ['success', 'initialized'].includes(finalState), + `Init did not succeed for chef-examples-metadata, state=${finalState}`, + ).toBeTruthy(); + + const modules = await getModules(baseURL, project.id); + // eslint-disable-next-line no-console + console.log(`Discovered ${modules.length} modules:`); + for (const mod of modules) { + // eslint-disable-next-line no-console + console.log( + ` module: ${mod.name} sourcePath: ${mod.sourcePath ?? '?'} id: ${mod.id}`, + ); + } + expect(modules.length).toBeGreaterThan(0); + + state.moduleId = modules[0].id; + state.moduleName = modules[0].name; + state.modulePath = modules[0].sourcePath ?? ''; + // eslint-disable-next-line no-console + console.log( + `Using module: ${state.moduleName} (sourcePath=${state.modulePath})`, + ); + }); + + test('Phase 2: Analyze root-level module', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect( + state.moduleId, + 'Module ID not set — init phase may have failed', + ).toBeTruthy(); + + // eslint-disable-next-line no-console + console.log( + `FLPATH-4215: Triggering Analyze for module ${state.moduleName} (sourcePath=${state.modulePath})`, + ); + const result = await triggerModulePhase( + baseURL, + state.projectId, + state.moduleId, + 'analyze', + ); + // eslint-disable-next-line no-console + console.log(`Analyze triggered: jobId=${result.jobId}`); + + const status = await pollModuleStatus( + baseURL, + state.projectId, + state.moduleId, + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Analyze API status: ${status}`); + expect(status, `Analyze failed with status=${status}`).toBe('success'); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + await x2aPage.waitForPhaseStatus('Analyze', 'Success', 30_000); + // eslint-disable-next-line no-console + console.log('Analyze phase verified in UI'); + }); + + test('Phase 3: Migrate root-level module (core source_dir=. validation)', async ({ + page, + }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect(state.moduleId, 'Module ID not set').toBeTruthy(); + + // eslint-disable-next-line no-console + console.log( + `FLPATH-4215: Triggering Migrate for root-level module ${state.moduleName} — ` + + 'this is the core test: before the fix, the export agent would fail ' + + 'to resolve source_dir="."', + ); + const result = await triggerModulePhase( + baseURL, + state.projectId, + state.moduleId, + 'migrate', + ); + // eslint-disable-next-line no-console + console.log(`Migrate triggered: jobId=${result.jobId}`); + + const status = await pollModuleStatus( + baseURL, + state.projectId, + state.moduleId, + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Migrate API status: ${status}`); + expect( + status, + `FLPATH-4215: Migrate failed for root-level module (source_dir='.'). ` + + `This indicates the export agent source dir resolution fix is not working. ` + + `status=${status}`, + ).toBe('success'); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + await x2aPage.waitForPhaseStatus('Migrate', 'Success', 30_000); + // eslint-disable-next-line no-console + console.log( + 'FLPATH-4215 verified: Migrate succeeded for root-level module', + ); + }); + + test('Phase 4: Verify all completed phases', async ({ page }) => { + test.setTimeout(60_000); + expect(state.moduleId, 'Module ID not set').toBeTruthy(); + + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(state.projectId, state.moduleId); + + const analyzeStatus = await x2aPage.getPhaseStatus('Analyze'); + // eslint-disable-next-line no-console + console.log(`Analyze status: ${analyzeStatus}`); + expect(analyzeStatus).toContain('Success'); + + const migrateStatus = await x2aPage.getPhaseStatus('Migrate'); + // eslint-disable-next-line no-console + console.log(`Migrate status: ${migrateStatus}`); + expect(migrateStatus).toContain('Success'); + + // eslint-disable-next-line no-console + console.log( + 'FLPATH-4215: All pipeline phases verified for root-level module', + ); + }); +}); diff --git a/workspaces/x2a/playwright.config.ts b/workspaces/x2a/playwright.config.ts index 3651f39c21..f627fde0a8 100644 --- a/workspaces/x2a/playwright.config.ts +++ b/workspaces/x2a/playwright.config.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,9 +16,6 @@ import { defineConfig } from '@playwright/test'; -/** - * See https://playwright.dev/docs/test-configuration. - */ export default defineConfig({ timeout: 60_000, @@ -26,8 +23,7 @@ export default defineConfig({ timeout: 5_000, }, - // Run your local dev server before starting the tests - webServer: process.env.CI + webServer: process.env.PLAYWRIGHT_URL ? [] : [ { @@ -46,9 +42,15 @@ export default defineConfig({ forbidOnly: !!process.env.CI, + workers: process.env.CI ? 2 : 1, + retries: process.env.CI ? 2 : 0, - reporter: [['html', { open: 'never', outputFolder: 'e2e-test-report' }]], + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'e2e-test-report' }], + ['junit', { outputFile: 'playwright-results.xml' }], + ], use: { actionTimeout: 0, @@ -57,9 +59,10 @@ export default defineConfig({ (process.env.CI ? 'http://localhost:7007' : 'http://localhost:3000'), screenshot: 'only-on-failure', trace: 'on-first-retry', + ignoreHTTPSErrors: true, }, - outputDir: 'node_modules/.cache/e2e-test-results', + outputDir: 'test-results', projects: [ {