diff --git a/workspaces/x2a/package.json b/workspaces/x2a/package.json index 243348c0881..21761194c23 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/analyze-bad-path.test.ts b/workspaces/x2a/packages/app/e2e-tests/analyze-bad-path.test.ts new file mode 100644 index 00000000000..520b65b9ebb --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/analyze-bad-path.test.ts @@ -0,0 +1,564 @@ +/* + * 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-4228: Analyze phase validation on non-existing module paths + * + * Validates that the analyze phase correctly fails when a module's + * source path no longer exists in the repository. The fix in + * x2a-convertor#206 ensures the convertor exits non-zero instead of + * silently producing an empty migration plan. + * + * Test strategy: + * 1. Create a temp branch in the test repo with a simple Chef recipe + * 2. Create an X2A project pointing to that branch + * 3. Init discovers the module (path exists) + * 4. Delete the source file from the branch via GitHub API + * 5. Run analyze — path no longer exists, should fail with error + * 6. Verify valid modules still succeed (regression) + */ + +import { test, expect, request } from '@playwright/test'; + +const TARGET_OWNER = 'rhdh-orchestrator-test'; +const TARGET_REPO_NAME = 'x2a-e2e-target'; +const REPO_URL = `https://github.com/${TARGET_OWNER}/${TARGET_REPO_NAME}.git`; +const POLL_INTERVAL = 5000; +const INIT_TIMEOUT = 300_000; +const PHASE_TIMEOUT = 180_000; + +let guestToken = ''; + +function getGitHubToken(): string { + return ( + process.env.RHDH_ORCHESTRATOR_GITHUB_TOKEN || process.env.GITHUB_TOKEN || '' + ); +} + +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', + }; +} + +// --- GitHub API helpers --- + +async function githubApi( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; data: unknown }> { + const ghToken = getGitHubToken(); + const ctx = await request.newContext({ + baseURL: 'https://api.github.com', + extraHTTPHeaders: { + Authorization: `token ${ghToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + let resp; + const url = `/repos/${TARGET_OWNER}/${TARGET_REPO_NAME}${path}`; + if (method === 'GET') { + resp = await ctx.get(url); + } else if (method === 'POST') { + resp = await ctx.post(url, { data: body }); + } else if (method === 'PUT') { + resp = await ctx.put(url, { data: body }); + } else if (method === 'DELETE') { + resp = await ctx.delete(url, { data: body }); + } else { + throw new Error(`Unsupported method: ${method}`); + } + const status = resp.status(); + const data = status !== 204 ? await resp.json().catch(() => null) : null; + await ctx.dispose(); + return { status, data }; +} + +async function createOrphanBranchWithFile( + branchName: string, + filePath: string, + content: string, +): Promise { + // Create blob + const { data: blobData } = await githubApi('POST', '/git/blobs', { + content: Buffer.from(content).toString('base64'), + encoding: 'base64', + }); + const blobSha = (blobData as { sha: string }).sha; + + // Create tree with just our file + const { data: treeData } = await githubApi('POST', '/git/trees', { + tree: [ + { + path: filePath, + mode: '100644', + type: 'blob', + sha: blobSha, + }, + ], + }); + const treeSha = (treeData as { sha: string }).sha; + + // Create commit (orphan — no parents) + const { data: commitData } = await githubApi('POST', '/git/commits', { + message: `test: orphan commit for FLPATH-4228 (${branchName})`, + tree: treeSha, + parents: [], + }); + const commitSha = (commitData as { sha: string }).sha; + + // Create branch ref + const { status } = await githubApi('POST', '/git/refs', { + ref: `refs/heads/${branchName}`, + sha: commitSha, + }); + expect( + [201, 422].includes(status), + `Failed to create branch: ${status}`, + ).toBeTruthy(); +} + +async function createFileInBranch( + branchName: string, + filePath: string, + content: string, + message: string, +) { + const encoded = Buffer.from(content).toString('base64'); + const { status } = await githubApi('PUT', `/contents/${filePath}`, { + message, + content: encoded, + branch: branchName, + }); + expect(status).toBe(201); +} + +async function deleteFileFromBranch( + branchName: string, + filePath: string, + message: string, +) { + // Get file SHA first + const { data: fileData } = await githubApi( + 'GET', + `/contents/${filePath}?ref=${branchName}`, + ); + const fileSha = (fileData as { sha: string }).sha; + const { status } = await githubApi('DELETE', `/contents/${filePath}`, { + message, + sha: fileSha, + branch: branchName, + }); + expect(status).toBe(200); +} + +async function deleteBranch(branchName: string) { + await githubApi('DELETE', `/git/refs/heads/${branchName}`); +} + +// --- X2A API helpers --- + +async function createProject(baseURL: string, branch: string) { + const headers = await apiHeaders(baseURL); + const name = `x2a-badpath-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-4228 bad path test', + sourceRepoUrl: REPO_URL, + targetRepoUrl: REPO_URL, + sourceRepoBranch: branch, + targetRepoBranch: branch, + }, + }); + expect(resp.ok(), `Failed to create project: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +async function triggerProjectInit(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ghToken = getGitHubToken(); + 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 }, + }, + }); + const status = resp.status(); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status, data }; +} + +async function triggerModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', +) { + const headers = await apiHeaders(baseURL); + const ghToken = getGitHubToken(); + 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 }, + }, + }, + ); + const status = resp.status(); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status, 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(); + return lastState; +} + +async function pollModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', + timeoutMs: number, +): Promise<{ status: string }> { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const deadline = Date.now() + timeoutMs; + let lastStatus = 'unknown'; + while (Date.now() < deadline) { + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + const data = await resp.json(); + const phaseData = data?.[phase]; + if (phaseData) { + lastStatus = phaseData.status; + if (['success', 'error', 'failed'].includes(lastStatus)) { + await ctx.dispose(); + return { status: lastStatus }; + } + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + return { status: lastStatus }; +} + +interface Module { + id: string; + name: string; + sourcePath: string; + status: string; +} + +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.items ?? data) as Module[]; +} + +test.describe('X2Ansible - FLPATH-4228 Analyze Bad Path Validation @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL ?? 'http://localhost:7007'; + + test.describe.configure({ mode: 'serial' }); + + const testBranch = `e2e-bad-path-${Date.now()}`; + let projectId = ''; + let targetModuleId = ''; + + test('Setup: create orphan branch with Ansible playbook', async () => { + test.setTimeout(30_000); + + // An Ansible playbook that X2A will discover as a module + const playbookContent = [ + '---', + '# FLPATH-4228 test playbook', + '- name: Configure e2e test service', + ' hosts: all', + ' become: true', + ' tasks:', + ' - name: Install httpd', + ' ansible.builtin.package:', + ' name: httpd', + ' state: present', + '', + ' - name: Start httpd service', + ' ansible.builtin.service:', + ' name: httpd', + ' state: started', + ' enabled: true', + '', + ' - name: Deploy config file', + ' ansible.builtin.template:', + ' src: templates/test.conf.j2', + ' dest: /etc/httpd/conf.d/test.conf', + ' owner: root', + ' mode: "0644"', + ' notify: Restart httpd', + '', + ' handlers:', + ' - name: Restart httpd', + ' ansible.builtin.service:', + ' name: httpd', + ' state: restarted', + ].join('\n'); + + // Create an orphan branch with ONLY our test file — avoids scanning + // hundreds of previously-published files during init + await createOrphanBranchWithFile( + testBranch, + 'e2e-bad-path-test/httpd_setup.yml', + playbookContent, + ); + // eslint-disable-next-line no-console + console.log( + `Created orphan branch ${testBranch} with e2e-bad-path-test/httpd_setup.yml`, + ); + }); + + test('Init: create project and discover module', async () => { + test.setTimeout(INIT_TIMEOUT + 60_000); + + const project = await createProject(baseURL, testBranch); + projectId = project.id; + // eslint-disable-next-line no-console + console.log(`Created project: ${project.name} (${projectId})`); + + const { status, data } = await triggerProjectInit(baseURL, projectId); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + + const finalState = await pollProjectState(baseURL, projectId, INIT_TIMEOUT); + expect( + ['success', 'initialized'].includes(finalState), + `Init failed: state=${finalState}`, + ).toBeTruthy(); + + const modules = await getModules(baseURL, projectId); + // eslint-disable-next-line no-console + console.log( + `Modules discovered: ${modules.map(m => `${m.name} (${m.sourcePath})`).join(', ')}`, + ); + + expect( + modules.length, + 'Init should discover at least 1 module from test-cookbook', + ).toBeGreaterThan(0); + + // Find our specific test module (the playbook file we created) + const testModule = modules.find( + m => + m.sourcePath.includes('e2e-bad-path-test') || + m.sourcePath.includes('httpd_setup'), + ); + expect( + testModule, + `e2e-bad-path-test module not found. Discovered: ${modules.map(m => `${m.name}(${m.sourcePath})`).join(', ')}`, + ).toBeDefined(); + targetModuleId = testModule!.id; + // eslint-disable-next-line no-console + console.log( + `Target module: ${testModule!.name} (${testModule!.sourcePath})`, + ); + }); + + test('Delete source file to create non-existing path scenario', async () => { + test.setTimeout(15_000); + expect(projectId && targetModuleId).toBeTruthy(); + + await deleteFileFromBranch( + testBranch, + 'e2e-bad-path-test/httpd_setup.yml', + 'test: remove playbook to simulate non-existing path', + ); + // eslint-disable-next-line no-console + console.log('Deleted e2e-bad-path-test/httpd_setup.yml from branch'); + }); + + test('Analyze: should fail on non-existing module path', async () => { + test.setTimeout(PHASE_TIMEOUT); + expect(projectId && targetModuleId).toBeTruthy(); + + const { status, data } = await triggerModulePhase( + baseURL, + projectId, + targetModuleId, + 'analyze', + ); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + // eslint-disable-next-line no-console + console.log(`Analyze triggered: jobId=${data.jobId}`); + + const result = await pollModulePhase( + baseURL, + projectId, + targetModuleId, + 'analyze', + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Analyze result: status=${result.status}`); + + // After the convertor fix (#206), analyze should report error/failed + expect( + ['error', 'failed'].includes(result.status), + `Expected analyze to fail on deleted path, got status=${result.status}`, + ).toBeTruthy(); + }); + + test('Regression: valid module path still succeeds', async () => { + test.setTimeout(PHASE_TIMEOUT + INIT_TIMEOUT); + + // Use the chef-examples repo which has known-good modules + const SOURCE_REPO = 'https://github.com/chef/chef-examples.git'; + const headers = await apiHeaders(baseURL); + const name = `x2a-regression-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-4228 regression check', + sourceRepoUrl: SOURCE_REPO, + targetRepoUrl: REPO_URL, + sourceRepoBranch: 'main', + targetRepoBranch: 'main', + }, + }); + expect(resp.ok()).toBeTruthy(); + const project = await resp.json(); + await ctx.dispose(); + // eslint-disable-next-line no-console + console.log(`Regression project: ${project.name} (${project.id})`); + + const ghToken = getGitHubToken(); + const initCtx = await request.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + const initResp = await initCtx.post(`/api/x2a/projects/${project.id}/run`, { + headers, + data: { + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }); + expect(initResp.ok()).toBeTruthy(); + await initCtx.dispose(); + + const state = await pollProjectState(baseURL, project.id, INIT_TIMEOUT); + expect(['success', 'initialized'].includes(state)).toBeTruthy(); + + const modules = await getModules(baseURL, project.id); + expect(modules.length, 'Should discover modules').toBeGreaterThan(0); + // Prefer a .yml module if available, otherwise take the first one + const target = + modules.find(m => m.sourcePath.endsWith('.yml')) ?? modules[0]; + // eslint-disable-next-line no-console + console.log(`Regression module: ${target.name} (${target.sourcePath})`); + + const { status: runStatus, data: runData } = await triggerModulePhase( + baseURL, + project.id, + target.id, + 'analyze', + ); + expect(runStatus).toBe(200); + expect(runData?.jobId).toBeDefined(); + + const result = await pollModulePhase( + baseURL, + project.id, + target.id, + 'analyze', + PHASE_TIMEOUT, + ); + // eslint-disable-next-line no-console + console.log(`Regression analyze: status=${result.status}`); + expect(result.status, 'Valid module path should analyze successfully').toBe( + 'success', + ); + }); + + test.afterAll(async () => { + // Clean up: delete test branch + try { + await deleteBranch(testBranch); + // eslint-disable-next-line no-console + console.log(`Cleaned up branch: ${testBranch}`); + } catch { + // eslint-disable-next-line no-console + console.log(`Warning: failed to delete branch ${testBranch}`); + } + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/app.test.ts b/workspaces/x2a/packages/app/e2e-tests/app.test.ts index ca25d125bf4..97708a5d167 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 00000000000..b12bba7b3f2 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/conversion-flow.test.ts @@ -0,0 +1,134 @@ +/* + * 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(); + }); + + // FLPATH-4413: Scaffolder RJSF form fields don't render on RHDH 1.10+. + // Blocked on FLPATH-4033 (upstream scaffolder update). Re-enable when fixed. + test.skip('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({ + timeout: 30000, + }); + await expect(x2aPage.page.getByLabel('Description')).toBeVisible(); + await expect(x2aPage.page.getByLabel('Abbreviation')).toBeVisible(); + await expect(x2aPage.page.getByLabel('Owned by group')).toBeVisible(); + }); + + // FLPATH-4413: Depends on form fields rendering (same root cause) + test.skip('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', () => { + // FLPATH-4413: Depends on scaffolder form fields rendering (same root cause) + test.skip('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 00000000000..076e7777e06 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/edit-project.test.ts @@ -0,0 +1,520 @@ +/* + * 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[] = []; + let patchSupported = true; + + test.beforeAll(async () => { + const probe = await createProject(baseURL, 'probe'); + createdProjects.push(probe.id); + const { status } = await patchProject(baseURL, probe.id, { + name: `probe-${Date.now()}`, + }); + patchSupported = status === 200; + }); + + test.beforeEach(async () => { + test.skip( + !patchSupported, + 'PATCH /projects/:id not supported by deployed backend', + ); + }); + + 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 = ''; + let patchSupported = true; + + test.beforeAll(async () => { + const probe = await createProject(baseURL, 'ui-probe'); + const { status } = await patchProject(baseURL, probe.id, { + name: `probe-${Date.now()}`, + }); + patchSupported = status === 200; + await deleteProject(baseURL, probe.id).catch(() => {}); + }); + + test.beforeEach(async () => { + test.skip( + !patchSupported, + 'PATCH /projects/:id not supported by deployed backend', + ); + }); + + 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.getByRole('heading', { name: '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/export-all-files.test.ts b/workspaces/x2a/packages/app/e2e-tests/export-all-files.test.ts new file mode 100644 index 00000000000..4902bbb118d --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/export-all-files.test.ts @@ -0,0 +1,469 @@ +/* + * 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-4213: Export agent copies all generated files + * FLPATH-4481: Verify AAP sync succeeds after publish (FLPATH-4398 regression gate) + * + * Validates that the publish phase: + * 1. Pushes ALL git-tracked files to the target repo (FLPATH-4213) + * 2. Completes with status=success, meaning AAP synced correctly (FLPATH-4481) + * + * The AAP sync assertion is the regression gate for FLPATH-4398 (PR #3551), + * which fixed a race condition where publish-aap was called before git push. + * + * Test flow: create project → init → analyze → migrate → publish → verify target repo + */ + +import { test, expect, request } from '@playwright/test'; + +const SOURCE_REPO = + process.env.X2A_SOURCE_REPO || + 'https://github.com/x2ansible/chef-examples.git'; +const TARGET_REPO = + process.env.X2A_TARGET_REPO || + 'https://github.com/rhdh-orchestrator-test/x2a-e2e-target.git'; +const TARGET_OWNER = 'rhdh-orchestrator-test'; +const TARGET_REPO_NAME = 'x2a-e2e-target'; +const POLL_INTERVAL = 5000; +const INIT_TIMEOUT = 120_000; +const PHASE_TIMEOUT = 300_000; + +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', + }; +} + +function getGitHubToken(): string { + return ( + process.env.RHDH_ORCHESTRATOR_GITHUB_TOKEN || process.env.GITHUB_TOKEN || '' + ); +} + +async function createProject(baseURL: string) { + const headers = await apiHeaders(baseURL); + const name = `x2a-export-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-4213 export all files test', + 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 triggerProjectInit(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ghToken = getGitHubToken(); + 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 }, + }, + }); + const status = resp.status(); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status, data }; +} + +async function triggerModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', +) { + const headers = await apiHeaders(baseURL); + const ghToken = getGitHubToken(); + 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 }, + }, + }, + ); + const status = resp.status(); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status, 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(); + return lastState; +} + +interface ModulePhaseInfo { + status: string; + commitId?: string; + artifacts?: Array<{ type: string; value: string }>; +} + +async function pollModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', + timeoutMs: number, +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const deadline = Date.now() + timeoutMs; + let lastStatus = 'unknown'; + while (Date.now() < deadline) { + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + const data = await resp.json(); + const phaseData = data?.[phase]; + if (phaseData) { + lastStatus = phaseData.status; + if (['success', 'error', 'failed'].includes(lastStatus)) { + await ctx.dispose(); + return { + status: lastStatus, + commitId: phaseData.commitId, + artifacts: phaseData.artifacts, + }; + } + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + return { status: lastStatus }; +} + +interface Module { + id: string; + name: string; + sourcePath: string; + status: string; + technology?: string; +} + +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.items ?? data) as Module[]; +} + +async function getGitHubCommitFiles(commitSha: string): Promise { + const ghToken = getGitHubToken(); + const ctx = await request.newContext({ + baseURL: 'https://api.github.com', + extraHTTPHeaders: { + Authorization: `token ${ghToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + const resp = await ctx.get( + `/repos/${TARGET_OWNER}/${TARGET_REPO_NAME}/commits/${commitSha}`, + ); + if (!resp.ok()) { + await ctx.dispose(); + return []; + } + const data = await resp.json(); + await ctx.dispose(); + return (data.files ?? []).map((f: { filename: string }) => f.filename); +} + +test.describe('X2Ansible - FLPATH-4213/4481 Export All Files & AAP Sync @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL ?? 'http://localhost:7007'; + + test.describe.configure({ mode: 'serial' }); + + let projectId = ''; + let targetModuleId = ''; + let targetModuleName = ''; + let publishCommitSha = ''; + + test('Setup: create project and initialize', async () => { + test.setTimeout(INIT_TIMEOUT + 30_000); + + const project = await createProject(baseURL); + projectId = project.id; + // eslint-disable-next-line no-console + console.log(`Created project: ${project.name} (${projectId})`); + + const { status, data } = await triggerProjectInit(baseURL, projectId); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + + const finalState = await pollProjectState(baseURL, projectId, INIT_TIMEOUT); + expect( + ['success', 'initialized'].includes(finalState), + `Init failed: state=${finalState}`, + ).toBeTruthy(); + + const modules = await getModules(baseURL, projectId); + expect(modules.length).toBeGreaterThan(0); + + // Pick a simple module — prefer a single .yml file (fastest to convert) + const singleYmlModule = modules.find(m => m.sourcePath.endsWith('.yml')); + // Fall back to smallest path (likely simplest module) + const target = + singleYmlModule ?? + modules.sort((a, b) => a.sourcePath.length - b.sourcePath.length)[0]; + targetModuleId = target.id; + targetModuleName = target.name; + // eslint-disable-next-line no-console + console.log( + `Target module: ${targetModuleName} (${targetModuleId}), source: ${target.sourcePath}`, + ); + }); + + test('Analyze: create migration plan for module', async () => { + test.setTimeout(PHASE_TIMEOUT); + expect(projectId && targetModuleId).toBeTruthy(); + + const { status, data } = await triggerModulePhase( + baseURL, + projectId, + targetModuleId, + 'analyze', + ); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + // eslint-disable-next-line no-console + console.log(`Analyze triggered: jobId=${data.jobId}`); + + const result = await pollModulePhase( + baseURL, + projectId, + targetModuleId, + 'analyze', + PHASE_TIMEOUT, + ); + expect(result.status, `Analyze failed: status=${result.status}`).toBe( + 'success', + ); + // eslint-disable-next-line no-console + console.log(`Analyze complete: status=${result.status}`); + }); + + test('Migrate: convert module to Ansible', async () => { + test.setTimeout(PHASE_TIMEOUT); + expect(projectId && targetModuleId).toBeTruthy(); + + const { status, data } = await triggerModulePhase( + baseURL, + projectId, + targetModuleId, + 'migrate', + ); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + // eslint-disable-next-line no-console + console.log(`Migrate triggered: jobId=${data.jobId}`); + + const result = await pollModulePhase( + baseURL, + projectId, + targetModuleId, + 'migrate', + PHASE_TIMEOUT, + ); + expect(result.status, `Migrate failed: status=${result.status}`).toBe( + 'success', + ); + expect(result.artifacts?.length).toBeGreaterThan(0); + // eslint-disable-next-line no-console + console.log( + `Migrate complete: status=${result.status}, artifacts=${result.artifacts?.length}`, + ); + }); + + test('Publish: push all files to target repository', async () => { + test.setTimeout(PHASE_TIMEOUT); + expect(projectId && targetModuleId).toBeTruthy(); + + const { status, data } = await triggerModulePhase( + baseURL, + projectId, + targetModuleId, + 'publish', + ); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + // eslint-disable-next-line no-console + console.log(`Publish triggered: jobId=${data.jobId}`); + + const result = await pollModulePhase( + baseURL, + projectId, + targetModuleId, + 'publish', + PHASE_TIMEOUT, + ); + // FLPATH-4481: Publish MUST succeed (AAP sync included). + // After FLPATH-4398 fix, git push happens before AAP sync, + // so AAP should always find the playbooks. + expect( + result.status, + `Publish failed: status=${result.status}. ` + + 'If AAP sync failed, this is a regression of FLPATH-4398.', + ).toBe('success'); + expect( + result.commitId, + 'Publish must produce a commit in target repo', + ).toBeDefined(); + + publishCommitSha = result.commitId!; + // eslint-disable-next-line no-console + console.log( + `Publish complete: status=${result.status}, commit=${publishCommitSha}`, + ); + }); + + test('Verify: all generated files appear in target repo commit', async () => { + test.setTimeout(30_000); + expect(publishCommitSha).toBeTruthy(); + + const files = await getGitHubCommitFiles(publishCommitSha); + expect(files.length).toBeGreaterThan(0); + // eslint-disable-next-line no-console + console.log(`Files in publish commit: ${files.length}`); + files.forEach(f => console.log(` ${f}`)); // eslint-disable-line no-console + + // Verify role structure files exist (tasks/main.yml is the core converted file) + const hasTasksMain = files.some(f => f.includes('/tasks/main.yml')); + expect( + hasTasksMain, + 'Target repo must contain roles/*/tasks/main.yml', + ).toBeTruthy(); + + // Verify molecule test files are included (nested deeply under roles/) + const hasMolecule = files.some(f => f.includes('/molecule/')); + expect( + hasMolecule, + 'Target repo must contain molecule test files (deeply nested)', + ).toBeTruthy(); + + // Verify project-level files are included (outside roles/ directory) + const hasProjectFiles = files.some( + f => + f.endsWith('/ansible.cfg') || + f.endsWith('/requirements.yml') || + f.endsWith('/README.md'), + ); + expect( + hasProjectFiles, + 'Target repo must include project-level files (ansible.cfg, README, etc.)', + ).toBeTruthy(); + }); + + test('Verify: files span multiple directory depths', async () => { + test.setTimeout(30_000); + expect(publishCommitSha).toBeTruthy(); + + const files = await getGitHubCommitFiles(publishCommitSha); + + // Count unique directory depths to verify deep file inclusion + const depths = new Set(files.map(f => f.split('/').length)); + // eslint-disable-next-line no-console + console.log(`Directory depths present: ${[...depths].sort().join(', ')}`); + + // Files should span at least 3 different depths: + // e.g., project/ansible-project/file (3), project/ansible-project/roles/name/file (5), + // project/ansible-project/roles/name/molecule/default/file (7) + expect( + depths.size, + `Expected files at multiple depths, got ${depths.size}`, + ).toBeGreaterThanOrEqual(3); + }); + + test('Verify: standard ansible role structure is correct', async () => { + test.setTimeout(30_000); + expect(publishCommitSha).toBeTruthy(); + + const files = await getGitHubCommitFiles(publishCommitSha); + + // Standard Ansible role directories that should be present + const expectedPaths = [ + '/tasks/main.yml', + '/meta/main.yml', + '/handlers/main.yml', + ]; + + for (const expected of expectedPaths) { + const found = files.some(f => f.includes(expected)); + // eslint-disable-next-line no-console + console.log(` ${expected}: ${found ? 'FOUND' : 'MISSING'}`); + expect( + found, + `Standard ansible path ${expected} not found in published files`, + ).toBeTruthy(); + } + }); +}); 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 00000000000..ee5a9b0cca1 --- /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 00000000000..76595770c4a --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/navigation.test.ts @@ -0,0 +1,70 @@ +/* + * 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'); + }); + + // FLPATH-4413: Scaffolder form fields don't render on RHDH 1.10+ + test.skip('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(); + }); + + // FLPATH-4413: Depends on scaffolder form fields rendering + test.skip('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 00000000000..c773bae60d6 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/pages/X2AnsiblePage.ts @@ -0,0 +1,383 @@ +/* + * 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.getByRole('heading', { + name: '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: 30000 }); + 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 tabPanel = this.page.locator('[role="tabpanel"]:visible'); + const chip = tabPanel + .locator('[class*="Chip"], [class*="chip"], [class*="status"]') + .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/phase-duration-attempts.test.ts b/workspaces/x2a/packages/app/e2e-tests/phase-duration-attempts.test.ts new file mode 100644 index 00000000000..825187b6401 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/phase-duration-attempts.test.ts @@ -0,0 +1,449 @@ +/* + * 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-4229: Phase Duration and Attempt Count display fix + * + * Validates that phase duration uses telemetry timestamps (not K8s job + * aggregated time) and that attempt count is exposed as a separate field. + * + * Requires PR rhdh-plugins#3238 (adds firstAttemptAt to Job schema). + * Tests skip gracefully if the field isn't present on the deployed image. + * + * Uses an already-completed module (from previous pipeline runs) to + * verify the response fields without running a new pipeline. + */ + +import { test, expect, request } from '@playwright/test'; + +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'; +const POLL_INTERVAL = 5000; +const INIT_TIMEOUT = 120_000; +const PHASE_TIMEOUT = 300_000; + +let guestToken = ''; + +function getGitHubToken(): string { + return ( + process.env.RHDH_ORCHESTRATOR_GITHUB_TOKEN || process.env.GITHUB_TOKEN || '' + ); +} + +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) { + const headers = await apiHeaders(baseURL); + const name = `x2a-duration-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-4229 duration/attempts test', + 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 triggerProjectInit(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ghToken = getGitHubToken(); + 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 }, + }, + }); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status: resp.status(), data }; +} + +async function triggerModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate', +) { + const headers = await apiHeaders(baseURL); + const ghToken = getGitHubToken(); + 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 }, + }, + }, + ); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status: resp.status(), 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(); + return lastState; +} + +interface PhaseData { + status: string; + startedAt?: string; + finishedAt?: string; + firstAttemptAt?: string; + attempts?: number; + telemetry?: { + startedAt?: string; + endedAt?: string; + phase?: string; + summary?: string; + }; +} + +async function getModulePhaseData( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate', +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + const data = await resp.json(); + await ctx.dispose(); + return data?.[phase] ?? null; +} + +async function pollModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate', + timeoutMs: number, +): Promise { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const deadline = Date.now() + timeoutMs; + let lastStatus = 'unknown'; + while (Date.now() < deadline) { + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + const data = await resp.json(); + const phaseData = data?.[phase]; + if (phaseData) { + lastStatus = phaseData.status; + if (['success', 'error', 'failed'].includes(lastStatus)) { + await ctx.dispose(); + return lastStatus; + } + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + await ctx.dispose(); + return lastStatus; +} + +interface Module { + id: string; + name: string; + sourcePath: string; +} + +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, + }); + const data = await resp.json(); + await ctx.dispose(); + return (data.items ?? data) as Module[]; +} + +test.describe('X2Ansible - FLPATH-4229 Phase Duration & Attempts @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL ?? 'http://localhost:7007'; + + test.describe.configure({ mode: 'serial' }); + + let projectId = ''; + let moduleId = ''; + + test('Setup: create project, init, and run analyze', async () => { + test.setTimeout(INIT_TIMEOUT + PHASE_TIMEOUT + 60_000); + + const project = await createProject(baseURL); + projectId = project.id; + // eslint-disable-next-line no-console + console.log(`Created project: ${project.name} (${projectId})`); + + const { status } = await triggerProjectInit(baseURL, projectId); + expect(status).toBe(200); + + const initState = await pollProjectState(baseURL, projectId, INIT_TIMEOUT); + expect(['success', 'initialized'].includes(initState)).toBeTruthy(); + + const modules = await getModules(baseURL, projectId); + const target = + modules.find(m => m.sourcePath.endsWith('.yml')) ?? modules[0]; + moduleId = target.id; + // eslint-disable-next-line no-console + console.log(`Module: ${target.name} (${target.sourcePath})`); + + // Run analyze to have completed phase data + const { status: analyzeStatus } = await triggerModulePhase( + baseURL, + projectId, + moduleId, + 'analyze', + ); + expect(analyzeStatus).toBe(200); + + const analyzeResult = await pollModulePhase( + baseURL, + projectId, + moduleId, + 'analyze', + PHASE_TIMEOUT, + ); + expect(analyzeResult).toBe('success'); + // eslint-disable-next-line no-console + console.log('Analyze completed successfully'); + }); + + test('Phase data includes telemetry timestamps', async () => { + expect(projectId && moduleId).toBeTruthy(); + + const phaseData = await getModulePhaseData( + baseURL, + projectId, + moduleId, + 'analyze', + ); + expect(phaseData).not.toBeNull(); + expect(phaseData!.status).toBe('success'); + + // Core fields that should always be present + expect(phaseData!.startedAt).toBeDefined(); + expect(phaseData!.finishedAt).toBeDefined(); + + // Telemetry should have its own timestamps + expect(phaseData!.telemetry).toBeDefined(); + expect(phaseData!.telemetry!.startedAt).toBeDefined(); + expect(phaseData!.telemetry!.endedAt).toBeDefined(); + + // eslint-disable-next-line no-console + console.log(`Job startedAt: ${phaseData!.startedAt}`); + // eslint-disable-next-line no-console + console.log(`Job finishedAt: ${phaseData!.finishedAt}`); + // eslint-disable-next-line no-console + console.log(`Telemetry startedAt: ${phaseData!.telemetry!.startedAt}`); + // eslint-disable-next-line no-console + console.log(`Telemetry endedAt: ${phaseData!.telemetry!.endedAt}`); + }); + + test('Telemetry duration is shorter than total job time', async () => { + expect(projectId && moduleId).toBeTruthy(); + + const phaseData = await getModulePhaseData( + baseURL, + projectId, + moduleId, + 'analyze', + ); + expect(phaseData?.telemetry).toBeDefined(); + + const jobStart = new Date(phaseData!.startedAt!).getTime(); + const jobEnd = new Date(phaseData!.finishedAt!).getTime(); + const jobDuration = jobEnd - jobStart; + + const telStart = new Date(phaseData!.telemetry!.startedAt!).getTime(); + const telEnd = new Date(phaseData!.telemetry!.endedAt!).getTime(); + const telDuration = telEnd - telStart; + + // eslint-disable-next-line no-console + console.log( + `Job duration: ${(jobDuration / 1000).toFixed(1)}s, Telemetry duration: ${(telDuration / 1000).toFixed(1)}s`, + ); + + // Telemetry duration should be <= job duration (job includes pod startup) + expect( + telDuration <= jobDuration, + `Telemetry duration (${telDuration}ms) should not exceed job duration (${jobDuration}ms)`, + ).toBeTruthy(); + + // There should be some overhead (pod startup > 0) + const overhead = jobDuration - telDuration; + // eslint-disable-next-line no-console + console.log(`Pod startup overhead: ${(overhead / 1000).toFixed(1)}s`); + expect( + overhead >= 0, + 'Job duration should include pod startup overhead', + ).toBeTruthy(); + }); + + test('firstAttemptAt field is present (requires PR #3238)', async () => { + expect(projectId && moduleId).toBeTruthy(); + + const phaseData = await getModulePhaseData( + baseURL, + projectId, + moduleId, + 'analyze', + ); + + if (!phaseData?.firstAttemptAt) { + test.skip(true, 'firstAttemptAt not present — PR #3238 not yet deployed'); + return; + } + + // eslint-disable-next-line no-console + console.log(`firstAttemptAt: ${phaseData.firstAttemptAt}`); + + // firstAttemptAt should be between job startedAt and telemetry startedAt + const firstAttempt = new Date(phaseData.firstAttemptAt).getTime(); + const jobStart = new Date(phaseData.startedAt!).getTime(); + expect( + firstAttempt >= jobStart, + 'firstAttemptAt should be >= job startedAt', + ).toBeTruthy(); + }); + + test('Attempts count is available (requires PR #3238)', async () => { + expect(projectId && moduleId).toBeTruthy(); + + const phaseData = await getModulePhaseData( + baseURL, + projectId, + moduleId, + 'analyze', + ); + + if (phaseData?.attempts === undefined) { + test.skip(true, 'attempts field not present — PR #3238 not yet deployed'); + return; + } + + // eslint-disable-next-line no-console + console.log(`Attempts: ${phaseData.attempts}`); + + // For a successful first-try analyze, attempts should be 1 + expect(phaseData.attempts).toBeGreaterThanOrEqual(1); + }); + + test('Run migrate and verify timing fields', async () => { + test.setTimeout(PHASE_TIMEOUT); + expect(projectId && moduleId).toBeTruthy(); + + const { status } = await triggerModulePhase( + baseURL, + projectId, + moduleId, + 'migrate', + ); + expect(status).toBe(200); + + const migrateResult = await pollModulePhase( + baseURL, + projectId, + moduleId, + 'migrate', + PHASE_TIMEOUT, + ); + expect(migrateResult).toBe('success'); + + const phaseData = await getModulePhaseData( + baseURL, + projectId, + moduleId, + 'migrate', + ); + expect(phaseData?.telemetry).toBeDefined(); + + const jobStart = new Date(phaseData!.startedAt!).getTime(); + const jobEnd = new Date(phaseData!.finishedAt!).getTime(); + const telStart = new Date(phaseData!.telemetry!.startedAt!).getTime(); + const telEnd = new Date(phaseData!.telemetry!.endedAt!).getTime(); + + const jobDuration = (jobEnd - jobStart) / 1000; + const telDuration = (telEnd - telStart) / 1000; + + // eslint-disable-next-line no-console + console.log( + `Migrate: job=${jobDuration.toFixed(1)}s, telemetry=${telDuration.toFixed(1)}s, overhead=${(jobDuration - telDuration).toFixed(1)}s`, + ); + + // Telemetry duration should be meaningful (migrate takes time) + expect(telDuration).toBeGreaterThan(10); + // And should be less than total job time + expect(telDuration).toBeLessThanOrEqual(jobDuration); + }); +}); 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 00000000000..f8ec199041d --- /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/project-rules.test.ts b/workspaces/x2a/packages/app/e2e-tests/project-rules.test.ts new file mode 100644 index 00000000000..e2095f9f9a8 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/project-rules.test.ts @@ -0,0 +1,324 @@ +/* + * 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-4210: Project Rules & Priorities feature + * + * Tests the /rules/ API endpoints added in rhdh-plugins#3144. + * Verifies CRUD operations, input validation, and rule-project association. + */ + +import { test, expect, request } from '@playwright/test'; + +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', + }; +} + +interface Rule { + id: string; + title: string; + description: string; + required: boolean; + createdAt: string; + updatedAt: string; +} + +async function createRule( + baseURL: string, + body: { title: string; description: string; required?: boolean }, +): Promise<{ status: number; data: Rule | null }> { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.post('/api/x2a/rules', { headers, data: body }); + const status = resp.status(); + const data = status === 201 ? ((await resp.json()) as Rule) : null; + await ctx.dispose(); + return { status, data }; +} + +async function listRules( + baseURL: string, +): Promise<{ status: number; items: Rule[] }> { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.get('/api/x2a/rules', { headers }); + const data = await resp.json(); + await ctx.dispose(); + return { status: resp.status(), items: data.items ?? [] }; +} + +async function getRule( + baseURL: string, + ruleId: string, +): Promise<{ status: number; data: Rule | null }> { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.get(`/api/x2a/rules/${ruleId}`, { headers }); + const data = resp.ok() ? ((await resp.json()) as Rule) : null; + await ctx.dispose(); + return { status: resp.status(), data }; +} + +async function updateRule( + baseURL: string, + ruleId: string, + body: { title: string; description: string; required: boolean }, +): Promise<{ status: number; data: Rule | null }> { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.put(`/api/x2a/rules/${ruleId}`, { + headers, + data: body, + }); + const data = resp.ok() ? ((await resp.json()) as Rule) : null; + await ctx.dispose(); + return { status: resp.status(), data }; +} + +async function deleteRule( + baseURL: string, + ruleId: string, +): Promise<{ status: number }> { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.delete(`/api/x2a/rules/${ruleId}`, { headers }); + await ctx.dispose(); + return { status: resp.status() }; +} + +// FLPATH-4210: Rules API not available on all RHDH versions. +// Skip until rules endpoint is confirmed stable across version matrix. +test.describe.skip('X2Ansible - FLPATH-4210 Project Rules @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL ?? 'http://localhost:7007'; + const createdRuleIds: string[] = []; + + test.afterAll(async () => { + for (const id of createdRuleIds) { + await deleteRule(baseURL, id).catch(() => {}); + } + }); + + test('should create a rule with title and description', async () => { + const { status, data } = await createRule(baseURL, { + title: `E2E Rule ${Date.now()}`, + description: 'Always generate YAML output format', + }); + + expect(status).toBe(201); + expect(data).not.toBeNull(); + expect(data!.id).toBeDefined(); + expect(data!.title).toContain('E2E Rule'); + expect(data!.description).toBe('Always generate YAML output format'); + expect(data!.required).toBe(false); + expect(data!.createdAt).toBeDefined(); + expect(data!.updatedAt).toBeDefined(); + + createdRuleIds.push(data!.id); + // eslint-disable-next-line no-console + console.log(`Created rule: ${data!.id}`); + }); + + test('should create a required rule', async () => { + const { status, data } = await createRule(baseURL, { + title: `E2E Required Rule ${Date.now()}`, + description: 'Must include molecule tests for all roles', + required: true, + }); + + expect(status).toBe(201); + expect(data).not.toBeNull(); + expect(data!.required).toBe(true); + + createdRuleIds.push(data!.id); + // eslint-disable-next-line no-console + console.log(`Created required rule: ${data!.id}`); + }); + + test('should list rules including created ones', async () => { + const { status, items } = await listRules(baseURL); + + expect(status).toBe(200); + expect(items.length).toBeGreaterThanOrEqual(createdRuleIds.length); + + for (const id of createdRuleIds) { + const found = items.find(r => r.id === id); + expect(found, `Rule ${id} should appear in list`).toBeDefined(); + } + }); + + test('should get a single rule by ID', async () => { + const ruleId = createdRuleIds[0]; + expect(ruleId, 'Need a created rule').toBeDefined(); + + const { status, data } = await getRule(baseURL, ruleId); + + expect(status).toBe(200); + expect(data).not.toBeNull(); + expect(data!.id).toBe(ruleId); + expect(data!.title).toContain('E2E Rule'); + }); + + test('should update rule via PUT', async () => { + const ruleId = createdRuleIds[0]; + expect(ruleId, 'Need a created rule').toBeDefined(); + + const newTitle = `Updated Rule ${Date.now()}`; + const { status, data } = await updateRule(baseURL, ruleId, { + title: newTitle, + description: 'Updated via PUT', + required: true, + }); + + expect(status).toBe(200); + expect(data).not.toBeNull(); + expect(data!.title).toBe(newTitle); + expect(data!.description).toBe('Updated via PUT'); + expect(data!.required).toBe(true); + // eslint-disable-next-line no-console + console.log(`PUT updated rule: ${ruleId}`); + }); + + test('should toggle required flag via PUT', async () => { + const ruleId = createdRuleIds[0]; + expect(ruleId, 'Need a created rule').toBeDefined(); + + const { data: current } = await getRule(baseURL, ruleId); + expect(current).not.toBeNull(); + + const { status } = await updateRule(baseURL, ruleId, { + title: current!.title, + description: current!.description, + required: !current!.required, + }); + expect(status).toBe(200); + + const { data: updated } = await getRule(baseURL, ruleId); + expect(updated!.required).toBe(!current!.required); + }); + + test('should return 400 for PUT without required field', async () => { + const ruleId = createdRuleIds[0]; + expect(ruleId, 'Need a created rule').toBeDefined(); + + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.put(`/api/x2a/rules/${ruleId}`, { + headers, + data: { title: 'Missing required field', description: 'test' }, + }); + expect(resp.status()).toBe(400); + await ctx.dispose(); + }); + + test('should return 400 for POST without description', async () => { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.post('/api/x2a/rules', { + headers, + data: { title: 'Missing description rule' }, + }); + + expect(resp.status()).toBe(400); + await ctx.dispose(); + }); + + test('should return 400 for POST without title', async () => { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const resp = await ctx.post('/api/x2a/rules', { + headers, + data: { description: 'Missing title rule' }, + }); + + expect(resp.status()).toBe(400); + await ctx.dispose(); + }); + + test('should return 404 for GET non-existent rule', async () => { + const { status } = await getRule( + baseURL, + '00000000-0000-0000-0000-000000000000', + ); + expect(status).toBe(404); + }); + + test('should return 404 for DELETE non-existent rule', async () => { + const { status } = await deleteRule( + baseURL, + '00000000-0000-0000-0000-000000000000', + ); + expect(status).toBe(404); + }); + + test('should delete a rule and confirm removal', async () => { + const { data: rule } = await createRule(baseURL, { + title: `E2E Delete Test ${Date.now()}`, + description: 'This rule will be deleted', + }); + expect(rule).not.toBeNull(); + + const { status: deleteStatus } = await deleteRule(baseURL, rule!.id); + expect(deleteStatus).toBe(200); + + const { status: getStatus } = await getRule(baseURL, rule!.id); + expect(getStatus).toBe(404); + + // eslint-disable-next-line no-console + console.log(`Deleted rule ${rule!.id} confirmed gone`); + }); + + test('should preserve createdAt and update updatedAt on PUT', async () => { + const ruleId = createdRuleIds[0]; + expect(ruleId, 'Need a created rule').toBeDefined(); + + const { data: before } = await getRule(baseURL, ruleId); + expect(before).not.toBeNull(); + + await new Promise(r => setTimeout(r, 1100)); + + await updateRule(baseURL, ruleId, { + title: `Timestamp check ${Date.now()}`, + description: before!.description, + required: before!.required, + }); + const { data: after } = await getRule(baseURL, ruleId); + expect(after).not.toBeNull(); + + expect(after!.createdAt).toBe(before!.createdAt); + expect(new Date(after!.updatedAt).getTime()).toBeGreaterThan( + new Date(before!.updatedAt).getTime(), + ); + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/resync-migration.test.ts b/workspaces/x2a/packages/app/e2e-tests/resync-migration.test.ts new file mode 100644 index 00000000000..5de62c1f790 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/resync-migration.test.ts @@ -0,0 +1,265 @@ +/* + * 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-4227: Resync Project Migration feature + * + * Tests the resync action (POST /projects/:id/run with refresh=true) + * added in rhdh-plugins#3196. + * Verifies that triggering a resync re-reads the migration plan and updates + * the module list accordingly. + */ + +import { test, expect, request } from '@playwright/test'; + +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'; +const POLL_INTERVAL = 3000; +const INIT_TIMEOUT = 120_000; + +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) { + const headers = await apiHeaders(baseURL); + const name = `x2a-resync-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-4227 resync test', + 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 triggerInit( + baseURL: string, + projectId: string, + refresh = false, +) { + const headers = await apiHeaders(baseURL); + const ghToken = + process.env.RHDH_ORCHESTRATOR_GITHUB_TOKEN || + process.env.GITHUB_TOKEN || + ''; + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const body: Record = { + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }; + if (refresh) { + body.refresh = true; + } + const resp = await ctx.post(`/api/x2a/projects/${projectId}/run`, { + headers, + data: body, + }); + const status = resp.status(); + const data = resp.ok() ? await resp.json() : null; + await ctx.dispose(); + return { status, 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(); + return lastState; +} + +interface Module { + id: string; + name: string; + sourcePath: string; + status: string; + removedAt?: string | null; +} + +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.items ?? data) as Module[]; +} + +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 }); + const data = await resp.json(); + await ctx.dispose(); + return data; +} + +test.describe('X2Ansible - FLPATH-4227 Resync Migration @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL ?? 'http://localhost:7007'; + + test.describe.configure({ mode: 'serial' }); + + let projectId = ''; + let initialModules: Module[] = []; + + test('Setup: create and initialize a project', async () => { + test.setTimeout(INIT_TIMEOUT + 30_000); + + const project = await createProject(baseURL); + projectId = project.id; + // eslint-disable-next-line no-console + console.log(`Created project: ${project.name} (${projectId})`); + + const { status, data } = await triggerInit(baseURL, projectId); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + // eslint-disable-next-line no-console + console.log(`Init triggered: jobId=${data.jobId}`); + + const finalState = await pollProjectState(baseURL, projectId, INIT_TIMEOUT); + expect( + ['success', 'initialized'].includes(finalState), + `Init did not succeed, state=${finalState}`, + ).toBeTruthy(); + + initialModules = await getModules(baseURL, projectId); + // eslint-disable-next-line no-console + console.log(`Init complete: ${initialModules.length} modules discovered`); + expect(initialModules.length).toBeGreaterThan(0); + }); + + test('should accept resync request (refresh=true) on initialized project', async () => { + expect(projectId, 'Need an initialized project').toBeTruthy(); + + const { status, data } = await triggerInit(baseURL, projectId, true); + expect(status).toBe(200); + expect(data?.jobId).toBeDefined(); + // eslint-disable-next-line no-console + console.log(`Resync triggered: jobId=${data.jobId}`); + }); + + test('should complete resync and return to initialized state', async () => { + test.setTimeout(INIT_TIMEOUT + 30_000); + expect(projectId, 'Need an initialized project').toBeTruthy(); + + const finalState = await pollProjectState(baseURL, projectId, INIT_TIMEOUT); + expect( + ['success', 'initialized'].includes(finalState), + `Resync did not succeed, state=${finalState}`, + ).toBeTruthy(); + // eslint-disable-next-line no-console + console.log(`Resync complete: state=${finalState}`); + }); + + test('should preserve module list after resync (no source changes)', async () => { + expect(projectId, 'Need an initialized project').toBeTruthy(); + + const modulesAfterResync = await getModules(baseURL, projectId); + + const activeModules = modulesAfterResync.filter(m => !m.removedAt); + // eslint-disable-next-line no-console + console.log( + `Modules after resync: ${activeModules.length} active, ${modulesAfterResync.length} total`, + ); + + expect(activeModules.length).toBe(initialModules.length); + + for (const original of initialModules) { + const found = activeModules.find( + m => m.sourcePath === original.sourcePath, + ); + expect( + found, + `Module ${original.name} (${original.sourcePath}) missing after resync`, + ).toBeDefined(); + } + }); + + test('should update modulesSummary in project status after resync', async () => { + expect(projectId, 'Need an initialized project').toBeTruthy(); + + const project = await getProject(baseURL, projectId); + const summary = project?.status?.modulesSummary; + + expect(summary).toBeDefined(); + expect(summary.total).toBeGreaterThan(0); + // eslint-disable-next-line no-console + console.log( + `Modules summary: total=${summary.total}, removed=${summary.removed ?? 0}`, + ); + }); + + test('should reject resync on non-initialized (created) project', async () => { + const freshProject = await createProject(baseURL); + // eslint-disable-next-line no-console + console.log(`Fresh project: ${freshProject.id} (state=created)`); + + const { status } = await triggerInit(baseURL, freshProject.id, true); + // A refresh on a project with no migration plan may fail or just run as normal init + // eslint-disable-next-line no-console + console.log(`Resync on fresh project: HTTP ${status}`); + // Document actual behavior — if it succeeds, it just runs as normal init + expect([200, 400, 409].includes(status)).toBeTruthy(); + }); +}); diff --git a/workspaces/x2a/packages/app/e2e-tests/smoke.test.ts b/workspaces/x2a/packages/app/e2e-tests/smoke.test.ts new file mode 100644 index 00000000000..300480950df --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/smoke.test.ts @@ -0,0 +1,839 @@ +/* + * 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 = 600_000; +const TOKEN_MAX_AGE_MS = 10 * 60 * 1000; // refresh token every 10 min +const SOURCE_REPO = + process.env.X2A_SOURCE_REPO || + 'https://github.com/x2ansible/chef-examples.git'; +const TARGET_REPO = + process.env.X2A_TARGET_REPO || + 'https://github.com/rhdh-orchestrator-test/x2a-e2e-target.git'; + +function requireGitHubToken(): string { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error( + 'GITHUB_TOKEN env var is required but not set. ' + + 'Set it before running E2E tests.', + ); + } + return token; +} + +// --------------------------------------------------------------------------- +// API helpers — with token refresh and resilient polling +// --------------------------------------------------------------------------- + +let cachedToken = ''; +let tokenTimestamp = 0; + +async function getGuestToken(baseURL: string): Promise { + if (cachedToken && Date.now() - tokenTimestamp < TOKEN_MAX_AGE_MS) { + return cachedToken; + } + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + try { + const resp = await ctx.post('/api/auth/guest/refresh'); + expect( + resp.ok(), + `Guest auth failed: ${resp.status()} ${resp.statusText()}`, + ).toBeTruthy(); + const data = await resp.json(); + const token = data?.backstageIdentity?.token; + expect(token, 'Guest token missing from auth response').toBeTruthy(); + cachedToken = token; + tokenTimestamp = Date.now(); + return cachedToken; + } finally { + await ctx.dispose(); + } +} + +function invalidateToken() { + cachedToken = ''; + tokenTimestamp = 0; +} + +async function apiHeaders(baseURL: string) { + const token = await getGuestToken(baseURL); + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; +} + +async function safeJson(resp: { json: () => Promise }) { + try { + return await resp.json(); + } catch { + return null; + } +} + +async function createProject(baseURL: string, name: string) { + const headers = await apiHeaders(baseURL); + const ghToken = requireGitHubToken(); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + try { + const resp = await ctx.post('/api/x2a/projects', { + headers, + data: { + name, + abbreviation: 'x2a', + description: `Smoke test: ${name}`, + sourceRepoUrl: SOURCE_REPO, + targetRepoUrl: TARGET_REPO, + sourceRepoBranch: 'main', + targetRepoBranch: `e2e-${name}`, + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }); + expect(resp.ok(), `Create project failed: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + expect(data?.id, 'Project response missing id').toBeTruthy(); + return data; + } finally { + await ctx.dispose(); + } +} + +async function triggerInit(baseURL: string, projectId: string) { + const headers = await apiHeaders(baseURL); + const ghToken = requireGitHubToken(); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + try { + const resp = await ctx.post(`/api/x2a/projects/${projectId}/run`, { + headers, + data: { + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }); + expect(resp.ok(), `Trigger init failed: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + return data; + } finally { + await ctx.dispose(); + } +} + +async function pollProjectState( + baseURL: string, + projectId: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastState = ''; + while (Date.now() < deadline) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + try { + const resp = await ctx.get(`/api/x2a/projects/${projectId}`, { + headers, + }); + if (resp.status() === 401) { + invalidateToken(); + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + continue; + } + const data = await safeJson(resp); + if (data) { + lastState = + (data as { status?: { state?: string } })?.status?.state ?? 'unknown'; + if (['success', 'initialized', 'failed', 'error'].includes(lastState)) { + return lastState; + } + } + } finally { + await ctx.dispose(); + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + throw new Error( + `Project 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 }); + try { + const resp = await ctx.get(`/api/x2a/projects/${projectId}/modules`, { + headers, + }); + expect(resp.ok(), `Get modules failed: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + return data; + } finally { + await ctx.dispose(); + } +} + +async function triggerModulePhase( + baseURL: string, + projectId: string, + moduleId: string, + phase: 'analyze' | 'migrate' | 'publish', +) { + const headers = await apiHeaders(baseURL); + const ghToken = requireGitHubToken(); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + try { + const resp = await ctx.post( + `/api/x2a/projects/${projectId}/modules/${moduleId}/run`, + { + headers, + data: { + phase, + sourceRepoAuth: { token: ghToken }, + targetRepoAuth: { token: ghToken }, + }, + }, + ); + expect(resp.ok(), `Trigger ${phase} failed: ${resp.status()}`).toBeTruthy(); + const data = await resp.json(); + return data; + } finally { + await ctx.dispose(); + } +} + +async function pollModuleStatus( + baseURL: string, + projectId: string, + moduleId: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastStatus = ''; + while (Date.now() < deadline) { + const headers = await apiHeaders(baseURL); + const ctx = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + try { + const resp = await ctx.get( + `/api/x2a/projects/${projectId}/modules/${moduleId}`, + { headers }, + ); + if (resp.status() === 401) { + invalidateToken(); + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + continue; + } + if (resp.ok()) { + const data = await safeJson(resp); + if (data) { + lastStatus = (data as { status?: string })?.status ?? 'unknown'; + if (['success', 'failed', 'error'].includes(lastStatus)) { + return lastStatus; + } + } + } + } finally { + await ctx.dispose(); + } + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + 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 }); + try { + const resp = await ctx.delete(`/api/x2a/projects/${projectId}`, { + headers, + }); + return resp; + } finally { + await ctx.dispose(); + } +} + +// =========================================================================== +// Screenshot on failure — captures visual state for CI debugging +// =========================================================================== + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status !== testInfo.expectedStatus) { + const screenshot = await page.screenshot({ fullPage: true }); + await testInfo.attach('screenshot-on-failure', { + body: screenshot, + contentType: 'image/png', + }); + } +}); + +// =========================================================================== +// 1. Smoke Tests — UI basics (fast, no API pipeline) +// =========================================================================== + +test.describe('X2Ansible - UI Smoke Tests @smoke @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL || 'http://localhost:3000'; + let smokeProjectId = ''; + + test.beforeAll(async () => { + try { + const project = await createProject( + baseURL, + `smoke-seed-${Date.now().toString(36)}`, + ); + smokeProjectId = project.id; + } catch { + // Non-fatal: tests that need a project will fail individually + } + }); + + test.afterAll(async () => { + if (smokeProjectId) { + await deleteProject(baseURL, smokeProjectId).catch(() => {}); + } + }); + + test('Guest login renders RHDH welcome page', async ({ page }) => { + await performLogin(page); + await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible({ + timeout: 15_000, + }); + }); + + test('X2A sidebar link is visible and has correct href', async ({ page }) => { + await performLogin(page); + const x2aLink = page.locator('nav a[href*="x2a"], nav [href*="x2a"]'); + await expect(x2aLink.first()).toBeVisible({ timeout: 10_000 }); + const href = await x2aLink.first().getAttribute('href'); + expect(href, 'X2A sidebar link should point to /x2a').toContain('/x2a'); + }); + + test('Navigate to Conversion Hub via sidebar', async ({ page }) => { + const x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateFromSidebar(); + await x2aPage.verifyConversionHubPage(); + }); + + test('Navigate to Conversion Hub via direct URL', async ({ page }) => { + const x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + expect(page.url()).toContain('/x2a'); + }); + + test('Conversion Hub shows Projects table with expected columns', async ({ + page, + }) => { + const x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + + const projectsHeading = page.getByText(/Projects\s*\(\d+\)/); + await expect(projectsHeading).toBeVisible({ timeout: 10_000 }); + + const tableHeader = page.locator( + 'thead, [role="rowgroup"]:first-child, [class*="TableHead"], [class*="tableHead"]', + ); + for (const col of ['Name', 'Status', 'Source Repository']) { + const headerCell = tableHeader.getByText(col).first(); + const fallback = page + .locator('th, [role="columnheader"]') + .getByText(col) + .first(); + await expect(headerCell.or(fallback).first()).toBeVisible({ + timeout: 5_000, + }); + } + }); + + test('New Project / Start first conversion button is visible', async ({ + page, + }) => { + const x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + + const startFirst = page.getByRole('button', { + name: /start first conversion/i, + }); + const newProject = page.getByRole('button', { + name: /new project/i, + }); + await expect(startFirst.or(newProject).first()).toBeVisible({ + timeout: 10_000, + }); + }); + + // FLPATH-4413: Scaffolder RJSF form fields don't render on RHDH 1.10+ + test.skip('Scaffolder wizard loads with correct form fields', async ({ + page, + }) => { + const x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.clickStartConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + await expect(page.getByLabel('Name')).toBeVisible(); + await expect(page.getByLabel('Description')).toBeVisible(); + await expect(page.getByLabel('Abbreviation')).toBeVisible(); + await expect(page.getByLabel('Owned by group')).toBeVisible(); + + for (const stepLabel of [ + 'Job name and description', + 'Source and target repositories', + 'Conversion parameters', + 'Review', + ]) { + await expect(page.getByText(stepLabel)).toBeVisible(); + } + }); + + // FLPATH-4413: Scaffolder RJSF form fields don't render on RHDH 1.10+ + test.skip('Wizard step navigation — forward and back with field retention', async ({ + page, + }) => { + const x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.clickStartConversion(); + await x2aPage.verifyTemplateFormLoaded(); + + await x2aPage.fillProjectName('smoke-nav-test'); + await x2aPage.fillOwnedByGroup('guests'); + await x2aPage.clickNext(); + await x2aPage.verifyRepositoryStepVisible(); + await x2aPage.dismissGitHubLoginDialog(); + + await x2aPage.clickBack(); + await x2aPage.verifyTemplateFormLoaded(); + + // Verify previously entered data is retained after navigating back + const nameField = page.getByLabel('Name'); + await expect(nameField).toHaveValue('smoke-nav-test'); + }); +}); + +// =========================================================================== +// 2. Full E2E Pipeline — fastapi-tutorial (init → analyze → migrate → +// publish) with UI verification at every step. +// =========================================================================== + +test.describe.serial('X2Ansible - FastAPI Tutorial Full E2E @e2e @live', () => { + const baseURL = process.env.PLAYWRIGHT_URL || 'http://localhost:3000'; + let x2aPage: X2AnsiblePage; + let projectId = ''; + let projectName = ''; + let moduleId = ''; + + test.afterAll(async () => { + if (projectId) { + await deleteProject(baseURL, projectId).catch(() => {}); + } + }); + + // -- Init --------------------------------------------------------------- + + test('Create project, trigger init, discover fastapi-tutorial', async ({ + page, + }) => { + test.setTimeout(INIT_TIMEOUT + 60_000); + + const ts = Date.now().toString(36); + projectName = `x2a-e2e-${ts}`; + const project = await createProject(baseURL, projectName); + projectId = project.id; + + const initData = await triggerInit(baseURL, projectId); + expect(initData.jobId).toBeTruthy(); + + const finalState = await pollProjectState(baseURL, projectId, INIT_TIMEOUT); + expect( + ['success', 'initialized'].includes(finalState), + `Init failed, state=${finalState}`, + ).toBeTruthy(); + + const modules = await getModules(baseURL, projectId); + expect(modules.length).toBeGreaterThan(0); + + const fastapi = modules.find( + (m: { name: string }) => m.name === 'fastapi-tutorial', + ); + expect( + fastapi, + `fastapi-tutorial not found among: ${modules.map((m: { name: string }) => m.name).join(', ')}`, + ).toBeTruthy(); + moduleId = fastapi.id; + + // --- UI: verify project page shows the project and module list --- + await performLogin(page); + x2aPage = new X2AnsiblePage(page); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await x2aPage.waitForPageLoad(); + + await expect(page.getByText(projectName, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByText('fastapi-tutorial', { exact: true }), + ).toBeVisible({ + timeout: 30_000, + }); + + // Verify the project shows initialized/success status. + // Scope to the "Status" section to avoid matching branch chips. + const statusSection = page.locator('h2:has-text("Status")').locator('..'); + const statusText = + (await statusSection.textContent().catch(() => '')) ?? ''; + expect( + statusText.toLowerCase(), + `Expected initialized/success status, got: "${statusText}"`, + ).toMatch(/success|initialized|init/i); + }); + + // -- UI: project details ------------------------------------------------ + + test('Project page shows correct details and module list', async ({ + page, + }) => { + test.setTimeout(60_000); + expect(projectId, 'Project not created').toBeTruthy(); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.login(); + await page.goto(`/x2a/projects/${projectId}`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await x2aPage.waitForPageLoad(); + + await expect(page.getByText(projectName, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + // Source repo URL may be displayed truncated or without .git suffix + const repoName = SOURCE_REPO.replace(/\.git$/, '') + .split('/') + .pop()!; + await expect(page.getByText(repoName).first()).toBeVisible(); + + await expect( + page.getByText('fastapi-tutorial', { exact: true }), + ).toBeVisible(); + + // Verify breadcrumb navigates back to Conversion Hub + const breadcrumb = page.getByText('Conversion Hub').first(); + await expect(breadcrumb).toBeVisible(); + await breadcrumb.click(); + await page.waitForLoadState('domcontentloaded'); + expect(page.url()).toContain('/x2a'); + await expect(page.getByText('Conversion Hub').first()).toBeVisible({ + timeout: 10_000, + }); + }); + + // -- Analyze ------------------------------------------------------------ + + test('Analyze — run and verify Success in UI', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect(moduleId, 'Module not set — init failed').toBeTruthy(); + + const result = await triggerModulePhase( + baseURL, + projectId, + moduleId, + 'analyze', + ); + expect(result.jobId).toBeTruthy(); + + const status = await pollModuleStatus( + baseURL, + projectId, + moduleId, + PHASE_TIMEOUT, + ); + expect(status, `Analyze failed: status=${status}`).toBe('success'); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + await x2aPage.waitForPhaseStatus('Analyze', 'Success', 30_000); + }); + + // -- UI: module details after analyze ----------------------------------- + + test('Module page shows artifacts and details after Analyze', async ({ + page, + }) => { + test.setTimeout(60_000); + expect(moduleId, 'Module not set').toBeTruthy(); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + await expect( + page.getByText('fastapi-tutorial', { exact: true }), + ).toBeVisible({ + timeout: 30_000, + }); + + await expect( + page.getByRole('link', { name: /Module Migration Plan/i }), + ).toBeVisible({ timeout: 30_000 }); + + // breadcrumb navigation back to hub + await expect(page.getByText('Conversion Hub').first()).toBeVisible(); + + // phase tabs are present + for (const tab of ['Analyze', 'Migrate', 'Publish']) { + await expect( + page.locator(`[role="tab"]:has-text("${tab}")`), + ).toBeVisible(); + } + + const analyzeStatus = await x2aPage.getPhaseStatus('Analyze'); + expect(analyzeStatus).toContain('Success'); + }); + + // -- UI: phase log viewing ---------------------------------------------- + + test('Analyze log — View Log button is present and functional', async ({ + page, + }) => { + test.setTimeout(60_000); + expect(moduleId, 'Module not set').toBeTruthy(); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + await x2aPage.clickPhaseTab('Analyze'); + + const viewLogBtn = page.getByRole('button', { name: /view log/i }); + await expect(viewLogBtn).toBeVisible({ timeout: 10_000 }); + + // Click and expect navigation to a scaffolder task log page + const [newPage] = await Promise.all([ + page + .context() + .waitForEvent('page', { timeout: 5_000 }) + .catch(() => null), + viewLogBtn.click(), + ]); + + if (newPage) { + await newPage.waitForLoadState('domcontentloaded'); + expect(newPage.url()).toBeTruthy(); + await newPage.close(); + } else { + // Button navigated the current page — verify we landed somewhere + // meaningful (scaffolder task page or log-related URL), then go back + await page.waitForLoadState('domcontentloaded'); + const url = page.url(); + const isLogPage = + url.includes('task') || url.includes('log') || url.includes('job'); + if (!isLogPage) { + // If the URL doesn't indicate a log/task page, the button may have + // triggered an in-page expansion. Either way, the button worked. + } + await page.goBack(); + await page.waitForLoadState('domcontentloaded'); + } + }); + + // -- Migrate ------------------------------------------------------------ + + test('Migrate — run and verify Success in UI', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect(moduleId, 'Module not set').toBeTruthy(); + + const result = await triggerModulePhase( + baseURL, + projectId, + moduleId, + 'migrate', + ); + expect(result.jobId).toBeTruthy(); + + const status = await pollModuleStatus( + baseURL, + projectId, + moduleId, + PHASE_TIMEOUT, + ); + expect(status, `Migrate failed: status=${status}`).toBe('success'); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + await x2aPage.waitForPhaseStatus('Migrate', 'Success', 30_000); + }); + + test('Migrated Sources artifact appears after Migrate', async ({ page }) => { + test.setTimeout(60_000); + expect(moduleId, 'Module not set').toBeTruthy(); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + + await expect(page.getByText('Migrated Sources').first()).toBeVisible({ + timeout: 10_000, + }); + }); + + // -- Publish ------------------------------------------------------------ + + test('Publish — run and verify Success in UI', async ({ page }) => { + test.setTimeout(PHASE_TIMEOUT + 60_000); + expect(moduleId, 'Module not set').toBeTruthy(); + + const result = await triggerModulePhase( + baseURL, + projectId, + moduleId, + 'publish', + ); + expect(result.jobId).toBeTruthy(); + + const status = await pollModuleStatus( + baseURL, + projectId, + moduleId, + PHASE_TIMEOUT, + ); + expect(status, `Publish failed: status=${status}`).toBe('success'); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + await x2aPage.waitForPhaseStatus('Publish', 'Success', 30_000); + }); + + // -- Final verification ------------------------------------------------- + + test('All phases show Success and all artifacts are present', async ({ + page, + }) => { + test.setTimeout(60_000); + expect(moduleId, 'Module not set').toBeTruthy(); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToModulePage(projectId, moduleId); + + for (const phase of ['Analyze', 'Migrate', 'Publish'] as const) { + const status = await x2aPage.getPhaseStatus(phase); + expect(status, `${phase} should be Success`).toContain('Success'); + } + + await expect( + page.getByRole('link', { name: /Module Migration Plan/i }), + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('Migrated Sources').first()).toBeVisible({ + timeout: 10_000, + }); + }); + + // -- Project shows completed status in Conversion Hub ------------------- + + test('Conversion Hub reflects completed project status', async ({ page }) => { + test.setTimeout(60_000); + expect(projectId, 'Project not created').toBeTruthy(); + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + + // The project may not be on the first page of a paginated table. + // Sort by "Created At" descending (click twice if needed) to bring + // the newest project to the top, or search for it. + const createdAtHeader = page + .locator('th, [role="columnheader"]') + .filter({ hasText: 'Created At' }); + if (await createdAtHeader.isVisible().catch(() => false)) { + await createdAtHeader.click(); + await page.waitForTimeout(500); + await createdAtHeader.click(); + await page.waitForTimeout(500); + } + + // If still not visible, try the search box + const nameVisible = await page + .getByText(projectName, { exact: true }) + .first() + .isVisible() + .catch(() => false); + + if (!nameVisible) { + const searchBox = page.getByPlaceholder(/search/i).first(); + if (await searchBox.isVisible().catch(() => false)) { + await searchBox.fill(projectName); + await page.waitForTimeout(1000); + } + } + + await expect( + page.getByText(projectName, { exact: true }).first(), + ).toBeVisible({ timeout: 15_000 }); + + // Verify the project row has a status that reflects pipeline activity. + const projectRow = page + .locator('tr, [role="row"]') + .filter({ hasText: projectName }); + await expect(projectRow.first()).toBeVisible({ timeout: 10_000 }); + const rowText = (await projectRow.first().textContent()) ?? ''; + expect( + rowText.toLowerCase(), + `Expected project row to show active/completed status, got: "${rowText}"`, + ).toMatch(/in progress|success|complete|done|published/i); + }); + + // -- Delete project via API and verify removal from UI ------------------ + + test('Delete project and verify removal from Conversion Hub', async ({ + page, + }) => { + test.setTimeout(60_000); + const deletedProjectId = projectId; + expect(deletedProjectId, 'Project not created').toBeTruthy(); + + const resp = await deleteProject(baseURL, deletedProjectId); + expect(resp.ok(), `Delete failed: ${resp.status()}`).toBeTruthy(); + projectId = ''; + + x2aPage = new X2AnsiblePage(page); + await x2aPage.navigateToX2AByUrl(); + await x2aPage.verifyConversionHubPage(); + + await expect( + page.getByText(projectName, { exact: true }).first(), + ).not.toBeVisible({ timeout: 15_000 }); + + // Verify navigating to the deleted project URL shows error/not-found + await page.goto(`/x2a/projects/${deletedProjectId}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(2000); + const errorIndicator = page + .getByText(/not found|does not exist|error|404/i) + .first(); + const emptyState = page.getByText(projectName, { exact: true }).first(); + // Either an explicit error message OR the project name is gone + const showsError = await errorIndicator.isVisible().catch(() => false); + const nameGone = !(await emptyState.isVisible().catch(() => false)); + expect( + showsError || nameGone, + 'Deleted project URL should show error or not display project data', + ).toBeTruthy(); + }); +}); 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 00000000000..cba4e992266 --- /dev/null +++ b/workspaces/x2a/packages/app/e2e-tests/source-dir-resolution.test.ts @@ -0,0 +1,422 @@ +/* + * 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_TYPE = process.env.X2A_SOURCE_TYPE || 'chef'; +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: 'master', + 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', () => { + test.skip( + SOURCE_TYPE !== 'chef', + `FLPATH-4215 requires chef-examples-metadata repo (current stream: ${SOURCE_TYPE})`, + ); + + 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 ${SOURCE_REPO_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 ${SOURCE_REPO_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 3651f39c213..f627fde0a87 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: [ { diff --git a/workspaces/x2a/plugins/x2a-backend/config.d.ts b/workspaces/x2a/plugins/x2a-backend/config.d.ts index 098e00f0421..6bcc9709172 100644 --- a/workspaces/x2a/plugins/x2a-backend/config.d.ts +++ b/workspaces/x2a/plugins/x2a-backend/config.d.ts @@ -51,6 +51,12 @@ export interface X2AConfig { username?: string; password?: string; skipSSLVerification?: boolean; + /** + * Timeout in seconds for AAP project sync after publish. + * Increase for cross-datacenter deployments where receptor mesh latency is high. + * Defaults to 120 if not set (convertor default). + */ + syncTimeoutSeconds?: number; }; }; } @@ -237,6 +243,13 @@ export interface Config { * @visibility backend */ skipSSLVerification?: boolean; + /** + * Timeout in seconds for AAP project sync after publish. + * Increase for cross-datacenter deployments where receptor mesh latency is high. + * Defaults to 120 if not set (convertor default). + * @visibility backend + */ + syncTimeoutSeconds?: number; }; }; }; diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts index 8f743b662b4..be77d5bfbb5 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts @@ -96,6 +96,8 @@ export class JobResourceBuilder { // Resolve SSL verification: skip=false by default (SSL is verified) const skipSSL = config.credentials.aap?.skipSSLVerification ?? false; + const syncTimeout = config.credentials.aap?.syncTimeoutSeconds; + return { apiVersion: 'v1', kind: 'Secret', @@ -130,6 +132,11 @@ export class JobResourceBuilder { // AAP SSL verification setting (derived from skipSSLVerification config) AAP_VERIFY_SSL: String(!skipSSL), + + // AAP sync timeout (seconds) — convertor reads AAP_SYNC_TIMEOUT_S env var + ...(syncTimeout !== undefined + ? { AAP_SYNC_TIMEOUT_S: String(syncTimeout) } + : {}), }, }; }