import { exec as execCb } from 'child_process'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { promisify } from 'util'; import { GitlabClient } from '../../src/services/gitlabClient'; const execCbProm = promisify(execCb); /** * Execute a shell command, return stdout/stderr or throw on error including logs. */ async function execCmd(cmd: string): Promise<{ stdout: string; stderr: string }> { try { return await execCbProm(cmd); } catch (err: any) { const stdout = err.stdout ?? ''; const stderr = err.stderr ?? err.message ?? ''; throw new Error(`Command failed: ${cmd}\nstdout:\n${stdout}\nstderr:\n${stderr}`); } } jest.setTimeout(300_000); describe('E2E CLI codex-env-tools', () => { const GITLAB_URL = process.env.GITLAB_URL || 'https://gitlab.teleport.ftprod.fr'; const GITLAB_TOKEN = process.env.GITLAB_TOKEN; // par défaut pour les tests E2E const PROJECT_PATH = process.env.PROJECT_PATH || 'ftprod/ftprod-ai-dev-used-for-e2e'; if (!GITLAB_TOKEN) { test('skip E2E: missing GITLAB_TOKEN', () => { console.warn('Skipping E2E tests: set GITLAB_TOKEN env var'); }); return; } let issueIid: number; let tmpDir: string; let repoDir: string; beforeAll(async () => { // (Re)génération des certificats client via TeleportBot avant les appels API const TELEPORT_TOKEN = process.env.TELEPORT_TOKEN; if (TELEPORT_TOKEN) { await execCmd( `node ${path.resolve('dist/cli/index.js')} start-app --app gitlab --token ${TELEPORT_TOKEN}` ); } else { console.warn('TELEPORT_TOKEN non défini – en supposant certificats TLS déjà présents'); } // 1. Create a new GitLab issue for the test using mTLS via GitlabClient const clientApi = new GitlabClient({ token: GITLAB_TOKEN, url: GITLAB_URL }); const projectId = encodeURIComponent(PROJECT_PATH); const createRes = await clientApi.instance.post( `/projects/${projectId}/issues`, { title: 'E2E test - codex-env-tools', description: 'Création d’un fichier horodaté pour test E2E', } ); const issue = createRes.data as { iid?: unknown }; if (typeof issue.iid !== 'number') { throw new Error(`Invalid issue response: ${JSON.stringify(issue)}`); } issueIid = issue.iid; // 2. Clone repository into a temp directory tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-e2e-')); repoDir = path.join(tmpDir, 'repo'); const cloneUrl = `${GITLAB_URL.replace(/^https?:\/\//, '')}/${PROJECT_PATH}.git`; const certPath = path.join(os.homedir(), '.ftprod-ai', 'tbot', 'dest', 'tls.crt'); const keyPath = path.join(os.homedir(), '.ftprod-ai', 'tbot', 'dest', 'tls.key'); const host: string = GITLAB_URL.replace(/^https?:\/\//, ''); await execCmd( `git -c http.https://${host}.sslCert=${certPath} -c http.https://${host}.sslKey=${keyPath} clone https://oauth2:${GITLAB_TOKEN}@${cloneUrl} ${repoDir}` ); }); test('start-task → code change → send-task → verify MR', async () => { // Run start-task const startCmd = `node ${path.resolve('dist/cli/index.js')} start-task ${issueIid} \ --repoPath ${repoDir} \ --gitlabToken ${GITLAB_TOKEN} \ --gitlabUrl ${GITLAB_URL}`; const { stdout: startOut } = await execCmd(startCmd); const branchMatch = startOut.match(/Branche créée: (.+), commit SHA: ([0-9a-f]{7,40})/); expect(branchMatch).toBeTruthy(); const branch = branchMatch![1]; // Modify code: add a timestamped file const timestamp = Date.now().toString(); const filePath = path.join(repoDir, 'doc', 'tests', 'data', `touch-${timestamp}.md`); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `Test E2E – ${timestamp}`); await execCmd(`git -C ${repoDir} add ${filePath}`); await execCmd( `git -C ${repoDir} commit -m "E2E: ajout ${path.relative(repoDir, filePath)}"` ); // Push branch with mTLS client certificates const certPath2 = path.join(os.homedir(), '.ftprod-ai', 'tbot', 'dest', 'tls.crt'); const keyPath2 = path.join(os.homedir(), '.ftprod-ai', 'tbot', 'dest', 'tls.key'); const host2: string = GITLAB_URL.replace(/^https?:\/\//, ''); await execCmd( `git -C ${repoDir} -c http.https://${host2}.sslCert=${certPath2} -c http.https://${host2}.sslKey=${keyPath2} push origin ${branch}` ); // Run send-task const sendCmd = `node ${path.resolve('dist/cli/index.js')} send-task ${issueIid} \ --repoPath ${repoDir} \ --gitlabToken ${GITLAB_TOKEN} \ --gitlabUrl ${GITLAB_URL}`; const { stdout: sendOut } = await execCmd(sendCmd); const mrMatch = sendOut.match(/MR créée: !(\d+) \((.+)\), branche (.+) à ([0-9a-f]{7,40})/); expect(mrMatch).toBeTruthy(); const mrIid = parseInt(mrMatch![1], 10); // Verify MR via API using GitlabClient with mTLS const clientApi2 = new GitlabClient({ token: GITLAB_TOKEN, url: GITLAB_URL }); const encodedProject = encodeURIComponent(PROJECT_PATH); // Poll for merge request changes (diffs) as GitLab may lag let changed: string[] = []; const maxAttempts = 5; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const changesRes = await clientApi2.instance.get( `/projects/${encodedProject}/merge_requests/${mrIid}/changes` ); const changesData = changesRes.data as { changes: Array<{ new_path: string }> }; changed = changesData.changes.map((c) => c.new_path); if (changed.length > 0) break; // small delay before retry await new Promise((resolve) => setTimeout(resolve, 1000)); } expect(changed.some((p) => p.endsWith(`touch-${timestamp}.md`))).toBe(true); const mrInfoRes = await clientApi2.instance.get( `/projects/${encodedProject}/merge_requests/${mrIid}` ); const mrInfo = mrInfoRes.data as { state: string; description: string }; expect(mrInfo.state).toBe('opened'); expect(mrInfo.description).toMatch(new RegExp(`Closes #${issueIid}`)); }, 300_000); });